Skip to main content

drasi_source_dataverse/
lib.rs

1#![allow(unexpected_cfgs)]
2// Copyright 2026 The Drasi Authors.
3//
4// Licensed under the Apache License, Version 2.0 (the "License");
5// you may not use this file except in compliance with the License.
6// You may obtain a copy of the License at
7//
8//     http://www.apache.org/licenses/LICENSE-2.0
9//
10// Unless required by applicable law or agreed to in writing, software
11// distributed under the License is distributed on an "AS IS" BASIS,
12// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13// See the License for the specific language governing permissions and
14// limitations under the License.
15
16//! Dataverse Source Plugin for Drasi
17//!
18//! This plugin monitors Microsoft Dataverse tables for changes using OData
19//! change tracking, which is the Web API equivalent of the platform's
20//! `RetrieveEntityChangesRequest`. It supports:
21//!
22//! - **Polling-based change detection** using delta links (equivalent to `DataVersion`/`DataToken`)
23//! - **Adaptive backoff** matching the platform's SyncWorker pattern
24//! - **Per-entity workers** each tracking their own delta token
25//! - **OAuth2 authentication** via Azure AD / Microsoft Entra ID client credentials
26//!
27//! # Architecture Alignment with Platform Source
28//!
29//! This Rust/Web API implementation mirrors the platform's C# Dataverse source:
30//!
31//! | Platform (C#)                           | Drasi-Core (Rust)                         |
32//! |-----------------------------------------|-------------------------------------------|
33//! | `RetrieveEntityChangesRequest`          | OData `Prefer: odata.track-changes`       |
34//! | `DataVersion` / `DataToken`             | Delta token in `@odata.deltaLink`         |
35//! | `NewOrUpdatedItem`                      | Record without `$deletedEntity` context   |
36//! | `RemovedOrDeletedItem`                  | Record with `$deletedEntity` in context   |
37//! | `SyncWorker` (per-entity)               | Per-entity `tokio::spawn` task            |
38//! | `{entity}-deltatoken` state key         | Same state key format                     |
39//! | `ServiceClient`                         | `reqwest` HTTP client                     |
40//! | Adaptive backoff (500ms → scaled max) | Same adaptive backoff pattern             |
41//!
42//! # Configuration
43//!
44//! | Field                  | Type                      | Default   | Description                              |
45//! |------------------------|---------------------------|-----------|------------------------------------------|
46//! | `environment_url`      | String                    | required  | Dataverse environment URL                |
47//! | `tenant_id`            | String                    | required  | Azure AD tenant ID                       |
48//! | `client_id`            | String                    | required  | Azure AD application ID                  |
49//! | `client_secret`        | String                    | required  | Azure AD client secret                   |
50//! | `entities`             | Vec\<String\>             | required  | Entity logical names to monitor          |
51//! | `entity_set_overrides` | HashMap\<String, String\> | `{}`      | Override entity set name mapping         |
52//! | `entity_columns`       | HashMap\<String, Vec...\> | `{}`      | Per-entity column selection              |
53//! | `min_interval_ms`      | u64                       | `500`     | Minimum adaptive interval                |
54//! | `max_interval_seconds` | u64                       | `30`      | Per-entity max interval (sqrt-scaled by entity count) |
55//! | `api_version`          | String                    | `"v9.2"`  | Web API version                          |
56//!
57//! # Usage
58//!
59//! ```rust,ignore
60//! use drasi_source_dataverse::DataverseSource;
61//!
62//! let source = DataverseSource::builder("dv-source")
63//!     .with_environment_url("https://myorg.crm.dynamics.com")
64//!     .with_tenant_id("00000000-0000-0000-0000-000000000001")
65//!     .with_client_id("00000000-0000-0000-0000-000000000002")
66//!     .with_client_secret("my-client-secret")
67//!     .with_entities(vec!["account".to_string(), "contact".to_string()])
68//!     .build()?;
69//! ```
70
71pub 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
96/// Dataverse source plugin for polling-based change detection.
97///
98/// Uses OData change tracking (Web API equivalent of `RetrieveEntityChangesRequest`)
99/// to detect inserts, updates, and deletes in Microsoft Dataverse tables.
100///
101/// # Fields
102///
103/// - `base`: Common source functionality (dispatchers, status, lifecycle)
104/// - `config`: Dataverse-specific configuration (connection, entities, polling)
105pub struct DataverseSource {
106    /// Base source implementation providing common functionality.
107    base: SourceBase,
108    /// Dataverse source configuration.
109    config: DataverseSourceConfig,
110    /// Optional identity provider for token acquisition.
111    /// When set, takes precedence over config-based client credentials / Azure CLI.
112    identity_provider: Option<Box<dyn IdentityProvider>>,
113}
114
115impl DataverseSource {
116    /// Create a new Dataverse source.
117    ///
118    /// # Arguments
119    ///
120    /// * `id` - Unique identifier for this source instance
121    /// * `config` - Dataverse source configuration
122    ///
123    /// # Returns
124    ///
125    /// A new `DataverseSource` instance, or an error if construction fails.
126    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    /// Create a builder for `DataverseSource`.
137    ///
138    /// # Arguments
139    ///
140    /// * `id` - Unique identifier for this source instance
141    pub fn builder(id: impl Into<String>) -> DataverseSourceBuilder {
142        DataverseSourceBuilder::new(id)
143    }
144
145    /// Compute the OAuth2 scope for a Dataverse environment URL.
146    ///
147    /// Dataverse expects scopes of the form `<scheme>://<host>/.default`,
148    /// derived strictly from the environment URL's origin (any path or
149    /// trailing slash is dropped). When the URL fails to parse we fall back
150    /// to `<env>/.default`, mirroring the previous behaviour.
151    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    /// Compute the next polling interval given the current interval and whether
162    /// changes were observed in the most recent poll.
163    ///
164    /// Implements the platform's two-phase multiplicative backoff:
165    /// - On `changes_detected = true`, reset to `min_interval_ms` for responsive polling.
166    /// - On `changes_detected = false`, multiply by 1.2x while under the 5s threshold,
167    ///   then 1.5x above it, capped at `max_interval_ms`.
168    ///
169    /// Extracted as a pure function so the algorithm can be unit-tested.
170    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    /// State-store key for an entity's delta token. Format matches the platform's
192    /// `{entity}-deltatoken` checkpoint key for cross-implementation compatibility.
193    pub(crate) fn delta_token_key(entity_name: &str) -> String {
194        format!("{entity_name}-deltatoken")
195    }
196
197    /// Load a previously persisted delta token from the state store, if any.
198    ///
199    /// Returns `Some(token)` when the entry exists and is valid UTF-8;
200    /// `None` when the key is missing, the value is malformed, or the store errors.
201    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    /// Persist the latest delta token to the state store.
218    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    /// Run the polling loop for a single entity.
231    ///
232    /// This is the Rust equivalent of the platform's `SyncWorker.ExecuteAsync()`.
233    /// It implements the same adaptive backoff pattern:
234    /// - Starts at `min_interval_ms` (500ms default)
235    /// - Slow backoff (1.2x) under 5s threshold
236    /// - Fast backoff (1.5x) above 5s threshold
237    /// - Resets to minimum on any detected changes
238    #[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        // Load last delta token from state store (like platform's checkpoint resume)
263        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 no delta token, get the initial one by requesting change tracking.
275        // Retry with exponential backoff on failure instead of dying permanently.
276        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                // Check for shutdown before each attempt
282                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                        // Save the initial token
293                        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        // Main polling loop (mirrors platform's SyncWorker while loop)
319        loop {
320            // Check for shutdown signal
321            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                    // Save the new delta token (like platform's state store Put)
354                    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            // Wait for the current interval, with shutdown check
372            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    /// Get the initial delta token by performing the first change tracking request.
385    ///
386    /// Mirrors the platform's `GetCurrentDeltaToken()` which pages through all
387    /// existing data to get the latest DataToken.
388    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        // Page through all data to get to the end and get the latest token
398        // (matching platform's while loop in GetCurrentDeltaToken)
399        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    /// Poll for changes using the delta link.
413    ///
414    /// Returns the new delta link and the number of changes processed.
415    /// Mirrors the platform's `GetChanges(deltaToken)` method.
416    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        // Collect changes from all pages
439        let changes = parse_delta_changes(&response, entity_name);
440        all_changes.extend(changes);
441
442        // Follow pagination (matching platform's while(moreData) loop)
443        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        // Dispatch changes (matching platform's channel.Writer.WriteAsync pattern)
459        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    /// Convert a Dataverse change to a Drasi SourceChange.
484    ///
485    /// Maps the platform's `IChangedItem` classification:
486    /// - `NewOrUpdated` → `SourceChange::Update` (like platform's `ChangeOp.UPDATE`)
487    /// - `Deleted` → `SourceChange::Delete` (like platform's `ChangeOp.DELETE`)
488    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                // Convert JSON attributes to ElementPropertyMap
496                // Mirrors the platform's JsonEventMapper attribute processing
497                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                // Use `modifiedon` from the record for accurate ordering.
504                // Falls back to current time if the field is missing or unparseable.
505                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                    // Deleted records don't carry attributes, so use current time.
532                    effective_from: chrono::Utc::now().timestamp_millis().max(0) as u64,
533                };
534
535                SourceChange::Delete { metadata }
536            }
537        }
538    }
539
540    /// Convert a JSON value to an ElementValue.
541    ///
542    /// Handles Dataverse-specific value types, mirroring the platform's
543    /// `JsonEventMapper` which extracts `Value` from complex types like
544    /// `OptionSetValue` and `EntityReference`.
545    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                // Handle multi-select choice: [{"Value":1},{"Value":2}] -> [1,2]
561                // Mirrors platform's JsonEventMapper array handling
562                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                // Handle single value types: {"Value":123} -> 123
581                // Mirrors platform's JsonEventMapper object handling
582                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                // Convert object to ElementPropertyMap
588                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        // Do NOT expose client_secret in properties
623        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        // Create token manager and client
655        let base_url = self.config.environment_url.clone();
656
657        // Build the identity provider for token acquisition.
658        // Priority: explicit identity_provider > Azure CLI / Developer Tools > client credentials.
659        //
660        // Both internal paths delegate to `AzureIdentityProvider` (which wraps
661        // `azure_identity` from the Azure SDK for Rust) so token acquisition,
662        // caching, and refresh stay in one well-maintained place.
663        let provider: Arc<dyn IdentityProvider> = if let Some(ref ip) = self.identity_provider {
664            Arc::from(ip.clone_box())
665        } else if self.config.use_azure_cli {
666            log::info!(
667                "[{}] Using Azure developer tools (CLI / azd / VS) for authentication",
668                self.base.id
669            );
670            let azure_provider =
671                drasi_identity_azure::AzureIdentityProvider::with_default_credentials("dataverse")?
672                    .with_scope(Self::dataverse_scope(&base_url));
673            Arc::new(azure_provider)
674        } else {
675            let azure_provider = drasi_identity_azure::AzureIdentityProvider::with_client_secret(
676                "dataverse",
677                &self.config.tenant_id,
678                &self.config.client_id,
679                &self.config.client_secret,
680            )?
681            .with_scope(Self::dataverse_scope(&base_url));
682            Arc::new(azure_provider)
683        };
684
685        let client = Arc::new(DataverseClient::new(
686            &base_url,
687            &self.config.api_version,
688            provider,
689        ));
690
691        // Create shutdown channel (watch channel for multiple receivers)
692        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        // Calculate effective max interval using square root scaling based on
700        // entity count, matching the platform's ChangeMonitor.cs:
701        //   calculatedMaxIntervalMs = SingleEntityMaxIntervalMs * sqrt(entityCount)
702        // Examples: 1 entity = 30s, 5 entities = ~67s, 10 entities = ~95s
703        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        // Get instance_id from context for log routing
715        let instance_id = self
716            .base
717            .context()
718            .await
719            .map(|c| c.instance_id)
720            .unwrap_or_default();
721
722        // Spawn a worker task per entity (matching platform's SyncWorker pattern)
723        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        // Store the shutdown sender for stop()
767        // We use a combined task that waits for all workers
768        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        // Store a shutdown bridge so stop() can trigger shutdown via the watch channel
781        {
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        // Send shutdown signal through the bridge
816        if let Some(tx) = self.base.shutdown_tx.write().await.take() {
817            let _ = tx.send(());
818        }
819
820        // Wait for the combined task to finish
821        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
871/// Builder for `DataverseSource` instances.
872///
873/// Provides a fluent API for constructing Dataverse sources with sensible defaults.
874/// The builder takes the source ID at construction and returns a fully constructed
875/// `DataverseSource` from `build()`.
876///
877/// # Example
878///
879/// ```rust,ignore
880/// let source = DataverseSource::builder("dv-source")
881///     .with_environment_url("https://myorg.crm.dynamics.com")
882///     .with_tenant_id("tenant-id")
883///     .with_client_id("client-id")
884///     .with_client_secret("client-secret")
885///     .with_entities(vec!["account".to_string()])
886///     .build()?;
887/// ```
888pub struct DataverseSourceBuilder {
889    id: String,
890    environment_url: String,
891    tenant_id: String,
892    client_id: String,
893    client_secret: String,
894    use_azure_cli: bool,
895    entities: Vec<String>,
896    entity_set_overrides: HashMap<String, String>,
897    entity_columns: HashMap<String, Vec<String>>,
898    min_interval_ms: u64,
899    max_interval_seconds: u64,
900    api_version: String,
901    dispatch_mode: Option<DispatchMode>,
902    dispatch_buffer_capacity: Option<usize>,
903    bootstrap_provider: Option<Box<dyn drasi_lib::bootstrap::BootstrapProvider + 'static>>,
904    identity_provider: Option<Box<dyn IdentityProvider>>,
905    auto_start: bool,
906}
907
908impl DataverseSourceBuilder {
909    /// Create a new builder with the given source ID.
910    pub fn new(id: impl Into<String>) -> Self {
911        Self {
912            id: id.into(),
913            environment_url: String::new(),
914            tenant_id: String::new(),
915            client_id: String::new(),
916            client_secret: String::new(),
917            use_azure_cli: false,
918            entities: Vec::new(),
919            entity_set_overrides: HashMap::new(),
920            entity_columns: HashMap::new(),
921            min_interval_ms: 500,
922            max_interval_seconds: 30,
923            api_version: "v9.2".to_string(),
924            dispatch_mode: None,
925            dispatch_buffer_capacity: None,
926            bootstrap_provider: None,
927            identity_provider: None,
928            auto_start: true,
929        }
930    }
931
932    /// Set the Dataverse environment URL.
933    pub fn with_environment_url(mut self, url: impl Into<String>) -> Self {
934        self.environment_url = url.into();
935        self
936    }
937
938    /// Set the Azure AD tenant ID.
939    pub fn with_tenant_id(mut self, tenant_id: impl Into<String>) -> Self {
940        self.tenant_id = tenant_id.into();
941        self
942    }
943
944    /// Set the Azure AD client ID.
945    pub fn with_client_id(mut self, client_id: impl Into<String>) -> Self {
946        self.client_id = client_id.into();
947        self
948    }
949
950    /// Set the Azure AD client secret.
951    pub fn with_client_secret(mut self, client_secret: impl Into<String>) -> Self {
952        self.client_secret = client_secret.into();
953        self
954    }
955
956    /// Use Azure CLI for authentication instead of client credentials.
957    ///
958    /// When enabled, the source acquires tokens by running
959    /// `az account get-access-token --resource <environment_url>`.
960    /// Requires `az login` to have been run beforehand.
961    ///
962    /// This is the simplest auth option for local development and testing.
963    /// When using Azure CLI auth, `tenant_id`, `client_id`, and `client_secret` are not required.
964    pub fn with_azure_cli_auth(mut self) -> Self {
965        self.use_azure_cli = true;
966        self
967    }
968
969    /// Set an identity provider for token acquisition.
970    ///
971    /// When an identity provider is set, it takes precedence over
972    /// `tenant_id`/`client_id`/`client_secret` and `use_azure_cli`.
973    /// The provider's `get_credentials()` must return `Credentials::Token`.
974    ///
975    /// This enables using any of the platform's identity providers, including:
976    /// - `AzureIdentityProvider::with_client_secret(...)` for client credentials
977    /// - `AzureIdentityProvider::with_default_credentials(...)` for managed identity
978    /// - `AzureIdentityProvider::with_developer_tools(...)` for local dev (CLI, azd, PS)
979    /// - `AzureIdentityProvider::with_workload_identity(...)` for Kubernetes workloads
980    ///
981    /// # Example
982    ///
983    /// ```rust,ignore
984    /// use drasi_lib::identity::AzureIdentityProvider;
985    /// use drasi_source_dataverse::DataverseSource;
986    ///
987    /// let provider = AzureIdentityProvider::with_client_secret(
988    ///     "tenant-id", "client-id", "client-secret", "dataverse",
989    /// )?
990    /// .with_scope("https://myorg.crm.dynamics.com/.default");
991    ///
992    /// let source = DataverseSource::builder("dv-source")
993    ///     .with_environment_url("https://myorg.crm.dynamics.com")
994    ///     .with_entities(vec!["account".to_string()])
995    ///     .with_identity_provider(provider)
996    ///     .build()?;
997    /// ```
998    pub fn with_identity_provider(mut self, provider: impl IdentityProvider + 'static) -> Self {
999        self.identity_provider = Some(Box::new(provider));
1000        self
1001    }
1002
1003    /// Set the list of entity logical names to monitor.
1004    pub fn with_entities(mut self, entities: Vec<String>) -> Self {
1005        self.entities = entities;
1006        self
1007    }
1008
1009    /// Add a single entity to monitor.
1010    pub fn with_entity(mut self, entity: impl Into<String>) -> Self {
1011        self.entities.push(entity.into());
1012        self
1013    }
1014
1015    /// Override the entity set name for a specific entity.
1016    pub fn with_entity_set_override(
1017        mut self,
1018        entity_name: impl Into<String>,
1019        entity_set_name: impl Into<String>,
1020    ) -> Self {
1021        self.entity_set_overrides
1022            .insert(entity_name.into(), entity_set_name.into());
1023        self
1024    }
1025
1026    /// Set column selection for a specific entity.
1027    pub fn with_entity_columns(mut self, entity: impl Into<String>, columns: Vec<String>) -> Self {
1028        self.entity_columns.insert(entity.into(), columns);
1029        self
1030    }
1031
1032    /// Set the minimum adaptive polling interval in milliseconds.
1033    pub fn with_min_interval_ms(mut self, ms: u64) -> Self {
1034        self.min_interval_ms = ms;
1035        self
1036    }
1037
1038    /// Set the maximum adaptive polling interval in seconds.
1039    pub fn with_max_interval_seconds(mut self, seconds: u64) -> Self {
1040        self.max_interval_seconds = seconds;
1041        self
1042    }
1043
1044    /// Set the Dataverse Web API version.
1045    pub fn with_api_version(mut self, version: impl Into<String>) -> Self {
1046        self.api_version = version.into();
1047        self
1048    }
1049
1050    /// Set the dispatch mode.
1051    pub fn with_dispatch_mode(mut self, mode: DispatchMode) -> Self {
1052        self.dispatch_mode = Some(mode);
1053        self
1054    }
1055
1056    /// Set the dispatch buffer capacity.
1057    pub fn with_dispatch_buffer_capacity(mut self, capacity: usize) -> Self {
1058        self.dispatch_buffer_capacity = Some(capacity);
1059        self
1060    }
1061
1062    /// Set the bootstrap provider for initial data delivery.
1063    pub fn with_bootstrap_provider(
1064        mut self,
1065        provider: impl drasi_lib::bootstrap::BootstrapProvider + 'static,
1066    ) -> Self {
1067        self.bootstrap_provider = Some(Box::new(provider));
1068        self
1069    }
1070
1071    /// Set whether this source should auto-start when DrasiLib starts.
1072    pub fn with_auto_start(mut self, auto_start: bool) -> Self {
1073        self.auto_start = auto_start;
1074        self
1075    }
1076
1077    /// Build the `DataverseSource` instance.
1078    pub fn build(self) -> Result<DataverseSource> {
1079        let config = DataverseSourceConfig {
1080            environment_url: self.environment_url,
1081            tenant_id: self.tenant_id,
1082            client_id: self.client_id,
1083            client_secret: self.client_secret,
1084            use_azure_cli: self.use_azure_cli,
1085            entities: self.entities,
1086            entity_set_overrides: self.entity_set_overrides,
1087            entity_columns: self.entity_columns,
1088            min_interval_ms: self.min_interval_ms,
1089            max_interval_seconds: self.max_interval_seconds,
1090            api_version: self.api_version,
1091        };
1092
1093        // When an identity provider is supplied, skip client credential validation
1094        // (only environment_url and entities are required)
1095        if self.identity_provider.is_some() {
1096            config
1097                .validate_with_identity_provider()
1098                .map_err(|e| anyhow::anyhow!(e))?;
1099        } else {
1100            config.validate().map_err(|e| anyhow::anyhow!(e))?;
1101        }
1102
1103        let mut params = SourceBaseParams::new(&self.id).with_auto_start(self.auto_start);
1104        if let Some(mode) = self.dispatch_mode {
1105            params = params.with_dispatch_mode(mode);
1106        }
1107        if let Some(capacity) = self.dispatch_buffer_capacity {
1108            params = params.with_dispatch_buffer_capacity(capacity);
1109        }
1110        if let Some(provider) = self.bootstrap_provider {
1111            params = params.with_bootstrap_provider(provider);
1112        }
1113
1114        Ok(DataverseSource {
1115            base: SourceBase::new(params)?,
1116            config,
1117            identity_provider: self.identity_provider,
1118        })
1119    }
1120}
1121
1122#[cfg(test)]
1123mod tests {
1124    use super::*;
1125
1126    mod construction {
1127        use super::*;
1128
1129        #[test]
1130        fn test_builder_creates_source() {
1131            let source = DataverseSource::builder("dv-source")
1132                .with_environment_url("https://myorg.crm.dynamics.com")
1133                .with_tenant_id("tenant-1")
1134                .with_client_id("client-1")
1135                .with_client_secret("secret-1")
1136                .with_entities(vec!["account".to_string()])
1137                .build();
1138            assert!(source.is_ok());
1139        }
1140
1141        #[test]
1142        fn test_builder_fails_without_entities() {
1143            let source = DataverseSource::builder("dv-source")
1144                .with_environment_url("https://myorg.crm.dynamics.com")
1145                .with_tenant_id("tenant-1")
1146                .with_client_id("client-1")
1147                .with_client_secret("secret-1")
1148                .build();
1149            assert!(source.is_err());
1150        }
1151
1152        #[test]
1153        fn test_builder_fails_without_url() {
1154            let source = DataverseSource::builder("dv-source")
1155                .with_tenant_id("tenant-1")
1156                .with_client_id("client-1")
1157                .with_client_secret("secret-1")
1158                .with_entities(vec!["account".to_string()])
1159                .build();
1160            assert!(source.is_err());
1161        }
1162
1163        #[test]
1164        fn test_new_with_valid_config() {
1165            let config = DataverseSourceConfig {
1166                environment_url: "https://test.crm.dynamics.com".to_string(),
1167                tenant_id: "t".to_string(),
1168                client_id: "c".to_string(),
1169                client_secret: "s".to_string(),
1170                use_azure_cli: false,
1171                entities: vec!["account".to_string()],
1172                entity_set_overrides: HashMap::new(),
1173                entity_columns: HashMap::new(),
1174                min_interval_ms: 500,
1175                max_interval_seconds: 30,
1176                api_version: "v9.2".to_string(),
1177            };
1178            let source = DataverseSource::new("test-source", config);
1179            assert!(source.is_ok());
1180        }
1181    }
1182
1183    mod properties {
1184        use super::*;
1185
1186        #[test]
1187        fn test_id_returns_correct_value() {
1188            let source = DataverseSource::builder("my-dv-source")
1189                .with_environment_url("https://test.crm.dynamics.com")
1190                .with_tenant_id("t")
1191                .with_client_id("c")
1192                .with_client_secret("s")
1193                .with_entities(vec!["account".to_string()])
1194                .build()
1195                .expect("should build");
1196            assert_eq!(source.id(), "my-dv-source");
1197        }
1198
1199        #[test]
1200        fn test_type_name_returns_dataverse() {
1201            let source = DataverseSource::builder("test")
1202                .with_environment_url("https://test.crm.dynamics.com")
1203                .with_tenant_id("t")
1204                .with_client_id("c")
1205                .with_client_secret("s")
1206                .with_entities(vec!["account".to_string()])
1207                .build()
1208                .expect("should build");
1209            assert_eq!(source.type_name(), "dataverse");
1210        }
1211
1212        #[test]
1213        fn test_properties_does_not_expose_secret() {
1214            let source = DataverseSource::builder("test")
1215                .with_environment_url("https://test.crm.dynamics.com")
1216                .with_tenant_id("t")
1217                .with_client_id("c")
1218                .with_client_secret("super-secret-value")
1219                .with_entities(vec!["account".to_string()])
1220                .build()
1221                .expect("should build");
1222            let props = source.properties();
1223
1224            assert!(props.contains_key("environment_url"));
1225            assert!(props.contains_key("tenant_id"));
1226            assert!(props.contains_key("client_id"));
1227            assert!(props.contains_key("entities"));
1228            assert!(!props.contains_key("client_secret"));
1229        }
1230
1231        #[test]
1232        fn test_properties_contains_correct_values() {
1233            let source = DataverseSource::builder("test")
1234                .with_environment_url("https://myorg.crm.dynamics.com")
1235                .with_tenant_id("tenant-123")
1236                .with_client_id("client-456")
1237                .with_client_secret("s")
1238                .with_entities(vec!["account".to_string(), "contact".to_string()])
1239                .build()
1240                .expect("should build");
1241            let props = source.properties();
1242
1243            assert_eq!(
1244                props.get("environment_url"),
1245                Some(&serde_json::Value::String(
1246                    "https://myorg.crm.dynamics.com".to_string()
1247                ))
1248            );
1249            assert_eq!(
1250                props.get("tenant_id"),
1251                Some(&serde_json::Value::String("tenant-123".to_string()))
1252            );
1253        }
1254    }
1255
1256    mod lifecycle {
1257        use super::*;
1258
1259        #[tokio::test]
1260        async fn test_initial_status_is_stopped() {
1261            let source = DataverseSource::builder("test")
1262                .with_environment_url("https://test.crm.dynamics.com")
1263                .with_tenant_id("t")
1264                .with_client_id("c")
1265                .with_client_secret("s")
1266                .with_entities(vec!["account".to_string()])
1267                .build()
1268                .expect("should build");
1269            assert_eq!(source.status().await, ComponentStatus::Stopped);
1270        }
1271    }
1272
1273    mod builder {
1274        use super::*;
1275
1276        #[test]
1277        fn test_builder_defaults() {
1278            let source = DataverseSource::builder("test")
1279                .with_environment_url("https://test.crm.dynamics.com")
1280                .with_tenant_id("t")
1281                .with_client_id("c")
1282                .with_client_secret("s")
1283                .with_entities(vec!["account".to_string()])
1284                .build()
1285                .expect("should build");
1286
1287            assert_eq!(source.config.min_interval_ms, 500);
1288            assert_eq!(source.config.max_interval_seconds, 30);
1289            assert_eq!(source.config.api_version, "v9.2");
1290            assert!(source.identity_provider.is_none());
1291        }
1292
1293        #[test]
1294        fn test_builder_custom_values() {
1295            let source = DataverseSource::builder("test")
1296                .with_environment_url("https://custom.crm.dynamics.com")
1297                .with_tenant_id("custom-tenant")
1298                .with_client_id("custom-client")
1299                .with_client_secret("custom-secret")
1300                .with_entities(vec!["account".to_string()])
1301                .with_min_interval_ms(200)
1302                .with_max_interval_seconds(60)
1303                .with_api_version("v9.1")
1304                .build()
1305                .expect("should build");
1306
1307            assert_eq!(
1308                source.config.environment_url,
1309                "https://custom.crm.dynamics.com"
1310            );
1311            assert_eq!(source.config.min_interval_ms, 200);
1312            assert_eq!(source.config.max_interval_seconds, 60);
1313            assert_eq!(source.config.api_version, "v9.1");
1314        }
1315
1316        #[test]
1317        fn test_builder_with_entity() {
1318            let source = DataverseSource::builder("test")
1319                .with_environment_url("https://test.crm.dynamics.com")
1320                .with_tenant_id("t")
1321                .with_client_id("c")
1322                .with_client_secret("s")
1323                .with_entity("account")
1324                .with_entity("contact")
1325                .build()
1326                .expect("should build");
1327
1328            assert_eq!(source.config.entities, vec!["account", "contact"]);
1329        }
1330
1331        #[test]
1332        fn test_builder_with_entity_set_override() {
1333            let source = DataverseSource::builder("test")
1334                .with_environment_url("https://test.crm.dynamics.com")
1335                .with_tenant_id("t")
1336                .with_client_id("c")
1337                .with_client_secret("s")
1338                .with_entity("activityparty")
1339                .with_entity_set_override("activityparty", "activityparties")
1340                .build()
1341                .expect("should build");
1342
1343            assert_eq!(
1344                source.config.entity_set_name("activityparty"),
1345                "activityparties"
1346            );
1347        }
1348
1349        #[test]
1350        fn test_builder_with_entity_columns() {
1351            let source = DataverseSource::builder("test")
1352                .with_environment_url("https://test.crm.dynamics.com")
1353                .with_tenant_id("t")
1354                .with_client_id("c")
1355                .with_client_secret("s")
1356                .with_entity("account")
1357                .with_entity_columns("account", vec!["name".to_string(), "revenue".to_string()])
1358                .build()
1359                .expect("should build");
1360
1361            assert_eq!(
1362                source.config.select_columns("account"),
1363                Some("name,revenue,accountid".to_string())
1364            );
1365        }
1366
1367        #[test]
1368        fn test_builder_with_identity_provider() {
1369            // When an identity provider is set, client credentials are not required
1370            let provider = drasi_lib::identity::PasswordIdentityProvider::new("user", "token");
1371            let source = DataverseSource::builder("test")
1372                .with_environment_url("https://test.crm.dynamics.com")
1373                .with_entities(vec!["account".to_string()])
1374                .with_identity_provider(provider)
1375                .build()
1376                .expect("should build with identity provider and no client credentials");
1377
1378            assert!(source.identity_provider.is_some());
1379        }
1380
1381        #[test]
1382        fn test_builder_with_identity_provider_still_needs_url() {
1383            let provider = drasi_lib::identity::PasswordIdentityProvider::new("user", "token");
1384            let result = DataverseSource::builder("test")
1385                .with_entities(vec!["account".to_string()])
1386                .with_identity_provider(provider)
1387                .build();
1388            assert!(result.is_err(), "should fail without environment_url");
1389        }
1390
1391        #[test]
1392        fn test_builder_with_identity_provider_still_needs_entities() {
1393            let provider = drasi_lib::identity::PasswordIdentityProvider::new("user", "token");
1394            let result = DataverseSource::builder("test")
1395                .with_environment_url("https://test.crm.dynamics.com")
1396                .with_identity_provider(provider)
1397                .build();
1398            assert!(result.is_err(), "should fail without entities");
1399        }
1400    }
1401
1402    mod change_conversion {
1403        use super::*;
1404
1405        #[test]
1406        fn test_convert_new_or_updated() {
1407            let mut attributes = serde_json::Map::new();
1408            attributes.insert(
1409                "name".to_string(),
1410                serde_json::Value::String("Contoso".to_string()),
1411            );
1412            attributes.insert("revenue".to_string(), serde_json::json!(1000000.0));
1413            attributes.insert(
1414                "accountid".to_string(),
1415                serde_json::Value::String("abc-123".to_string()),
1416            );
1417
1418            let change = DataverseChange::NewOrUpdated {
1419                id: "abc-123".to_string(),
1420                entity_name: "account".to_string(),
1421                attributes,
1422            };
1423
1424            let source_change = DataverseSource::convert_to_source_change("test-source", &change);
1425            match source_change {
1426                SourceChange::Update { element } => match element {
1427                    Element::Node {
1428                        metadata,
1429                        properties,
1430                    } => {
1431                        assert_eq!(metadata.reference.element_id.as_ref(), "account:abc-123");
1432                        assert_eq!(metadata.reference.source_id.as_ref(), "test-source");
1433                        assert_eq!(metadata.labels.len(), 1);
1434                        assert_eq!(metadata.labels[0].as_ref(), "account");
1435                        assert!(properties.get("name").is_some());
1436                    }
1437                    _ => panic!("Expected Node element"),
1438                },
1439                _ => panic!("Expected Update change"),
1440            }
1441        }
1442
1443        #[test]
1444        fn test_convert_deleted() {
1445            let change = DataverseChange::Deleted {
1446                id: "def-456".to_string(),
1447                entity_name: "contact".to_string(),
1448            };
1449
1450            let source_change = DataverseSource::convert_to_source_change("test-source", &change);
1451            match source_change {
1452                SourceChange::Delete { metadata } => {
1453                    assert_eq!(metadata.reference.element_id.as_ref(), "contact:def-456");
1454                    assert_eq!(metadata.reference.source_id.as_ref(), "test-source");
1455                    assert_eq!(metadata.labels[0].as_ref(), "contact");
1456                }
1457                _ => panic!("Expected Delete change"),
1458            }
1459        }
1460
1461        #[test]
1462        fn test_convert_json_value_primitives() {
1463            assert_eq!(
1464                DataverseSource::convert_json_value(&serde_json::Value::Null),
1465                ElementValue::Null
1466            );
1467            assert_eq!(
1468                DataverseSource::convert_json_value(&serde_json::json!(true)),
1469                ElementValue::Bool(true)
1470            );
1471            assert_eq!(
1472                DataverseSource::convert_json_value(&serde_json::json!(42)),
1473                ElementValue::Integer(42)
1474            );
1475            assert_eq!(
1476                DataverseSource::convert_json_value(&serde_json::json!(3.15)),
1477                ElementValue::Float(ordered_float::OrderedFloat(3.15))
1478            );
1479            assert_eq!(
1480                DataverseSource::convert_json_value(&serde_json::json!("hello")),
1481                ElementValue::String(Arc::from("hello"))
1482            );
1483        }
1484
1485        #[test]
1486        fn test_convert_json_value_extracts_value() {
1487            // Single value type: {"Value": 123} -> 123
1488            let json = serde_json::json!({"Value": 123});
1489            assert_eq!(
1490                DataverseSource::convert_json_value(&json),
1491                ElementValue::Integer(123)
1492            );
1493        }
1494
1495        #[test]
1496        fn test_convert_json_value_multi_select_choice() {
1497            // Multi-select: [{"Value":1},{"Value":2}] -> [1,2]
1498            let json = serde_json::json!([{"Value": 1}, {"Value": 2}]);
1499            let result = DataverseSource::convert_json_value(&json);
1500            match result {
1501                ElementValue::List(values) => {
1502                    assert_eq!(values.len(), 2);
1503                    assert_eq!(values[0], ElementValue::Integer(1));
1504                    assert_eq!(values[1], ElementValue::Integer(2));
1505                }
1506                _ => panic!("Expected List"),
1507            }
1508        }
1509    }
1510
1511    mod backoff {
1512        use super::*;
1513
1514        #[test]
1515        fn resets_to_min_when_changes_detected() {
1516            // Even at very large current intervals, a change observation should
1517            // snap us back to min for responsive polling.
1518            let next = DataverseSource::next_backoff_interval(20_000, 500, 30_000, true);
1519            assert_eq!(next, 500);
1520        }
1521
1522        #[test]
1523        fn slow_backoff_under_threshold() {
1524            // Below 5s, multiplier is 1.2x.
1525            let next = DataverseSource::next_backoff_interval(1000, 500, 30_000, false);
1526            assert_eq!(next, 1200);
1527
1528            let next = DataverseSource::next_backoff_interval(4000, 500, 30_000, false);
1529            assert_eq!(next, 4800);
1530        }
1531
1532        #[test]
1533        fn fast_backoff_above_threshold() {
1534            // At/above 5s, multiplier is 1.5x.
1535            let next = DataverseSource::next_backoff_interval(5000, 500, 30_000, false);
1536            assert_eq!(next, 7500);
1537
1538            let next = DataverseSource::next_backoff_interval(10_000, 500, 30_000, false);
1539            assert_eq!(next, 15_000);
1540        }
1541
1542        #[test]
1543        fn does_not_exceed_max() {
1544            // Capped at the configured max.
1545            let next = DataverseSource::next_backoff_interval(25_000, 500, 30_000, false);
1546            assert_eq!(next, 30_000);
1547
1548            // Already at max; staying at max.
1549            let next = DataverseSource::next_backoff_interval(30_000, 500, 30_000, false);
1550            assert_eq!(next, 30_000);
1551        }
1552
1553        #[test]
1554        fn full_progression_no_changes() {
1555            // From min, repeatedly back off; verify the sequence reaches the cap
1556            // without ever overshooting.
1557            let min = 500;
1558            let max = 30_000;
1559            let mut current = min;
1560            let mut steps = 0;
1561            while current < max {
1562                let next = DataverseSource::next_backoff_interval(current, min, max, false);
1563                assert!(
1564                    next > current || next == max,
1565                    "interval should grow or hit the cap (was {current}, became {next})"
1566                );
1567                assert!(
1568                    next <= max,
1569                    "interval must never exceed max ({next} > {max})"
1570                );
1571                current = next;
1572                steps += 1;
1573                assert!(steps < 100, "backoff failed to converge");
1574            }
1575            assert_eq!(current, max);
1576        }
1577
1578        #[test]
1579        fn full_progression_with_change_resets() {
1580            // Back off twice, then observe a change: should drop straight back to min.
1581            let min = 500;
1582            let max = 30_000;
1583            let mut current = min;
1584            current = DataverseSource::next_backoff_interval(current, min, max, false);
1585            current = DataverseSource::next_backoff_interval(current, min, max, false);
1586            assert!(current > min);
1587            current = DataverseSource::next_backoff_interval(current, min, max, true);
1588            assert_eq!(current, min);
1589        }
1590    }
1591
1592    mod state_store_helpers {
1593        use super::*;
1594        use drasi_lib::MemoryStateStoreProvider;
1595
1596        fn store() -> Arc<dyn drasi_lib::StateStoreProvider> {
1597            Arc::new(MemoryStateStoreProvider::new())
1598        }
1599
1600        #[test]
1601        fn delta_token_key_matches_platform_format() {
1602            // The state-key format must remain `{entity}-deltatoken` for
1603            // cross-implementation checkpoint compatibility.
1604            assert_eq!(
1605                DataverseSource::delta_token_key("account"),
1606                "account-deltatoken"
1607            );
1608        }
1609
1610        #[tokio::test]
1611        async fn load_returns_none_on_empty_store() {
1612            let s = store();
1613            let result = DataverseSource::load_delta_token(&s, "src-1", "account").await;
1614            assert!(
1615                result.is_none(),
1616                "no checkpoint should be present initially"
1617            );
1618        }
1619
1620        #[tokio::test]
1621        async fn save_then_load_round_trip() {
1622            let s = store();
1623            DataverseSource::save_delta_token(&s, "src-1", "account", "delta-token-123").await;
1624
1625            let loaded = DataverseSource::load_delta_token(&s, "src-1", "account").await;
1626            assert_eq!(loaded.as_deref(), Some("delta-token-123"));
1627        }
1628
1629        #[tokio::test]
1630        async fn checkpoints_are_isolated_per_entity() {
1631            let s = store();
1632            DataverseSource::save_delta_token(&s, "src-1", "account", "token-A").await;
1633            DataverseSource::save_delta_token(&s, "src-1", "contact", "token-B").await;
1634
1635            assert_eq!(
1636                DataverseSource::load_delta_token(&s, "src-1", "account")
1637                    .await
1638                    .as_deref(),
1639                Some("token-A")
1640            );
1641            assert_eq!(
1642                DataverseSource::load_delta_token(&s, "src-1", "contact")
1643                    .await
1644                    .as_deref(),
1645                Some("token-B")
1646            );
1647        }
1648
1649        #[tokio::test]
1650        async fn checkpoints_are_isolated_per_source() {
1651            let s = store();
1652            DataverseSource::save_delta_token(&s, "src-1", "account", "token-A").await;
1653            DataverseSource::save_delta_token(&s, "src-2", "account", "token-B").await;
1654
1655            assert_eq!(
1656                DataverseSource::load_delta_token(&s, "src-1", "account")
1657                    .await
1658                    .as_deref(),
1659                Some("token-A")
1660            );
1661            assert_eq!(
1662                DataverseSource::load_delta_token(&s, "src-2", "account")
1663                    .await
1664                    .as_deref(),
1665                Some("token-B")
1666            );
1667        }
1668
1669        #[tokio::test]
1670        async fn save_overwrites_previous_value() {
1671            let s = store();
1672            DataverseSource::save_delta_token(&s, "src-1", "account", "token-old").await;
1673            DataverseSource::save_delta_token(&s, "src-1", "account", "token-new").await;
1674
1675            assert_eq!(
1676                DataverseSource::load_delta_token(&s, "src-1", "account")
1677                    .await
1678                    .as_deref(),
1679                Some("token-new")
1680            );
1681        }
1682
1683        #[tokio::test]
1684        async fn load_skips_invalid_utf8() {
1685            let s = store();
1686            let key = DataverseSource::delta_token_key("account");
1687            // Write deliberately invalid UTF-8 directly.
1688            s.set("src-1", &key, vec![0xff, 0xfe, 0xfd])
1689                .await
1690                .expect("set should succeed");
1691
1692            let loaded = DataverseSource::load_delta_token(&s, "src-1", "account").await;
1693            assert!(loaded.is_none(), "non-UTF8 stored value should be ignored");
1694        }
1695    }
1696}
1697
1698/// Dynamic plugin entry point.
1699#[cfg(feature = "dynamic-plugin")]
1700drasi_plugin_sdk::export_plugin!(
1701    plugin_id = "dataverse-source",
1702    core_version = env!("CARGO_PKG_VERSION"),
1703    lib_version = env!("CARGO_PKG_VERSION"),
1704    plugin_version = env!("CARGO_PKG_VERSION"),
1705    source_descriptors = [descriptor::DataverseSourceDescriptor],
1706    reaction_descriptors = [],
1707    bootstrap_descriptors = [],
1708);