Skip to main content

drasi_source_dataverse/
lib.rs

1// Copyright 2026 The Drasi Authors.
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15#![allow(unexpected_cfgs)]
16
17//! Dataverse Source Plugin for Drasi
18//!
19//! This plugin monitors Microsoft Dataverse tables for changes using OData
20//! change tracking, which is the Web API equivalent of the platform's
21//! `RetrieveEntityChangesRequest`. It supports:
22//!
23//! - **Polling-based change detection** using delta links (equivalent to `DataVersion`/`DataToken`)
24//! - **Adaptive backoff** matching the platform's SyncWorker pattern
25//! - **Per-entity workers** each tracking their own delta token
26//! - **OAuth2 authentication** via Azure AD / Microsoft Entra ID client credentials
27//!
28//! # Architecture Alignment with Platform Source
29//!
30//! This Rust/Web API implementation mirrors the platform's C# Dataverse source:
31//!
32//! | Platform (C#)                           | Drasi-Core (Rust)                         |
33//! |-----------------------------------------|-------------------------------------------|
34//! | `RetrieveEntityChangesRequest`          | OData `Prefer: odata.track-changes`       |
35//! | `DataVersion` / `DataToken`             | Delta token in `@odata.deltaLink`         |
36//! | `NewOrUpdatedItem`                      | Record without `$deletedEntity` context   |
37//! | `RemovedOrDeletedItem`                  | Record with `$deletedEntity` in context   |
38//! | `SyncWorker` (per-entity)               | Per-entity `tokio::spawn` task            |
39//! | `{entity}-deltatoken` state key         | Same state key format                     |
40//! | `ServiceClient`                         | `reqwest` HTTP client                     |
41//! | Adaptive backoff (500ms → scaled max) | Same adaptive backoff pattern             |
42//!
43//! # Configuration
44//!
45//! | Field                  | Type                      | Default   | Description                              |
46//! |------------------------|---------------------------|-----------|------------------------------------------|
47//! | `environment_url`      | String                    | required  | Dataverse environment URL                |
48//! | `tenant_id`            | String                    | required  | Azure AD tenant ID                       |
49//! | `client_id`            | String                    | required  | Azure AD application ID                  |
50//! | `client_secret`        | String                    | required  | Azure AD client secret                   |
51//! | `entities`             | Vec\<String\>             | required  | Entity logical names to monitor          |
52//! | `entity_set_overrides` | HashMap\<String, String\> | `{}`      | Override entity set name mapping         |
53//! | `entity_columns`       | HashMap\<String, Vec...\> | `{}`      | Per-entity column selection              |
54//! | `min_interval_ms`      | u64                       | `500`     | Minimum adaptive interval                |
55//! | `max_interval_seconds` | u64                       | `30`      | Per-entity max interval (sqrt-scaled by entity count) |
56//! | `api_version`          | String                    | `"v9.2"`  | Web API version                          |
57//!
58//! # Usage
59//!
60//! ```rust,ignore
61//! use drasi_source_dataverse::DataverseSource;
62//!
63//! let source = DataverseSource::builder("dv-source")
64//!     .with_environment_url("https://myorg.crm.dynamics.com")
65//!     .with_tenant_id("00000000-0000-0000-0000-000000000001")
66//!     .with_client_id("00000000-0000-0000-0000-000000000002")
67//!     .with_client_secret("my-client-secret")
68//!     .with_entities(vec!["account".to_string(), "contact".to_string()])
69//!     .build()?;
70//! ```
71
72pub 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
97/// Dataverse source plugin for polling-based change detection.
98///
99/// Uses OData change tracking (Web API equivalent of `RetrieveEntityChangesRequest`)
100/// to detect inserts, updates, and deletes in Microsoft Dataverse tables.
101///
102/// # Fields
103///
104/// - `base`: Common source functionality (dispatchers, status, lifecycle)
105/// - `config`: Dataverse-specific configuration (connection, entities, polling)
106pub struct DataverseSource {
107    /// Base source implementation providing common functionality.
108    base: SourceBase,
109    /// Dataverse source configuration.
110    config: DataverseSourceConfig,
111    /// Optional identity provider for token acquisition.
112    /// When set, takes precedence over config-based client credentials / Azure CLI.
113    identity_provider: Option<Box<dyn IdentityProvider>>,
114}
115
116impl DataverseSource {
117    /// Create a new Dataverse source.
118    ///
119    /// # Arguments
120    ///
121    /// * `id` - Unique identifier for this source instance
122    /// * `config` - Dataverse source configuration
123    ///
124    /// # Returns
125    ///
126    /// A new `DataverseSource` instance, or an error if construction fails.
127    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    /// Create a builder for `DataverseSource`.
138    ///
139    /// # Arguments
140    ///
141    /// * `id` - Unique identifier for this source instance
142    pub fn builder(id: impl Into<String>) -> DataverseSourceBuilder {
143        DataverseSourceBuilder::new(id)
144    }
145
146    /// Compute the OAuth2 scope for a Dataverse environment URL.
147    ///
148    /// Dataverse expects scopes of the form `<scheme>://<host>/.default`,
149    /// derived strictly from the environment URL's origin (any path or
150    /// trailing slash is dropped). When the URL fails to parse we fall back
151    /// to `<env>/.default`, mirroring the previous behaviour.
152    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    /// Compute the next polling interval given the current interval and whether
163    /// changes were observed in the most recent poll.
164    ///
165    /// Implements the platform's two-phase multiplicative backoff:
166    /// - On `changes_detected = true`, reset to `min_interval_ms` for responsive polling.
167    /// - On `changes_detected = false`, multiply by 1.2x while under the 5s threshold,
168    ///   then 1.5x above it, capped at `max_interval_ms`.
169    ///
170    /// Extracted as a pure function so the algorithm can be unit-tested.
171    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    /// State-store key for an entity's delta token. Format matches the platform's
193    /// `{entity}-deltatoken` checkpoint key for cross-implementation compatibility.
194    pub(crate) fn delta_token_key(entity_name: &str) -> String {
195        format!("{entity_name}-deltatoken")
196    }
197
198    /// Load a previously persisted delta token from the state store, if any.
199    ///
200    /// Returns `Some(token)` when the entry exists and is valid UTF-8;
201    /// `None` when the key is missing, the value is malformed, or the store errors.
202    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    /// Persist the latest delta token to the state store.
219    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    /// Run the polling loop for a single entity.
232    ///
233    /// This is the Rust equivalent of the platform's `SyncWorker.ExecuteAsync()`.
234    /// It implements the same adaptive backoff pattern:
235    /// - Starts at `min_interval_ms` (500ms default)
236    /// - Slow backoff (1.2x) under 5s threshold
237    /// - Fast backoff (1.5x) above 5s threshold
238    /// - Resets to minimum on any detected changes
239    #[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        dispatchers: Arc<
247            tokio::sync::RwLock<
248                Vec<
249                    Box<
250                        dyn drasi_lib::channels::ChangeDispatcher<SourceEventWrapper> + Send + Sync,
251                    >,
252                >,
253            >,
254        >,
255        state_store: Option<Arc<dyn drasi_lib::StateStoreProvider>>,
256        mut shutdown_rx: tokio::sync::watch::Receiver<bool>,
257        min_interval_ms: u64,
258        max_interval_seconds: u64,
259    ) {
260        let mut current_interval_ms = min_interval_ms;
261        let max_interval_ms = max_interval_seconds * 1000;
262
263        // Load last delta token from state store (like platform's checkpoint resume)
264        let mut delta_link: Option<String> = None;
265
266        if let Some(ref store) = state_store {
267            delta_link = Self::load_delta_token(store, &source_id, &entity_name).await;
268            if delta_link.is_some() {
269                log::info!("[{source_id}] Resuming from checkpoint for {entity_name}");
270            } else {
271                log::info!("[{source_id}] No checkpoint found for {entity_name}, getting current delta token");
272            }
273        }
274
275        // If no delta token, get the initial one by requesting change tracking.
276        // Retry with exponential backoff on failure instead of dying permanently.
277        if delta_link.is_none() {
278            let mut retry_interval_ms: u64 = 1000;
279            const MAX_RETRY_INTERVAL_MS: u64 = 60_000;
280
281            loop {
282                // Check for shutdown before each attempt
283                if *shutdown_rx.borrow() {
284                    log::info!("[{source_id}] Shutting down entity worker for {entity_name} during initial token acquisition");
285                    return;
286                }
287
288                match Self::get_initial_delta_token(&client, &entity_set_name, select.as_deref())
289                    .await
290                {
291                    Ok(token) => {
292                        log::info!("[{source_id}] Initial delta token obtained for {entity_name}");
293                        // Save the initial token
294                        if let Some(ref store) = state_store {
295                            Self::save_delta_token(store, &source_id, &entity_name, &token).await;
296                        }
297                        delta_link = Some(token);
298                        break;
299                    }
300                    Err(e) => {
301                        log::error!(
302                            "[{source_id}] Failed to get initial delta token for {entity_name}: {e}. Retrying in {retry_interval_ms}ms"
303                        );
304                        tokio::select! {
305                            _ = tokio::time::sleep(Duration::from_millis(retry_interval_ms)) => {}
306                            _ = shutdown_rx.changed() => {
307                                if *shutdown_rx.borrow() {
308                                    log::info!("[{source_id}] Shutting down entity worker for {entity_name}");
309                                    return;
310                                }
311                            }
312                        }
313                        retry_interval_ms = (retry_interval_ms * 2).min(MAX_RETRY_INTERVAL_MS);
314                    }
315                }
316            }
317        }
318
319        // Main polling loop (mirrors platform's SyncWorker while loop)
320        loop {
321            // Check for shutdown signal
322            if *shutdown_rx.borrow() {
323                log::info!("[{source_id}] Shutting down entity worker for {entity_name}");
324                break;
325            }
326
327            log::debug!(
328                "[{source_id}] Polling for changes in entity: {entity_name} (interval: {current_interval_ms}ms)"
329            );
330
331            match Self::poll_for_changes(
332                &source_id,
333                &entity_name,
334                &client,
335                delta_link.as_deref(),
336                &dispatchers,
337            )
338            .await
339            {
340                Ok((new_delta_link, change_count)) => {
341                    let changes_detected = change_count > 0;
342                    if changes_detected {
343                        log::info!(
344                            "[{source_id}] Got {change_count} changes for entity {entity_name}"
345                        );
346                    }
347                    current_interval_ms = Self::next_backoff_interval(
348                        current_interval_ms,
349                        min_interval_ms,
350                        max_interval_ms,
351                        changes_detected,
352                    );
353
354                    // Save the new delta token (like platform's state store Put)
355                    if let Some(ref dl) = new_delta_link {
356                        delta_link = Some(dl.clone());
357                        if let Some(ref store) = state_store {
358                            Self::save_delta_token(store, &source_id, &entity_name, dl).await;
359                        }
360                    }
361
362                    if changes_detected {
363                        continue;
364                    }
365                }
366                Err(e) => {
367                    log::error!("[{source_id}] Error polling entity {entity_name}: {e}");
368                    current_interval_ms = 5000;
369                }
370            }
371
372            // Wait for the current interval, with shutdown check
373            tokio::select! {
374                _ = tokio::time::sleep(Duration::from_millis(current_interval_ms)) => {}
375                _ = shutdown_rx.changed() => {
376                    if *shutdown_rx.borrow() {
377                        log::info!("[{source_id}] Shutting down entity worker for {entity_name}");
378                        break;
379                    }
380                }
381            }
382        }
383    }
384
385    /// Get the initial delta token by performing the first change tracking request.
386    ///
387    /// Mirrors the platform's `GetCurrentDeltaToken()` which pages through all
388    /// existing data to get the latest DataToken.
389    async fn get_initial_delta_token(
390        client: &DataverseClient,
391        entity_set_name: &str,
392        select: Option<&str>,
393    ) -> Result<String> {
394        let mut response = client
395            .initial_change_tracking(entity_set_name, select)
396            .await?;
397
398        // Page through all data to get to the end and get the latest token
399        // (matching platform's while loop in GetCurrentDeltaToken)
400        while response.next_link.is_some() {
401            let next = response
402                .next_link
403                .as_ref()
404                .expect("next_link checked above");
405            response = client.follow_next_link(next).await?;
406        }
407
408        response.delta_link.ok_or_else(|| {
409            anyhow::anyhow!("No delta link returned from initial change tracking request")
410        })
411    }
412
413    /// Poll for changes using the delta link.
414    ///
415    /// Returns the new delta link and the number of changes processed.
416    /// Mirrors the platform's `GetChanges(deltaToken)` method.
417    async fn poll_for_changes(
418        source_id: &str,
419        entity_name: &str,
420        client: &DataverseClient,
421        delta_link: Option<&str>,
422        dispatchers: &Arc<
423            tokio::sync::RwLock<
424                Vec<
425                    Box<
426                        dyn drasi_lib::channels::ChangeDispatcher<SourceEventWrapper> + Send + Sync,
427                    >,
428                >,
429            >,
430        >,
431    ) -> Result<(Option<String>, usize)> {
432        let delta_link =
433            delta_link.ok_or_else(|| anyhow::anyhow!("No delta link available for polling"))?;
434
435        let mut response = client.follow_delta_link(delta_link).await?;
436        let mut all_changes = Vec::new();
437        let mut final_delta_link = response.delta_link.clone();
438
439        // Collect changes from all pages
440        let changes = parse_delta_changes(&response, entity_name);
441        all_changes.extend(changes);
442
443        // Follow pagination (matching platform's while(moreData) loop)
444        while response.next_link.is_some() {
445            let next = response
446                .next_link
447                .as_ref()
448                .expect("next_link checked above");
449            response = client.follow_next_link(next).await?;
450            let changes = parse_delta_changes(&response, entity_name);
451            all_changes.extend(changes);
452            if response.delta_link.is_some() {
453                final_delta_link = response.delta_link.clone();
454            }
455        }
456
457        let change_count = all_changes.len();
458
459        // Dispatch changes (matching platform's channel.Writer.WriteAsync pattern)
460        for change in &all_changes {
461            let source_change = Self::convert_to_source_change(source_id, change);
462            let timestamp = chrono::Utc::now();
463
464            let mut profiling = drasi_lib::profiling::ProfilingMetadata::new();
465            profiling.source_send_ns = Some(drasi_lib::profiling::timestamp_ns());
466
467            let wrapper = SourceEventWrapper::with_profiling(
468                source_id.to_string(),
469                SourceEvent::Change(source_change),
470                timestamp,
471                profiling,
472            );
473
474            if let Err(e) =
475                SourceBase::dispatch_from_task(dispatchers.clone(), wrapper, source_id).await
476            {
477                log::error!("[{source_id}] Failed to dispatch change for {entity_name}: {e}");
478            }
479        }
480
481        Ok((final_delta_link, change_count))
482    }
483
484    /// Convert a Dataverse change to a Drasi SourceChange.
485    ///
486    /// Maps the platform's `IChangedItem` classification:
487    /// - `NewOrUpdated` → `SourceChange::Update` (like platform's `ChangeOp.UPDATE`)
488    /// - `Deleted` → `SourceChange::Delete` (like platform's `ChangeOp.DELETE`)
489    fn convert_to_source_change(source_id: &str, change: &DataverseChange) -> SourceChange {
490        match change {
491            DataverseChange::NewOrUpdated {
492                id,
493                entity_name,
494                attributes,
495            } => {
496                // Convert JSON attributes to ElementPropertyMap
497                // Mirrors the platform's JsonEventMapper attribute processing
498                let mut properties = ElementPropertyMap::new();
499                for (key, value) in attributes {
500                    let element_value = Self::convert_json_value(value);
501                    properties.insert(key, element_value);
502                }
503
504                // Use `modifiedon` from the record for accurate ordering.
505                // Falls back to current time if the field is missing or unparsable.
506                let effective_from = attributes
507                    .get("modifiedon")
508                    .and_then(|v| v.as_str())
509                    .and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok())
510                    .map(|dt| dt.timestamp_millis().max(0) as u64)
511                    .unwrap_or_else(|| chrono::Utc::now().timestamp_millis().max(0) as u64);
512
513                let element_id = format!("{entity_name}:{id}");
514                let metadata = ElementMetadata {
515                    reference: ElementReference::new(source_id, &element_id),
516                    labels: Arc::from(vec![Arc::from(entity_name.as_str())]),
517                    effective_from,
518                };
519
520                SourceChange::Update {
521                    element: Element::Node {
522                        metadata,
523                        properties,
524                    },
525                }
526            }
527            DataverseChange::Deleted { id, entity_name } => {
528                let element_id = format!("{entity_name}:{id}");
529                let metadata = ElementMetadata {
530                    reference: ElementReference::new(source_id, &element_id),
531                    labels: Arc::from(vec![Arc::from(entity_name.as_str())]),
532                    // Deleted records don't carry attributes, so use current time.
533                    effective_from: chrono::Utc::now().timestamp_millis().max(0) as u64,
534                };
535
536                SourceChange::Delete { metadata }
537            }
538        }
539    }
540
541    /// Convert a JSON value to an ElementValue.
542    ///
543    /// Handles Dataverse-specific value types, mirroring the platform's
544    /// `JsonEventMapper` which extracts `Value` from complex types like
545    /// `OptionSetValue` and `EntityReference`.
546    fn convert_json_value(value: &serde_json::Value) -> ElementValue {
547        match value {
548            serde_json::Value::Null => ElementValue::Null,
549            serde_json::Value::Bool(b) => ElementValue::Bool(*b),
550            serde_json::Value::Number(n) => {
551                if let Some(i) = n.as_i64() {
552                    ElementValue::Integer(i)
553                } else if let Some(f) = n.as_f64() {
554                    ElementValue::Float(ordered_float::OrderedFloat(f))
555                } else {
556                    ElementValue::Null
557                }
558            }
559            serde_json::Value::String(s) => ElementValue::String(Arc::from(s.as_str())),
560            serde_json::Value::Array(arr) => {
561                // Handle multi-select choice: [{"Value":1},{"Value":2}] -> [1,2]
562                // Mirrors platform's JsonEventMapper array handling
563                if !arr.is_empty() {
564                    if let Some(first_obj) = arr[0].as_object() {
565                        if first_obj.contains_key("Value") {
566                            let values: Vec<ElementValue> = arr
567                                .iter()
568                                .filter_map(|item| {
569                                    item.as_object()
570                                        .and_then(|obj| obj.get("Value"))
571                                        .map(Self::convert_json_value)
572                                })
573                                .collect();
574                            return ElementValue::List(values);
575                        }
576                    }
577                }
578                ElementValue::List(arr.iter().map(Self::convert_json_value).collect())
579            }
580            serde_json::Value::Object(obj) => {
581                // Handle single value types: {"Value":123} -> 123
582                // Mirrors platform's JsonEventMapper object handling
583                if obj.contains_key("Value") && obj.len() <= 2 {
584                    if let Some(val) = obj.get("Value") {
585                        return Self::convert_json_value(val);
586                    }
587                }
588                // Convert object to ElementPropertyMap
589                let mut map = ElementPropertyMap::new();
590                for (k, v) in obj {
591                    map.insert(k, Self::convert_json_value(v));
592                }
593                ElementValue::Object(map)
594            }
595        }
596    }
597}
598
599#[async_trait]
600impl Source for DataverseSource {
601    fn id(&self) -> &str {
602        &self.base.id
603    }
604
605    fn type_name(&self) -> &str {
606        "dataverse"
607    }
608
609    fn properties(&self) -> HashMap<String, serde_json::Value> {
610        let mut props = HashMap::new();
611        props.insert(
612            "environment_url".to_string(),
613            serde_json::Value::String(self.config.environment_url.clone()),
614        );
615        props.insert(
616            "tenant_id".to_string(),
617            serde_json::Value::String(self.config.tenant_id.clone()),
618        );
619        props.insert(
620            "client_id".to_string(),
621            serde_json::Value::String(self.config.client_id.clone()),
622        );
623        // Do NOT expose client_secret in properties
624        props.insert(
625            "entities".to_string(),
626            serde_json::Value::Array(
627                self.config
628                    .entities
629                    .iter()
630                    .map(|e| serde_json::Value::String(e.clone()))
631                    .collect(),
632            ),
633        );
634        props.insert(
635            "api_version".to_string(),
636            serde_json::Value::String(self.config.api_version.clone()),
637        );
638        props
639    }
640
641    fn auto_start(&self) -> bool {
642        self.base.get_auto_start()
643    }
644
645    async fn start(&self) -> Result<()> {
646        log::info!("[{}] Starting Dataverse source", self.base.id);
647
648        self.base
649            .set_status(
650                ComponentStatus::Starting,
651                Some("Starting Dataverse source".to_string()),
652            )
653            .await;
654
655        // Create token manager and client
656        let base_url = self.config.environment_url.clone();
657
658        // Build the identity provider for token acquisition.
659        // Priority:
660        //   1. Provider injected by the host via `set_identity_provider()` (e.g. drasi-server).
661        //   2. Provider supplied directly on the builder via `with_identity_provider()`.
662        //   3. Built-in client credentials (`tenant_id` / `client_id` / `client_secret`).
663        //
664        // For Azure CLI / developer tools / managed identity authentication,
665        // configure an Azure identity provider (`kind: azure`) and inject it
666        // via paths (1) or (2). The internal client-credentials path delegates
667        // to `AzureIdentityProvider` (which wraps `azure_identity` from the
668        // Azure SDK for Rust).
669        let provider: Arc<dyn IdentityProvider> = if let Some(injected) =
670            self.base.identity_provider().await
671        {
672            injected
673        } else if let Some(ref ip) = self.identity_provider {
674            Arc::from(ip.clone_box())
675        } else {
676            let azure_provider = drasi_identity_azure::AzureIdentityProvider::with_client_secret(
677                "dataverse",
678                &self.config.tenant_id,
679                &self.config.client_id,
680                &self.config.client_secret,
681            )?
682            .with_scope(Self::dataverse_scope(&base_url));
683            Arc::new(azure_provider)
684        };
685
686        let client = Arc::new(DataverseClient::new(
687            &base_url,
688            &self.config.api_version,
689            provider,
690        ));
691
692        // Create shutdown channel (watch channel for multiple receivers)
693        let (shutdown_tx, _) = tokio::sync::watch::channel(false);
694        let shutdown_tx = Arc::new(shutdown_tx);
695
696        let dispatchers = self.base.dispatchers.clone();
697        let state_store = self.base.state_store().await;
698        let source_id = self.base.id.clone();
699
700        // Calculate effective max interval using square root scaling based on
701        // entity count, matching the platform's ChangeMonitor.cs:
702        //   calculatedMaxIntervalMs = SingleEntityMaxIntervalMs * sqrt(entityCount)
703        // Examples: 1 entity = 30s, 5 entities = ~67s, 10 entities = ~95s
704        let entity_count = self.config.entities.len() as f64;
705        let effective_max_interval_seconds =
706            (self.config.max_interval_seconds as f64 * entity_count.sqrt()).max(1.0) as u64;
707        log::info!(
708            "[{}] Effective max polling interval: {}s (base {}s * sqrt({} entities))",
709            self.base.id,
710            effective_max_interval_seconds,
711            self.config.max_interval_seconds,
712            self.config.entities.len()
713        );
714
715        // Get instance_id from context for log routing
716        let instance_id = self
717            .base
718            .context()
719            .await
720            .map(|c| c.instance_id)
721            .unwrap_or_default();
722
723        // Spawn a worker task per entity (matching platform's SyncWorker pattern)
724        let mut task_handles = Vec::new();
725        for entity_name in &self.config.entities {
726            let entity_set_name = self.config.entity_set_name(entity_name);
727            let select = self.config.select_columns(entity_name);
728            let source_id = source_id.clone();
729            let entity_name = entity_name.clone();
730            let client = client.clone();
731            let dispatchers = dispatchers.clone();
732            let state_store = state_store.clone();
733            let shutdown_rx = shutdown_tx.subscribe();
734            let min_interval_ms = self.config.min_interval_ms;
735            let max_interval_seconds = effective_max_interval_seconds;
736            let instance_id = instance_id.clone();
737
738            let span = tracing::info_span!(
739                "dataverse_entity_worker",
740                instance_id = %instance_id,
741                component_id = %source_id,
742                component_type = "source",
743                entity = %entity_name
744            );
745
746            let handle = tokio::spawn(
747                async move {
748                    Self::run_entity_worker(
749                        source_id,
750                        entity_name,
751                        entity_set_name,
752                        select,
753                        client,
754                        dispatchers,
755                        state_store,
756                        shutdown_rx,
757                        min_interval_ms,
758                        max_interval_seconds,
759                    )
760                    .await;
761                }
762                .instrument(span),
763            );
764            task_handles.push(handle);
765        }
766
767        // Store the shutdown sender for stop()
768        // We use a combined task that waits for all workers
769        let source_id = self.base.id.clone();
770        let combined_handle = tokio::spawn(async move {
771            for (i, handle) in task_handles.into_iter().enumerate() {
772                if let Err(e) = handle.await {
773                    log::error!("[{source_id}] Entity worker {i} terminated with error: {e}");
774                }
775            }
776            log::info!("[{source_id}] All entity workers stopped");
777        });
778
779        *self.base.task_handle.write().await = Some(combined_handle);
780
781        // Store a shutdown bridge so stop() can trigger shutdown via the watch channel
782        {
783            let mut lock = self.base.shutdown_tx.write().await;
784            let shutdown_tx_for_stop = shutdown_tx.clone();
785            let (bridge_tx, bridge_rx) = tokio::sync::oneshot::channel::<()>();
786            tokio::spawn(async move {
787                let _ = bridge_rx.await;
788                let _ = shutdown_tx_for_stop.send(true);
789            });
790            *lock = Some(bridge_tx);
791        }
792
793        self.base
794            .set_status(
795                ComponentStatus::Running,
796                Some(format!(
797                    "Dataverse source running, monitoring {} entities",
798                    self.config.entities.len()
799                )),
800            )
801            .await;
802
803        Ok(())
804    }
805
806    async fn stop(&self) -> Result<()> {
807        log::info!("[{}] Stopping Dataverse source", self.base.id);
808
809        self.base
810            .set_status(
811                ComponentStatus::Stopping,
812                Some("Stopping Dataverse source".to_string()),
813            )
814            .await;
815
816        // Send shutdown signal through the bridge
817        if let Some(tx) = self.base.shutdown_tx.write().await.take() {
818            let _ = tx.send(());
819        }
820
821        // Wait for the combined task to finish
822        if let Some(handle) = self.base.task_handle.write().await.take() {
823            let mut handle = handle;
824            if tokio::time::timeout(Duration::from_secs(10), &mut handle)
825                .await
826                .is_err()
827            {
828                handle.abort();
829                let _ = handle.await;
830            }
831        }
832
833        self.base
834            .set_status(
835                ComponentStatus::Stopped,
836                Some("Dataverse source stopped".to_string()),
837            )
838            .await;
839
840        Ok(())
841    }
842
843    async fn status(&self) -> ComponentStatus {
844        self.base.get_status().await
845    }
846
847    async fn subscribe(
848        &self,
849        settings: drasi_lib::config::SourceSubscriptionSettings,
850    ) -> Result<drasi_lib::channels::SubscriptionResponse> {
851        self.base
852            .subscribe_with_bootstrap(&settings, "Dataverse")
853            .await
854    }
855
856    fn as_any(&self) -> &dyn std::any::Any {
857        self
858    }
859
860    async fn initialize(&self, context: drasi_lib::context::SourceRuntimeContext) {
861        self.base.initialize(context).await;
862    }
863
864    async fn set_bootstrap_provider(
865        &self,
866        provider: Box<dyn drasi_lib::bootstrap::BootstrapProvider + 'static>,
867    ) {
868        self.base.set_bootstrap_provider(provider).await;
869    }
870}
871
872/// Builder for `DataverseSource` instances.
873///
874/// Provides a fluent API for constructing Dataverse sources with sensible defaults.
875/// The builder takes the source ID at construction and returns a fully constructed
876/// `DataverseSource` from `build()`.
877///
878/// # Example
879///
880/// ```rust,ignore
881/// let source = DataverseSource::builder("dv-source")
882///     .with_environment_url("https://myorg.crm.dynamics.com")
883///     .with_tenant_id("tenant-id")
884///     .with_client_id("client-id")
885///     .with_client_secret("client-secret")
886///     .with_entities(vec!["account".to_string()])
887///     .build()?;
888/// ```
889pub struct DataverseSourceBuilder {
890    id: String,
891    environment_url: String,
892    tenant_id: String,
893    client_id: String,
894    client_secret: String,
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            entities: Vec::new(),
918            entity_set_overrides: HashMap::new(),
919            entity_columns: HashMap::new(),
920            min_interval_ms: 500,
921            max_interval_seconds: 30,
922            api_version: "v9.2".to_string(),
923            dispatch_mode: None,
924            dispatch_buffer_capacity: None,
925            bootstrap_provider: None,
926            identity_provider: None,
927            auto_start: true,
928        }
929    }
930
931    /// Set the Dataverse environment URL.
932    pub fn with_environment_url(mut self, url: impl Into<String>) -> Self {
933        self.environment_url = url.into();
934        self
935    }
936
937    /// Set the Azure AD tenant ID.
938    pub fn with_tenant_id(mut self, tenant_id: impl Into<String>) -> Self {
939        self.tenant_id = tenant_id.into();
940        self
941    }
942
943    /// Set the Azure AD client ID.
944    pub fn with_client_id(mut self, client_id: impl Into<String>) -> Self {
945        self.client_id = client_id.into();
946        self
947    }
948
949    /// Set the Azure AD client secret.
950    pub fn with_client_secret(mut self, client_secret: impl Into<String>) -> Self {
951        self.client_secret = client_secret.into();
952        self
953    }
954
955    /// Set an identity provider for token acquisition.
956    ///
957    /// When an identity provider is set, it takes precedence over
958    /// `tenant_id`/`client_id`/`client_secret`.
959    /// The provider's `get_credentials()` must return `Credentials::Token`.
960    ///
961    /// This enables using any of the platform's identity providers, including:
962    /// - `AzureIdentityProvider::with_client_secret(...)` for client credentials
963    /// - `AzureIdentityProvider::with_default_credentials(...)` for managed identity
964    /// - `AzureIdentityProvider::with_developer_tools(...)` for local dev (CLI, azd, PS)
965    /// - `AzureIdentityProvider::with_workload_identity(...)` for Kubernetes workloads
966    ///
967    /// # Example
968    ///
969    /// ```rust,ignore
970    /// use drasi_lib::identity::AzureIdentityProvider;
971    /// use drasi_source_dataverse::DataverseSource;
972    ///
973    /// let provider = AzureIdentityProvider::with_client_secret(
974    ///     "tenant-id", "client-id", "client-secret", "dataverse",
975    /// )?
976    /// .with_scope("https://myorg.crm.dynamics.com/.default");
977    ///
978    /// let source = DataverseSource::builder("dv-source")
979    ///     .with_environment_url("https://myorg.crm.dynamics.com")
980    ///     .with_entities(vec!["account".to_string()])
981    ///     .with_identity_provider(provider)
982    ///     .build()?;
983    /// ```
984    pub fn with_identity_provider(mut self, provider: impl IdentityProvider + 'static) -> Self {
985        self.identity_provider = Some(Box::new(provider));
986        self
987    }
988
989    /// Set the list of entity logical names to monitor.
990    pub fn with_entities(mut self, entities: Vec<String>) -> Self {
991        self.entities = entities;
992        self
993    }
994
995    /// Add a single entity to monitor.
996    pub fn with_entity(mut self, entity: impl Into<String>) -> Self {
997        self.entities.push(entity.into());
998        self
999    }
1000
1001    /// Override the entity set name for a specific entity.
1002    pub fn with_entity_set_override(
1003        mut self,
1004        entity_name: impl Into<String>,
1005        entity_set_name: impl Into<String>,
1006    ) -> Self {
1007        self.entity_set_overrides
1008            .insert(entity_name.into(), entity_set_name.into());
1009        self
1010    }
1011
1012    /// Set column selection for a specific entity.
1013    pub fn with_entity_columns(mut self, entity: impl Into<String>, columns: Vec<String>) -> Self {
1014        self.entity_columns.insert(entity.into(), columns);
1015        self
1016    }
1017
1018    /// Set the minimum adaptive polling interval in milliseconds.
1019    pub fn with_min_interval_ms(mut self, ms: u64) -> Self {
1020        self.min_interval_ms = ms;
1021        self
1022    }
1023
1024    /// Set the maximum adaptive polling interval in seconds.
1025    pub fn with_max_interval_seconds(mut self, seconds: u64) -> Self {
1026        self.max_interval_seconds = seconds;
1027        self
1028    }
1029
1030    /// Set the Dataverse Web API version.
1031    pub fn with_api_version(mut self, version: impl Into<String>) -> Self {
1032        self.api_version = version.into();
1033        self
1034    }
1035
1036    /// Set the dispatch mode.
1037    pub fn with_dispatch_mode(mut self, mode: DispatchMode) -> Self {
1038        self.dispatch_mode = Some(mode);
1039        self
1040    }
1041
1042    /// Set the dispatch buffer capacity.
1043    pub fn with_dispatch_buffer_capacity(mut self, capacity: usize) -> Self {
1044        self.dispatch_buffer_capacity = Some(capacity);
1045        self
1046    }
1047
1048    /// Set the bootstrap provider for initial data delivery.
1049    pub fn with_bootstrap_provider(
1050        mut self,
1051        provider: impl drasi_lib::bootstrap::BootstrapProvider + 'static,
1052    ) -> Self {
1053        self.bootstrap_provider = Some(Box::new(provider));
1054        self
1055    }
1056
1057    /// Set whether this source should auto-start when DrasiLib starts.
1058    pub fn with_auto_start(mut self, auto_start: bool) -> Self {
1059        self.auto_start = auto_start;
1060        self
1061    }
1062
1063    /// Build the `DataverseSource` instance.
1064    pub fn build(self) -> Result<DataverseSource> {
1065        let config = DataverseSourceConfig {
1066            environment_url: self.environment_url,
1067            tenant_id: self.tenant_id,
1068            client_id: self.client_id,
1069            client_secret: self.client_secret,
1070            entities: self.entities,
1071            entity_set_overrides: self.entity_set_overrides,
1072            entity_columns: self.entity_columns,
1073            min_interval_ms: self.min_interval_ms,
1074            max_interval_seconds: self.max_interval_seconds,
1075            api_version: self.api_version,
1076        };
1077
1078        // Auth resolution order:
1079        // 1. An identity provider was supplied directly on the builder → relaxed validation.
1080        // 2. No identity provider and no client credentials → assume a host
1081        //    (e.g. drasi-server) will inject an identity provider after
1082        //    `build()` via `set_identity_provider()`. Relaxed validation.
1083        // 3. Otherwise (built-in client credentials) → strict validation.
1084        let no_builtin_credentials = config.tenant_id.is_empty()
1085            && config.client_id.is_empty()
1086            && config.client_secret.is_empty();
1087        if self.identity_provider.is_some() || no_builtin_credentials {
1088            config
1089                .validate_with_identity_provider()
1090                .map_err(|e| anyhow::anyhow!(e))?;
1091        } else {
1092            config.validate().map_err(|e| anyhow::anyhow!(e))?;
1093        }
1094
1095        let mut params = SourceBaseParams::new(&self.id).with_auto_start(self.auto_start);
1096        if let Some(mode) = self.dispatch_mode {
1097            params = params.with_dispatch_mode(mode);
1098        }
1099        if let Some(capacity) = self.dispatch_buffer_capacity {
1100            params = params.with_dispatch_buffer_capacity(capacity);
1101        }
1102        if let Some(provider) = self.bootstrap_provider {
1103            params = params.with_bootstrap_provider(provider);
1104        }
1105
1106        Ok(DataverseSource {
1107            base: SourceBase::new(params)?,
1108            config,
1109            identity_provider: self.identity_provider,
1110        })
1111    }
1112}
1113
1114#[cfg(test)]
1115mod tests {
1116    use super::*;
1117
1118    mod construction {
1119        use super::*;
1120
1121        #[test]
1122        fn test_builder_creates_source() {
1123            let source = DataverseSource::builder("dv-source")
1124                .with_environment_url("https://myorg.crm.dynamics.com")
1125                .with_tenant_id("tenant-1")
1126                .with_client_id("client-1")
1127                .with_client_secret("secret-1")
1128                .with_entities(vec!["account".to_string()])
1129                .build();
1130            assert!(source.is_ok());
1131        }
1132
1133        #[test]
1134        fn test_builder_fails_without_entities() {
1135            let source = DataverseSource::builder("dv-source")
1136                .with_environment_url("https://myorg.crm.dynamics.com")
1137                .with_tenant_id("tenant-1")
1138                .with_client_id("client-1")
1139                .with_client_secret("secret-1")
1140                .build();
1141            assert!(source.is_err());
1142        }
1143
1144        #[test]
1145        fn test_builder_fails_without_url() {
1146            let source = DataverseSource::builder("dv-source")
1147                .with_tenant_id("tenant-1")
1148                .with_client_id("client-1")
1149                .with_client_secret("secret-1")
1150                .with_entities(vec!["account".to_string()])
1151                .build();
1152            assert!(source.is_err());
1153        }
1154
1155        #[test]
1156        fn test_new_with_valid_config() {
1157            let config = DataverseSourceConfig {
1158                environment_url: "https://test.crm.dynamics.com".to_string(),
1159                tenant_id: "t".to_string(),
1160                client_id: "c".to_string(),
1161                client_secret: "s".to_string(),
1162                entities: vec!["account".to_string()],
1163                entity_set_overrides: HashMap::new(),
1164                entity_columns: HashMap::new(),
1165                min_interval_ms: 500,
1166                max_interval_seconds: 30,
1167                api_version: "v9.2".to_string(),
1168            };
1169            let source = DataverseSource::new("test-source", config);
1170            assert!(source.is_ok());
1171        }
1172    }
1173
1174    mod properties {
1175        use super::*;
1176
1177        #[test]
1178        fn test_id_returns_correct_value() {
1179            let source = DataverseSource::builder("my-dv-source")
1180                .with_environment_url("https://test.crm.dynamics.com")
1181                .with_tenant_id("t")
1182                .with_client_id("c")
1183                .with_client_secret("s")
1184                .with_entities(vec!["account".to_string()])
1185                .build()
1186                .expect("should build");
1187            assert_eq!(source.id(), "my-dv-source");
1188        }
1189
1190        #[test]
1191        fn test_type_name_returns_dataverse() {
1192            let source = DataverseSource::builder("test")
1193                .with_environment_url("https://test.crm.dynamics.com")
1194                .with_tenant_id("t")
1195                .with_client_id("c")
1196                .with_client_secret("s")
1197                .with_entities(vec!["account".to_string()])
1198                .build()
1199                .expect("should build");
1200            assert_eq!(source.type_name(), "dataverse");
1201        }
1202
1203        #[test]
1204        fn test_properties_does_not_expose_secret() {
1205            let source = DataverseSource::builder("test")
1206                .with_environment_url("https://test.crm.dynamics.com")
1207                .with_tenant_id("t")
1208                .with_client_id("c")
1209                .with_client_secret("super-secret-value")
1210                .with_entities(vec!["account".to_string()])
1211                .build()
1212                .expect("should build");
1213            let props = source.properties();
1214
1215            assert!(props.contains_key("environment_url"));
1216            assert!(props.contains_key("tenant_id"));
1217            assert!(props.contains_key("client_id"));
1218            assert!(props.contains_key("entities"));
1219            assert!(!props.contains_key("client_secret"));
1220        }
1221
1222        #[test]
1223        fn test_properties_contains_correct_values() {
1224            let source = DataverseSource::builder("test")
1225                .with_environment_url("https://myorg.crm.dynamics.com")
1226                .with_tenant_id("tenant-123")
1227                .with_client_id("client-456")
1228                .with_client_secret("s")
1229                .with_entities(vec!["account".to_string(), "contact".to_string()])
1230                .build()
1231                .expect("should build");
1232            let props = source.properties();
1233
1234            assert_eq!(
1235                props.get("environment_url"),
1236                Some(&serde_json::Value::String(
1237                    "https://myorg.crm.dynamics.com".to_string()
1238                ))
1239            );
1240            assert_eq!(
1241                props.get("tenant_id"),
1242                Some(&serde_json::Value::String("tenant-123".to_string()))
1243            );
1244        }
1245    }
1246
1247    mod lifecycle {
1248        use super::*;
1249
1250        #[tokio::test]
1251        async fn test_initial_status_is_stopped() {
1252            let source = DataverseSource::builder("test")
1253                .with_environment_url("https://test.crm.dynamics.com")
1254                .with_tenant_id("t")
1255                .with_client_id("c")
1256                .with_client_secret("s")
1257                .with_entities(vec!["account".to_string()])
1258                .build()
1259                .expect("should build");
1260            assert_eq!(source.status().await, ComponentStatus::Stopped);
1261        }
1262    }
1263
1264    mod builder {
1265        use super::*;
1266
1267        #[test]
1268        fn test_builder_defaults() {
1269            let source = DataverseSource::builder("test")
1270                .with_environment_url("https://test.crm.dynamics.com")
1271                .with_tenant_id("t")
1272                .with_client_id("c")
1273                .with_client_secret("s")
1274                .with_entities(vec!["account".to_string()])
1275                .build()
1276                .expect("should build");
1277
1278            assert_eq!(source.config.min_interval_ms, 500);
1279            assert_eq!(source.config.max_interval_seconds, 30);
1280            assert_eq!(source.config.api_version, "v9.2");
1281            assert!(source.identity_provider.is_none());
1282        }
1283
1284        #[test]
1285        fn test_builder_custom_values() {
1286            let source = DataverseSource::builder("test")
1287                .with_environment_url("https://custom.crm.dynamics.com")
1288                .with_tenant_id("custom-tenant")
1289                .with_client_id("custom-client")
1290                .with_client_secret("custom-secret")
1291                .with_entities(vec!["account".to_string()])
1292                .with_min_interval_ms(200)
1293                .with_max_interval_seconds(60)
1294                .with_api_version("v9.1")
1295                .build()
1296                .expect("should build");
1297
1298            assert_eq!(
1299                source.config.environment_url,
1300                "https://custom.crm.dynamics.com"
1301            );
1302            assert_eq!(source.config.min_interval_ms, 200);
1303            assert_eq!(source.config.max_interval_seconds, 60);
1304            assert_eq!(source.config.api_version, "v9.1");
1305        }
1306
1307        #[test]
1308        fn test_builder_with_entity() {
1309            let source = DataverseSource::builder("test")
1310                .with_environment_url("https://test.crm.dynamics.com")
1311                .with_tenant_id("t")
1312                .with_client_id("c")
1313                .with_client_secret("s")
1314                .with_entity("account")
1315                .with_entity("contact")
1316                .build()
1317                .expect("should build");
1318
1319            assert_eq!(source.config.entities, vec!["account", "contact"]);
1320        }
1321
1322        #[test]
1323        fn test_builder_with_entity_set_override() {
1324            let source = DataverseSource::builder("test")
1325                .with_environment_url("https://test.crm.dynamics.com")
1326                .with_tenant_id("t")
1327                .with_client_id("c")
1328                .with_client_secret("s")
1329                .with_entity("activityparty")
1330                .with_entity_set_override("activityparty", "activityparties")
1331                .build()
1332                .expect("should build");
1333
1334            assert_eq!(
1335                source.config.entity_set_name("activityparty"),
1336                "activityparties"
1337            );
1338        }
1339
1340        #[test]
1341        fn test_builder_with_entity_columns() {
1342            let source = DataverseSource::builder("test")
1343                .with_environment_url("https://test.crm.dynamics.com")
1344                .with_tenant_id("t")
1345                .with_client_id("c")
1346                .with_client_secret("s")
1347                .with_entity("account")
1348                .with_entity_columns("account", vec!["name".to_string(), "revenue".to_string()])
1349                .build()
1350                .expect("should build");
1351
1352            assert_eq!(
1353                source.config.select_columns("account"),
1354                Some("name,revenue,accountid".to_string())
1355            );
1356        }
1357
1358        #[test]
1359        fn test_builder_with_identity_provider() {
1360            // When an identity provider is set, client credentials are not required
1361            let provider = drasi_lib::identity::PasswordIdentityProvider::new("user", "token");
1362            let source = DataverseSource::builder("test")
1363                .with_environment_url("https://test.crm.dynamics.com")
1364                .with_entities(vec!["account".to_string()])
1365                .with_identity_provider(provider)
1366                .build()
1367                .expect("should build with identity provider and no client credentials");
1368
1369            assert!(source.identity_provider.is_some());
1370        }
1371
1372        #[test]
1373        fn test_builder_with_identity_provider_still_needs_url() {
1374            let provider = drasi_lib::identity::PasswordIdentityProvider::new("user", "token");
1375            let result = DataverseSource::builder("test")
1376                .with_entities(vec!["account".to_string()])
1377                .with_identity_provider(provider)
1378                .build();
1379            assert!(result.is_err(), "should fail without environment_url");
1380        }
1381
1382        #[test]
1383        fn test_builder_with_identity_provider_still_needs_entities() {
1384            let provider = drasi_lib::identity::PasswordIdentityProvider::new("user", "token");
1385            let result = DataverseSource::builder("test")
1386                .with_environment_url("https://test.crm.dynamics.com")
1387                .with_identity_provider(provider)
1388                .build();
1389            assert!(result.is_err(), "should fail without entities");
1390        }
1391    }
1392
1393    mod change_conversion {
1394        use super::*;
1395
1396        #[test]
1397        fn test_convert_new_or_updated() {
1398            let mut attributes = serde_json::Map::new();
1399            attributes.insert(
1400                "name".to_string(),
1401                serde_json::Value::String("Contoso".to_string()),
1402            );
1403            attributes.insert("revenue".to_string(), serde_json::json!(1000000.0));
1404            attributes.insert(
1405                "accountid".to_string(),
1406                serde_json::Value::String("abc-123".to_string()),
1407            );
1408
1409            let change = DataverseChange::NewOrUpdated {
1410                id: "abc-123".to_string(),
1411                entity_name: "account".to_string(),
1412                attributes,
1413            };
1414
1415            let source_change = DataverseSource::convert_to_source_change("test-source", &change);
1416            match source_change {
1417                SourceChange::Update { element } => match element {
1418                    Element::Node {
1419                        metadata,
1420                        properties,
1421                    } => {
1422                        assert_eq!(metadata.reference.element_id.as_ref(), "account:abc-123");
1423                        assert_eq!(metadata.reference.source_id.as_ref(), "test-source");
1424                        assert_eq!(metadata.labels.len(), 1);
1425                        assert_eq!(metadata.labels[0].as_ref(), "account");
1426                        assert!(properties.get("name").is_some());
1427                    }
1428                    _ => panic!("Expected Node element"),
1429                },
1430                _ => panic!("Expected Update change"),
1431            }
1432        }
1433
1434        #[test]
1435        fn test_convert_deleted() {
1436            let change = DataverseChange::Deleted {
1437                id: "def-456".to_string(),
1438                entity_name: "contact".to_string(),
1439            };
1440
1441            let source_change = DataverseSource::convert_to_source_change("test-source", &change);
1442            match source_change {
1443                SourceChange::Delete { metadata } => {
1444                    assert_eq!(metadata.reference.element_id.as_ref(), "contact:def-456");
1445                    assert_eq!(metadata.reference.source_id.as_ref(), "test-source");
1446                    assert_eq!(metadata.labels[0].as_ref(), "contact");
1447                }
1448                _ => panic!("Expected Delete change"),
1449            }
1450        }
1451
1452        #[test]
1453        fn test_convert_json_value_primitives() {
1454            assert_eq!(
1455                DataverseSource::convert_json_value(&serde_json::Value::Null),
1456                ElementValue::Null
1457            );
1458            assert_eq!(
1459                DataverseSource::convert_json_value(&serde_json::json!(true)),
1460                ElementValue::Bool(true)
1461            );
1462            assert_eq!(
1463                DataverseSource::convert_json_value(&serde_json::json!(42)),
1464                ElementValue::Integer(42)
1465            );
1466            assert_eq!(
1467                DataverseSource::convert_json_value(&serde_json::json!(3.15)),
1468                ElementValue::Float(ordered_float::OrderedFloat(3.15))
1469            );
1470            assert_eq!(
1471                DataverseSource::convert_json_value(&serde_json::json!("hello")),
1472                ElementValue::String(Arc::from("hello"))
1473            );
1474        }
1475
1476        #[test]
1477        fn test_convert_json_value_extracts_value() {
1478            // Single value type: {"Value": 123} -> 123
1479            let json = serde_json::json!({"Value": 123});
1480            assert_eq!(
1481                DataverseSource::convert_json_value(&json),
1482                ElementValue::Integer(123)
1483            );
1484        }
1485
1486        #[test]
1487        fn test_convert_json_value_multi_select_choice() {
1488            // Multi-select: [{"Value":1},{"Value":2}] -> [1,2]
1489            let json = serde_json::json!([{"Value": 1}, {"Value": 2}]);
1490            let result = DataverseSource::convert_json_value(&json);
1491            match result {
1492                ElementValue::List(values) => {
1493                    assert_eq!(values.len(), 2);
1494                    assert_eq!(values[0], ElementValue::Integer(1));
1495                    assert_eq!(values[1], ElementValue::Integer(2));
1496                }
1497                _ => panic!("Expected List"),
1498            }
1499        }
1500    }
1501
1502    mod backoff {
1503        use super::*;
1504
1505        #[test]
1506        fn resets_to_min_when_changes_detected() {
1507            // Even at very large current intervals, a change observation should
1508            // snap us back to min for responsive polling.
1509            let next = DataverseSource::next_backoff_interval(20_000, 500, 30_000, true);
1510            assert_eq!(next, 500);
1511        }
1512
1513        #[test]
1514        fn slow_backoff_under_threshold() {
1515            // Below 5s, multiplier is 1.2x.
1516            let next = DataverseSource::next_backoff_interval(1000, 500, 30_000, false);
1517            assert_eq!(next, 1200);
1518
1519            let next = DataverseSource::next_backoff_interval(4000, 500, 30_000, false);
1520            assert_eq!(next, 4800);
1521        }
1522
1523        #[test]
1524        fn fast_backoff_above_threshold() {
1525            // At/above 5s, multiplier is 1.5x.
1526            let next = DataverseSource::next_backoff_interval(5000, 500, 30_000, false);
1527            assert_eq!(next, 7500);
1528
1529            let next = DataverseSource::next_backoff_interval(10_000, 500, 30_000, false);
1530            assert_eq!(next, 15_000);
1531        }
1532
1533        #[test]
1534        fn does_not_exceed_max() {
1535            // Capped at the configured max.
1536            let next = DataverseSource::next_backoff_interval(25_000, 500, 30_000, false);
1537            assert_eq!(next, 30_000);
1538
1539            // Already at max; staying at max.
1540            let next = DataverseSource::next_backoff_interval(30_000, 500, 30_000, false);
1541            assert_eq!(next, 30_000);
1542        }
1543
1544        #[test]
1545        fn full_progression_no_changes() {
1546            // From min, repeatedly back off; verify the sequence reaches the cap
1547            // without ever overshooting.
1548            let min = 500;
1549            let max = 30_000;
1550            let mut current = min;
1551            let mut steps = 0;
1552            while current < max {
1553                let next = DataverseSource::next_backoff_interval(current, min, max, false);
1554                assert!(
1555                    next > current || next == max,
1556                    "interval should grow or hit the cap (was {current}, became {next})"
1557                );
1558                assert!(
1559                    next <= max,
1560                    "interval must never exceed max ({next} > {max})"
1561                );
1562                current = next;
1563                steps += 1;
1564                assert!(steps < 100, "backoff failed to converge");
1565            }
1566            assert_eq!(current, max);
1567        }
1568
1569        #[test]
1570        fn full_progression_with_change_resets() {
1571            // Back off twice, then observe a change: should drop straight back to min.
1572            let min = 500;
1573            let max = 30_000;
1574            let mut current = min;
1575            current = DataverseSource::next_backoff_interval(current, min, max, false);
1576            current = DataverseSource::next_backoff_interval(current, min, max, false);
1577            assert!(current > min);
1578            current = DataverseSource::next_backoff_interval(current, min, max, true);
1579            assert_eq!(current, min);
1580        }
1581    }
1582
1583    mod state_store_helpers {
1584        use super::*;
1585        use drasi_lib::MemoryStateStoreProvider;
1586
1587        fn store() -> Arc<dyn drasi_lib::StateStoreProvider> {
1588            Arc::new(MemoryStateStoreProvider::new())
1589        }
1590
1591        #[test]
1592        fn delta_token_key_matches_platform_format() {
1593            // The state-key format must remain `{entity}-deltatoken` for
1594            // cross-implementation checkpoint compatibility.
1595            assert_eq!(
1596                DataverseSource::delta_token_key("account"),
1597                "account-deltatoken"
1598            );
1599        }
1600
1601        #[tokio::test]
1602        async fn load_returns_none_on_empty_store() {
1603            let s = store();
1604            let result = DataverseSource::load_delta_token(&s, "src-1", "account").await;
1605            assert!(
1606                result.is_none(),
1607                "no checkpoint should be present initially"
1608            );
1609        }
1610
1611        #[tokio::test]
1612        async fn save_then_load_round_trip() {
1613            let s = store();
1614            DataverseSource::save_delta_token(&s, "src-1", "account", "delta-token-123").await;
1615
1616            let loaded = DataverseSource::load_delta_token(&s, "src-1", "account").await;
1617            assert_eq!(loaded.as_deref(), Some("delta-token-123"));
1618        }
1619
1620        #[tokio::test]
1621        async fn checkpoints_are_isolated_per_entity() {
1622            let s = store();
1623            DataverseSource::save_delta_token(&s, "src-1", "account", "token-A").await;
1624            DataverseSource::save_delta_token(&s, "src-1", "contact", "token-B").await;
1625
1626            assert_eq!(
1627                DataverseSource::load_delta_token(&s, "src-1", "account")
1628                    .await
1629                    .as_deref(),
1630                Some("token-A")
1631            );
1632            assert_eq!(
1633                DataverseSource::load_delta_token(&s, "src-1", "contact")
1634                    .await
1635                    .as_deref(),
1636                Some("token-B")
1637            );
1638        }
1639
1640        #[tokio::test]
1641        async fn checkpoints_are_isolated_per_source() {
1642            let s = store();
1643            DataverseSource::save_delta_token(&s, "src-1", "account", "token-A").await;
1644            DataverseSource::save_delta_token(&s, "src-2", "account", "token-B").await;
1645
1646            assert_eq!(
1647                DataverseSource::load_delta_token(&s, "src-1", "account")
1648                    .await
1649                    .as_deref(),
1650                Some("token-A")
1651            );
1652            assert_eq!(
1653                DataverseSource::load_delta_token(&s, "src-2", "account")
1654                    .await
1655                    .as_deref(),
1656                Some("token-B")
1657            );
1658        }
1659
1660        #[tokio::test]
1661        async fn save_overwrites_previous_value() {
1662            let s = store();
1663            DataverseSource::save_delta_token(&s, "src-1", "account", "token-old").await;
1664            DataverseSource::save_delta_token(&s, "src-1", "account", "token-new").await;
1665
1666            assert_eq!(
1667                DataverseSource::load_delta_token(&s, "src-1", "account")
1668                    .await
1669                    .as_deref(),
1670                Some("token-new")
1671            );
1672        }
1673
1674        #[tokio::test]
1675        async fn load_skips_invalid_utf8() {
1676            let s = store();
1677            let key = DataverseSource::delta_token_key("account");
1678            // Write deliberately invalid UTF-8 directly.
1679            s.set("src-1", &key, vec![0xff, 0xfe, 0xfd])
1680                .await
1681                .expect("set should succeed");
1682
1683            let loaded = DataverseSource::load_delta_token(&s, "src-1", "account").await;
1684            assert!(loaded.is_none(), "non-UTF8 stored value should be ignored");
1685        }
1686    }
1687}
1688
1689/// Dynamic plugin entry point.
1690#[cfg(feature = "dynamic-plugin")]
1691drasi_plugin_sdk::export_plugin!(
1692    plugin_id = "dataverse-source",
1693    core_version = env!("CARGO_PKG_VERSION"),
1694    lib_version = env!("CARGO_PKG_VERSION"),
1695    plugin_version = env!("CARGO_PKG_VERSION"),
1696    source_descriptors = [descriptor::DataverseSourceDescriptor],
1697    reaction_descriptors = [],
1698    bootstrap_descriptors = [],
1699);