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:
659        //   1. Provider injected by the host via `set_identity_provider()` (e.g. drasi-server).
660        //   2. Provider supplied directly on the builder via `with_identity_provider()`.
661        //   3. Built-in client credentials (`tenant_id` / `client_id` / `client_secret`).
662        //
663        // For Azure CLI / developer tools / managed identity authentication,
664        // configure an Azure identity provider (`kind: azure`) and inject it
665        // via paths (1) or (2). The internal client-credentials path delegates
666        // to `AzureIdentityProvider` (which wraps `azure_identity` from the
667        // Azure SDK for Rust).
668        let provider: Arc<dyn IdentityProvider> = if let Some(injected) =
669            self.base.identity_provider().await
670        {
671            injected
672        } else if let Some(ref ip) = self.identity_provider {
673            Arc::from(ip.clone_box())
674        } else {
675            let azure_provider = drasi_identity_azure::AzureIdentityProvider::with_client_secret(
676                "dataverse",
677                &self.config.tenant_id,
678                &self.config.client_id,
679                &self.config.client_secret,
680            )?
681            .with_scope(Self::dataverse_scope(&base_url));
682            Arc::new(azure_provider)
683        };
684
685        let client = Arc::new(DataverseClient::new(
686            &base_url,
687            &self.config.api_version,
688            provider,
689        ));
690
691        // 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    entities: Vec<String>,
895    entity_set_overrides: HashMap<String, String>,
896    entity_columns: HashMap<String, Vec<String>>,
897    min_interval_ms: u64,
898    max_interval_seconds: u64,
899    api_version: String,
900    dispatch_mode: Option<DispatchMode>,
901    dispatch_buffer_capacity: Option<usize>,
902    bootstrap_provider: Option<Box<dyn drasi_lib::bootstrap::BootstrapProvider + 'static>>,
903    identity_provider: Option<Box<dyn IdentityProvider>>,
904    auto_start: bool,
905}
906
907impl DataverseSourceBuilder {
908    /// Create a new builder with the given source ID.
909    pub fn new(id: impl Into<String>) -> Self {
910        Self {
911            id: id.into(),
912            environment_url: String::new(),
913            tenant_id: String::new(),
914            client_id: String::new(),
915            client_secret: String::new(),
916            entities: Vec::new(),
917            entity_set_overrides: HashMap::new(),
918            entity_columns: HashMap::new(),
919            min_interval_ms: 500,
920            max_interval_seconds: 30,
921            api_version: "v9.2".to_string(),
922            dispatch_mode: None,
923            dispatch_buffer_capacity: None,
924            bootstrap_provider: None,
925            identity_provider: None,
926            auto_start: true,
927        }
928    }
929
930    /// Set the Dataverse environment URL.
931    pub fn with_environment_url(mut self, url: impl Into<String>) -> Self {
932        self.environment_url = url.into();
933        self
934    }
935
936    /// Set the Azure AD tenant ID.
937    pub fn with_tenant_id(mut self, tenant_id: impl Into<String>) -> Self {
938        self.tenant_id = tenant_id.into();
939        self
940    }
941
942    /// Set the Azure AD client ID.
943    pub fn with_client_id(mut self, client_id: impl Into<String>) -> Self {
944        self.client_id = client_id.into();
945        self
946    }
947
948    /// Set the Azure AD client secret.
949    pub fn with_client_secret(mut self, client_secret: impl Into<String>) -> Self {
950        self.client_secret = client_secret.into();
951        self
952    }
953
954    /// Set an identity provider for token acquisition.
955    ///
956    /// When an identity provider is set, it takes precedence over
957    /// `tenant_id`/`client_id`/`client_secret`.
958    /// The provider's `get_credentials()` must return `Credentials::Token`.
959    ///
960    /// This enables using any of the platform's identity providers, including:
961    /// - `AzureIdentityProvider::with_client_secret(...)` for client credentials
962    /// - `AzureIdentityProvider::with_default_credentials(...)` for managed identity
963    /// - `AzureIdentityProvider::with_developer_tools(...)` for local dev (CLI, azd, PS)
964    /// - `AzureIdentityProvider::with_workload_identity(...)` for Kubernetes workloads
965    ///
966    /// # Example
967    ///
968    /// ```rust,ignore
969    /// use drasi_lib::identity::AzureIdentityProvider;
970    /// use drasi_source_dataverse::DataverseSource;
971    ///
972    /// let provider = AzureIdentityProvider::with_client_secret(
973    ///     "tenant-id", "client-id", "client-secret", "dataverse",
974    /// )?
975    /// .with_scope("https://myorg.crm.dynamics.com/.default");
976    ///
977    /// let source = DataverseSource::builder("dv-source")
978    ///     .with_environment_url("https://myorg.crm.dynamics.com")
979    ///     .with_entities(vec!["account".to_string()])
980    ///     .with_identity_provider(provider)
981    ///     .build()?;
982    /// ```
983    pub fn with_identity_provider(mut self, provider: impl IdentityProvider + 'static) -> Self {
984        self.identity_provider = Some(Box::new(provider));
985        self
986    }
987
988    /// Set the list of entity logical names to monitor.
989    pub fn with_entities(mut self, entities: Vec<String>) -> Self {
990        self.entities = entities;
991        self
992    }
993
994    /// Add a single entity to monitor.
995    pub fn with_entity(mut self, entity: impl Into<String>) -> Self {
996        self.entities.push(entity.into());
997        self
998    }
999
1000    /// Override the entity set name for a specific entity.
1001    pub fn with_entity_set_override(
1002        mut self,
1003        entity_name: impl Into<String>,
1004        entity_set_name: impl Into<String>,
1005    ) -> Self {
1006        self.entity_set_overrides
1007            .insert(entity_name.into(), entity_set_name.into());
1008        self
1009    }
1010
1011    /// Set column selection for a specific entity.
1012    pub fn with_entity_columns(mut self, entity: impl Into<String>, columns: Vec<String>) -> Self {
1013        self.entity_columns.insert(entity.into(), columns);
1014        self
1015    }
1016
1017    /// Set the minimum adaptive polling interval in milliseconds.
1018    pub fn with_min_interval_ms(mut self, ms: u64) -> Self {
1019        self.min_interval_ms = ms;
1020        self
1021    }
1022
1023    /// Set the maximum adaptive polling interval in seconds.
1024    pub fn with_max_interval_seconds(mut self, seconds: u64) -> Self {
1025        self.max_interval_seconds = seconds;
1026        self
1027    }
1028
1029    /// Set the Dataverse Web API version.
1030    pub fn with_api_version(mut self, version: impl Into<String>) -> Self {
1031        self.api_version = version.into();
1032        self
1033    }
1034
1035    /// Set the dispatch mode.
1036    pub fn with_dispatch_mode(mut self, mode: DispatchMode) -> Self {
1037        self.dispatch_mode = Some(mode);
1038        self
1039    }
1040
1041    /// Set the dispatch buffer capacity.
1042    pub fn with_dispatch_buffer_capacity(mut self, capacity: usize) -> Self {
1043        self.dispatch_buffer_capacity = Some(capacity);
1044        self
1045    }
1046
1047    /// Set the bootstrap provider for initial data delivery.
1048    pub fn with_bootstrap_provider(
1049        mut self,
1050        provider: impl drasi_lib::bootstrap::BootstrapProvider + 'static,
1051    ) -> Self {
1052        self.bootstrap_provider = Some(Box::new(provider));
1053        self
1054    }
1055
1056    /// Set whether this source should auto-start when DrasiLib starts.
1057    pub fn with_auto_start(mut self, auto_start: bool) -> Self {
1058        self.auto_start = auto_start;
1059        self
1060    }
1061
1062    /// Build the `DataverseSource` instance.
1063    pub fn build(self) -> Result<DataverseSource> {
1064        let config = DataverseSourceConfig {
1065            environment_url: self.environment_url,
1066            tenant_id: self.tenant_id,
1067            client_id: self.client_id,
1068            client_secret: self.client_secret,
1069            entities: self.entities,
1070            entity_set_overrides: self.entity_set_overrides,
1071            entity_columns: self.entity_columns,
1072            min_interval_ms: self.min_interval_ms,
1073            max_interval_seconds: self.max_interval_seconds,
1074            api_version: self.api_version,
1075        };
1076
1077        // Auth resolution order:
1078        // 1. An identity provider was supplied directly on the builder → relaxed validation.
1079        // 2. No identity provider and no client credentials → assume a host
1080        //    (e.g. drasi-server) will inject an identity provider after
1081        //    `build()` via `set_identity_provider()`. Relaxed validation.
1082        // 3. Otherwise (built-in client credentials) → strict validation.
1083        let no_builtin_credentials = config.tenant_id.is_empty()
1084            && config.client_id.is_empty()
1085            && config.client_secret.is_empty();
1086        if self.identity_provider.is_some() || no_builtin_credentials {
1087            config
1088                .validate_with_identity_provider()
1089                .map_err(|e| anyhow::anyhow!(e))?;
1090        } else {
1091            config.validate().map_err(|e| anyhow::anyhow!(e))?;
1092        }
1093
1094        let mut params = SourceBaseParams::new(&self.id).with_auto_start(self.auto_start);
1095        if let Some(mode) = self.dispatch_mode {
1096            params = params.with_dispatch_mode(mode);
1097        }
1098        if let Some(capacity) = self.dispatch_buffer_capacity {
1099            params = params.with_dispatch_buffer_capacity(capacity);
1100        }
1101        if let Some(provider) = self.bootstrap_provider {
1102            params = params.with_bootstrap_provider(provider);
1103        }
1104
1105        Ok(DataverseSource {
1106            base: SourceBase::new(params)?,
1107            config,
1108            identity_provider: self.identity_provider,
1109        })
1110    }
1111}
1112
1113#[cfg(test)]
1114mod tests {
1115    use super::*;
1116
1117    mod construction {
1118        use super::*;
1119
1120        #[test]
1121        fn test_builder_creates_source() {
1122            let source = DataverseSource::builder("dv-source")
1123                .with_environment_url("https://myorg.crm.dynamics.com")
1124                .with_tenant_id("tenant-1")
1125                .with_client_id("client-1")
1126                .with_client_secret("secret-1")
1127                .with_entities(vec!["account".to_string()])
1128                .build();
1129            assert!(source.is_ok());
1130        }
1131
1132        #[test]
1133        fn test_builder_fails_without_entities() {
1134            let source = DataverseSource::builder("dv-source")
1135                .with_environment_url("https://myorg.crm.dynamics.com")
1136                .with_tenant_id("tenant-1")
1137                .with_client_id("client-1")
1138                .with_client_secret("secret-1")
1139                .build();
1140            assert!(source.is_err());
1141        }
1142
1143        #[test]
1144        fn test_builder_fails_without_url() {
1145            let source = DataverseSource::builder("dv-source")
1146                .with_tenant_id("tenant-1")
1147                .with_client_id("client-1")
1148                .with_client_secret("secret-1")
1149                .with_entities(vec!["account".to_string()])
1150                .build();
1151            assert!(source.is_err());
1152        }
1153
1154        #[test]
1155        fn test_new_with_valid_config() {
1156            let config = DataverseSourceConfig {
1157                environment_url: "https://test.crm.dynamics.com".to_string(),
1158                tenant_id: "t".to_string(),
1159                client_id: "c".to_string(),
1160                client_secret: "s".to_string(),
1161                entities: vec!["account".to_string()],
1162                entity_set_overrides: HashMap::new(),
1163                entity_columns: HashMap::new(),
1164                min_interval_ms: 500,
1165                max_interval_seconds: 30,
1166                api_version: "v9.2".to_string(),
1167            };
1168            let source = DataverseSource::new("test-source", config);
1169            assert!(source.is_ok());
1170        }
1171    }
1172
1173    mod properties {
1174        use super::*;
1175
1176        #[test]
1177        fn test_id_returns_correct_value() {
1178            let source = DataverseSource::builder("my-dv-source")
1179                .with_environment_url("https://test.crm.dynamics.com")
1180                .with_tenant_id("t")
1181                .with_client_id("c")
1182                .with_client_secret("s")
1183                .with_entities(vec!["account".to_string()])
1184                .build()
1185                .expect("should build");
1186            assert_eq!(source.id(), "my-dv-source");
1187        }
1188
1189        #[test]
1190        fn test_type_name_returns_dataverse() {
1191            let source = DataverseSource::builder("test")
1192                .with_environment_url("https://test.crm.dynamics.com")
1193                .with_tenant_id("t")
1194                .with_client_id("c")
1195                .with_client_secret("s")
1196                .with_entities(vec!["account".to_string()])
1197                .build()
1198                .expect("should build");
1199            assert_eq!(source.type_name(), "dataverse");
1200        }
1201
1202        #[test]
1203        fn test_properties_does_not_expose_secret() {
1204            let source = DataverseSource::builder("test")
1205                .with_environment_url("https://test.crm.dynamics.com")
1206                .with_tenant_id("t")
1207                .with_client_id("c")
1208                .with_client_secret("super-secret-value")
1209                .with_entities(vec!["account".to_string()])
1210                .build()
1211                .expect("should build");
1212            let props = source.properties();
1213
1214            assert!(props.contains_key("environment_url"));
1215            assert!(props.contains_key("tenant_id"));
1216            assert!(props.contains_key("client_id"));
1217            assert!(props.contains_key("entities"));
1218            assert!(!props.contains_key("client_secret"));
1219        }
1220
1221        #[test]
1222        fn test_properties_contains_correct_values() {
1223            let source = DataverseSource::builder("test")
1224                .with_environment_url("https://myorg.crm.dynamics.com")
1225                .with_tenant_id("tenant-123")
1226                .with_client_id("client-456")
1227                .with_client_secret("s")
1228                .with_entities(vec!["account".to_string(), "contact".to_string()])
1229                .build()
1230                .expect("should build");
1231            let props = source.properties();
1232
1233            assert_eq!(
1234                props.get("environment_url"),
1235                Some(&serde_json::Value::String(
1236                    "https://myorg.crm.dynamics.com".to_string()
1237                ))
1238            );
1239            assert_eq!(
1240                props.get("tenant_id"),
1241                Some(&serde_json::Value::String("tenant-123".to_string()))
1242            );
1243        }
1244    }
1245
1246    mod lifecycle {
1247        use super::*;
1248
1249        #[tokio::test]
1250        async fn test_initial_status_is_stopped() {
1251            let source = DataverseSource::builder("test")
1252                .with_environment_url("https://test.crm.dynamics.com")
1253                .with_tenant_id("t")
1254                .with_client_id("c")
1255                .with_client_secret("s")
1256                .with_entities(vec!["account".to_string()])
1257                .build()
1258                .expect("should build");
1259            assert_eq!(source.status().await, ComponentStatus::Stopped);
1260        }
1261    }
1262
1263    mod builder {
1264        use super::*;
1265
1266        #[test]
1267        fn test_builder_defaults() {
1268            let source = DataverseSource::builder("test")
1269                .with_environment_url("https://test.crm.dynamics.com")
1270                .with_tenant_id("t")
1271                .with_client_id("c")
1272                .with_client_secret("s")
1273                .with_entities(vec!["account".to_string()])
1274                .build()
1275                .expect("should build");
1276
1277            assert_eq!(source.config.min_interval_ms, 500);
1278            assert_eq!(source.config.max_interval_seconds, 30);
1279            assert_eq!(source.config.api_version, "v9.2");
1280            assert!(source.identity_provider.is_none());
1281        }
1282
1283        #[test]
1284        fn test_builder_custom_values() {
1285            let source = DataverseSource::builder("test")
1286                .with_environment_url("https://custom.crm.dynamics.com")
1287                .with_tenant_id("custom-tenant")
1288                .with_client_id("custom-client")
1289                .with_client_secret("custom-secret")
1290                .with_entities(vec!["account".to_string()])
1291                .with_min_interval_ms(200)
1292                .with_max_interval_seconds(60)
1293                .with_api_version("v9.1")
1294                .build()
1295                .expect("should build");
1296
1297            assert_eq!(
1298                source.config.environment_url,
1299                "https://custom.crm.dynamics.com"
1300            );
1301            assert_eq!(source.config.min_interval_ms, 200);
1302            assert_eq!(source.config.max_interval_seconds, 60);
1303            assert_eq!(source.config.api_version, "v9.1");
1304        }
1305
1306        #[test]
1307        fn test_builder_with_entity() {
1308            let source = DataverseSource::builder("test")
1309                .with_environment_url("https://test.crm.dynamics.com")
1310                .with_tenant_id("t")
1311                .with_client_id("c")
1312                .with_client_secret("s")
1313                .with_entity("account")
1314                .with_entity("contact")
1315                .build()
1316                .expect("should build");
1317
1318            assert_eq!(source.config.entities, vec!["account", "contact"]);
1319        }
1320
1321        #[test]
1322        fn test_builder_with_entity_set_override() {
1323            let source = DataverseSource::builder("test")
1324                .with_environment_url("https://test.crm.dynamics.com")
1325                .with_tenant_id("t")
1326                .with_client_id("c")
1327                .with_client_secret("s")
1328                .with_entity("activityparty")
1329                .with_entity_set_override("activityparty", "activityparties")
1330                .build()
1331                .expect("should build");
1332
1333            assert_eq!(
1334                source.config.entity_set_name("activityparty"),
1335                "activityparties"
1336            );
1337        }
1338
1339        #[test]
1340        fn test_builder_with_entity_columns() {
1341            let source = DataverseSource::builder("test")
1342                .with_environment_url("https://test.crm.dynamics.com")
1343                .with_tenant_id("t")
1344                .with_client_id("c")
1345                .with_client_secret("s")
1346                .with_entity("account")
1347                .with_entity_columns("account", vec!["name".to_string(), "revenue".to_string()])
1348                .build()
1349                .expect("should build");
1350
1351            assert_eq!(
1352                source.config.select_columns("account"),
1353                Some("name,revenue,accountid".to_string())
1354            );
1355        }
1356
1357        #[test]
1358        fn test_builder_with_identity_provider() {
1359            // When an identity provider is set, client credentials are not required
1360            let provider = drasi_lib::identity::PasswordIdentityProvider::new("user", "token");
1361            let source = DataverseSource::builder("test")
1362                .with_environment_url("https://test.crm.dynamics.com")
1363                .with_entities(vec!["account".to_string()])
1364                .with_identity_provider(provider)
1365                .build()
1366                .expect("should build with identity provider and no client credentials");
1367
1368            assert!(source.identity_provider.is_some());
1369        }
1370
1371        #[test]
1372        fn test_builder_with_identity_provider_still_needs_url() {
1373            let provider = drasi_lib::identity::PasswordIdentityProvider::new("user", "token");
1374            let result = DataverseSource::builder("test")
1375                .with_entities(vec!["account".to_string()])
1376                .with_identity_provider(provider)
1377                .build();
1378            assert!(result.is_err(), "should fail without environment_url");
1379        }
1380
1381        #[test]
1382        fn test_builder_with_identity_provider_still_needs_entities() {
1383            let provider = drasi_lib::identity::PasswordIdentityProvider::new("user", "token");
1384            let result = DataverseSource::builder("test")
1385                .with_environment_url("https://test.crm.dynamics.com")
1386                .with_identity_provider(provider)
1387                .build();
1388            assert!(result.is_err(), "should fail without entities");
1389        }
1390    }
1391
1392    mod change_conversion {
1393        use super::*;
1394
1395        #[test]
1396        fn test_convert_new_or_updated() {
1397            let mut attributes = serde_json::Map::new();
1398            attributes.insert(
1399                "name".to_string(),
1400                serde_json::Value::String("Contoso".to_string()),
1401            );
1402            attributes.insert("revenue".to_string(), serde_json::json!(1000000.0));
1403            attributes.insert(
1404                "accountid".to_string(),
1405                serde_json::Value::String("abc-123".to_string()),
1406            );
1407
1408            let change = DataverseChange::NewOrUpdated {
1409                id: "abc-123".to_string(),
1410                entity_name: "account".to_string(),
1411                attributes,
1412            };
1413
1414            let source_change = DataverseSource::convert_to_source_change("test-source", &change);
1415            match source_change {
1416                SourceChange::Update { element } => match element {
1417                    Element::Node {
1418                        metadata,
1419                        properties,
1420                    } => {
1421                        assert_eq!(metadata.reference.element_id.as_ref(), "account:abc-123");
1422                        assert_eq!(metadata.reference.source_id.as_ref(), "test-source");
1423                        assert_eq!(metadata.labels.len(), 1);
1424                        assert_eq!(metadata.labels[0].as_ref(), "account");
1425                        assert!(properties.get("name").is_some());
1426                    }
1427                    _ => panic!("Expected Node element"),
1428                },
1429                _ => panic!("Expected Update change"),
1430            }
1431        }
1432
1433        #[test]
1434        fn test_convert_deleted() {
1435            let change = DataverseChange::Deleted {
1436                id: "def-456".to_string(),
1437                entity_name: "contact".to_string(),
1438            };
1439
1440            let source_change = DataverseSource::convert_to_source_change("test-source", &change);
1441            match source_change {
1442                SourceChange::Delete { metadata } => {
1443                    assert_eq!(metadata.reference.element_id.as_ref(), "contact:def-456");
1444                    assert_eq!(metadata.reference.source_id.as_ref(), "test-source");
1445                    assert_eq!(metadata.labels[0].as_ref(), "contact");
1446                }
1447                _ => panic!("Expected Delete change"),
1448            }
1449        }
1450
1451        #[test]
1452        fn test_convert_json_value_primitives() {
1453            assert_eq!(
1454                DataverseSource::convert_json_value(&serde_json::Value::Null),
1455                ElementValue::Null
1456            );
1457            assert_eq!(
1458                DataverseSource::convert_json_value(&serde_json::json!(true)),
1459                ElementValue::Bool(true)
1460            );
1461            assert_eq!(
1462                DataverseSource::convert_json_value(&serde_json::json!(42)),
1463                ElementValue::Integer(42)
1464            );
1465            assert_eq!(
1466                DataverseSource::convert_json_value(&serde_json::json!(3.15)),
1467                ElementValue::Float(ordered_float::OrderedFloat(3.15))
1468            );
1469            assert_eq!(
1470                DataverseSource::convert_json_value(&serde_json::json!("hello")),
1471                ElementValue::String(Arc::from("hello"))
1472            );
1473        }
1474
1475        #[test]
1476        fn test_convert_json_value_extracts_value() {
1477            // Single value type: {"Value": 123} -> 123
1478            let json = serde_json::json!({"Value": 123});
1479            assert_eq!(
1480                DataverseSource::convert_json_value(&json),
1481                ElementValue::Integer(123)
1482            );
1483        }
1484
1485        #[test]
1486        fn test_convert_json_value_multi_select_choice() {
1487            // Multi-select: [{"Value":1},{"Value":2}] -> [1,2]
1488            let json = serde_json::json!([{"Value": 1}, {"Value": 2}]);
1489            let result = DataverseSource::convert_json_value(&json);
1490            match result {
1491                ElementValue::List(values) => {
1492                    assert_eq!(values.len(), 2);
1493                    assert_eq!(values[0], ElementValue::Integer(1));
1494                    assert_eq!(values[1], ElementValue::Integer(2));
1495                }
1496                _ => panic!("Expected List"),
1497            }
1498        }
1499    }
1500
1501    mod backoff {
1502        use super::*;
1503
1504        #[test]
1505        fn resets_to_min_when_changes_detected() {
1506            // Even at very large current intervals, a change observation should
1507            // snap us back to min for responsive polling.
1508            let next = DataverseSource::next_backoff_interval(20_000, 500, 30_000, true);
1509            assert_eq!(next, 500);
1510        }
1511
1512        #[test]
1513        fn slow_backoff_under_threshold() {
1514            // Below 5s, multiplier is 1.2x.
1515            let next = DataverseSource::next_backoff_interval(1000, 500, 30_000, false);
1516            assert_eq!(next, 1200);
1517
1518            let next = DataverseSource::next_backoff_interval(4000, 500, 30_000, false);
1519            assert_eq!(next, 4800);
1520        }
1521
1522        #[test]
1523        fn fast_backoff_above_threshold() {
1524            // At/above 5s, multiplier is 1.5x.
1525            let next = DataverseSource::next_backoff_interval(5000, 500, 30_000, false);
1526            assert_eq!(next, 7500);
1527
1528            let next = DataverseSource::next_backoff_interval(10_000, 500, 30_000, false);
1529            assert_eq!(next, 15_000);
1530        }
1531
1532        #[test]
1533        fn does_not_exceed_max() {
1534            // Capped at the configured max.
1535            let next = DataverseSource::next_backoff_interval(25_000, 500, 30_000, false);
1536            assert_eq!(next, 30_000);
1537
1538            // Already at max; staying at max.
1539            let next = DataverseSource::next_backoff_interval(30_000, 500, 30_000, false);
1540            assert_eq!(next, 30_000);
1541        }
1542
1543        #[test]
1544        fn full_progression_no_changes() {
1545            // From min, repeatedly back off; verify the sequence reaches the cap
1546            // without ever overshooting.
1547            let min = 500;
1548            let max = 30_000;
1549            let mut current = min;
1550            let mut steps = 0;
1551            while current < max {
1552                let next = DataverseSource::next_backoff_interval(current, min, max, false);
1553                assert!(
1554                    next > current || next == max,
1555                    "interval should grow or hit the cap (was {current}, became {next})"
1556                );
1557                assert!(
1558                    next <= max,
1559                    "interval must never exceed max ({next} > {max})"
1560                );
1561                current = next;
1562                steps += 1;
1563                assert!(steps < 100, "backoff failed to converge");
1564            }
1565            assert_eq!(current, max);
1566        }
1567
1568        #[test]
1569        fn full_progression_with_change_resets() {
1570            // Back off twice, then observe a change: should drop straight back to min.
1571            let min = 500;
1572            let max = 30_000;
1573            let mut current = min;
1574            current = DataverseSource::next_backoff_interval(current, min, max, false);
1575            current = DataverseSource::next_backoff_interval(current, min, max, false);
1576            assert!(current > min);
1577            current = DataverseSource::next_backoff_interval(current, min, max, true);
1578            assert_eq!(current, min);
1579        }
1580    }
1581
1582    mod state_store_helpers {
1583        use super::*;
1584        use drasi_lib::MemoryStateStoreProvider;
1585
1586        fn store() -> Arc<dyn drasi_lib::StateStoreProvider> {
1587            Arc::new(MemoryStateStoreProvider::new())
1588        }
1589
1590        #[test]
1591        fn delta_token_key_matches_platform_format() {
1592            // The state-key format must remain `{entity}-deltatoken` for
1593            // cross-implementation checkpoint compatibility.
1594            assert_eq!(
1595                DataverseSource::delta_token_key("account"),
1596                "account-deltatoken"
1597            );
1598        }
1599
1600        #[tokio::test]
1601        async fn load_returns_none_on_empty_store() {
1602            let s = store();
1603            let result = DataverseSource::load_delta_token(&s, "src-1", "account").await;
1604            assert!(
1605                result.is_none(),
1606                "no checkpoint should be present initially"
1607            );
1608        }
1609
1610        #[tokio::test]
1611        async fn save_then_load_round_trip() {
1612            let s = store();
1613            DataverseSource::save_delta_token(&s, "src-1", "account", "delta-token-123").await;
1614
1615            let loaded = DataverseSource::load_delta_token(&s, "src-1", "account").await;
1616            assert_eq!(loaded.as_deref(), Some("delta-token-123"));
1617        }
1618
1619        #[tokio::test]
1620        async fn checkpoints_are_isolated_per_entity() {
1621            let s = store();
1622            DataverseSource::save_delta_token(&s, "src-1", "account", "token-A").await;
1623            DataverseSource::save_delta_token(&s, "src-1", "contact", "token-B").await;
1624
1625            assert_eq!(
1626                DataverseSource::load_delta_token(&s, "src-1", "account")
1627                    .await
1628                    .as_deref(),
1629                Some("token-A")
1630            );
1631            assert_eq!(
1632                DataverseSource::load_delta_token(&s, "src-1", "contact")
1633                    .await
1634                    .as_deref(),
1635                Some("token-B")
1636            );
1637        }
1638
1639        #[tokio::test]
1640        async fn checkpoints_are_isolated_per_source() {
1641            let s = store();
1642            DataverseSource::save_delta_token(&s, "src-1", "account", "token-A").await;
1643            DataverseSource::save_delta_token(&s, "src-2", "account", "token-B").await;
1644
1645            assert_eq!(
1646                DataverseSource::load_delta_token(&s, "src-1", "account")
1647                    .await
1648                    .as_deref(),
1649                Some("token-A")
1650            );
1651            assert_eq!(
1652                DataverseSource::load_delta_token(&s, "src-2", "account")
1653                    .await
1654                    .as_deref(),
1655                Some("token-B")
1656            );
1657        }
1658
1659        #[tokio::test]
1660        async fn save_overwrites_previous_value() {
1661            let s = store();
1662            DataverseSource::save_delta_token(&s, "src-1", "account", "token-old").await;
1663            DataverseSource::save_delta_token(&s, "src-1", "account", "token-new").await;
1664
1665            assert_eq!(
1666                DataverseSource::load_delta_token(&s, "src-1", "account")
1667                    .await
1668                    .as_deref(),
1669                Some("token-new")
1670            );
1671        }
1672
1673        #[tokio::test]
1674        async fn load_skips_invalid_utf8() {
1675            let s = store();
1676            let key = DataverseSource::delta_token_key("account");
1677            // Write deliberately invalid UTF-8 directly.
1678            s.set("src-1", &key, vec![0xff, 0xfe, 0xfd])
1679                .await
1680                .expect("set should succeed");
1681
1682            let loaded = DataverseSource::load_delta_token(&s, "src-1", "account").await;
1683            assert!(loaded.is_none(), "non-UTF8 stored value should be ignored");
1684        }
1685    }
1686}
1687
1688/// Dynamic plugin entry point.
1689#[cfg(feature = "dynamic-plugin")]
1690drasi_plugin_sdk::export_plugin!(
1691    plugin_id = "dataverse-source",
1692    core_version = env!("CARGO_PKG_VERSION"),
1693    lib_version = env!("CARGO_PKG_VERSION"),
1694    plugin_version = env!("CARGO_PKG_VERSION"),
1695    source_descriptors = [descriptor::DataverseSourceDescriptor],
1696    reaction_descriptors = [],
1697    bootstrap_descriptors = [],
1698);