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