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