#![allow(unexpected_cfgs)]
pub mod client;
pub mod config;
pub mod descriptor;
pub mod types;
pub use config::DataverseSourceConfig;
use anyhow::Result;
use async_trait::async_trait;
use std::collections::HashMap;
use std::sync::Arc;
use std::time::Duration;
use drasi_core::models::{
Element, ElementMetadata, ElementPropertyMap, ElementReference, ElementValue, SourceChange,
};
use drasi_lib::channels::{ComponentStatus, DispatchMode, SourceEvent, SourceEventWrapper};
use drasi_lib::identity::IdentityProvider;
use drasi_lib::sources::base::{SourceBase, SourceBaseParams};
use drasi_lib::Source;
use tracing::Instrument;
use crate::client::DataverseClient;
use crate::types::{parse_delta_changes, DataverseChange};
pub struct DataverseSource {
base: SourceBase,
config: DataverseSourceConfig,
identity_provider: Option<Box<dyn IdentityProvider>>,
}
impl DataverseSource {
pub fn new(id: impl Into<String>, config: DataverseSourceConfig) -> Result<Self> {
config.validate().map_err(|e| anyhow::anyhow!(e))?;
let params = SourceBaseParams::new(id.into());
Ok(Self {
base: SourceBase::new(params)?,
config,
identity_provider: None,
})
}
pub fn builder(id: impl Into<String>) -> DataverseSourceBuilder {
DataverseSourceBuilder::new(id)
}
pub(crate) fn dataverse_scope(environment_url: &str) -> String {
match url::Url::parse(environment_url) {
Ok(url) => match url.host_str() {
Some(host) => format!("{}://{}/.default", url.scheme(), host),
None => format!("{}/.default", environment_url.trim_end_matches('/')),
},
Err(_) => format!("{}/.default", environment_url.trim_end_matches('/')),
}
}
pub(crate) fn next_backoff_interval(
current_interval_ms: u64,
min_interval_ms: u64,
max_interval_ms: u64,
changes_detected: bool,
) -> u64 {
const THRESHOLD_MS: u64 = 5000;
const SLOW_BACKOFF: f64 = 1.2;
const FAST_BACKOFF: f64 = 1.5;
if changes_detected {
return min_interval_ms;
}
let multiplier = if current_interval_ms < THRESHOLD_MS {
SLOW_BACKOFF
} else {
FAST_BACKOFF
};
((current_interval_ms as f64 * multiplier) as u64).min(max_interval_ms)
}
pub(crate) fn delta_token_key(entity_name: &str) -> String {
format!("{entity_name}-deltatoken")
}
pub(crate) async fn load_delta_token(
store: &Arc<dyn drasi_lib::StateStoreProvider>,
source_id: &str,
entity_name: &str,
) -> Option<String> {
let key = Self::delta_token_key(entity_name);
match store.get(source_id, &key).await {
Ok(Some(bytes)) => String::from_utf8(bytes).ok(),
Ok(None) => None,
Err(e) => {
log::warn!("[{source_id}] Failed to load delta token for {entity_name}: {e}");
None
}
}
}
pub(crate) async fn save_delta_token(
store: &Arc<dyn drasi_lib::StateStoreProvider>,
source_id: &str,
entity_name: &str,
token: &str,
) {
let key = Self::delta_token_key(entity_name);
if let Err(e) = store.set(source_id, &key, token.as_bytes().to_vec()).await {
log::warn!("[{source_id}] Failed to persist delta token for {entity_name}: {e}");
}
}
#[allow(clippy::too_many_arguments)]
async fn run_entity_worker(
source_id: String,
entity_name: String,
entity_set_name: String,
select: Option<String>,
client: Arc<DataverseClient>,
dispatchers: Arc<
tokio::sync::RwLock<
Vec<
Box<
dyn drasi_lib::channels::ChangeDispatcher<SourceEventWrapper> + Send + Sync,
>,
>,
>,
>,
state_store: Option<Arc<dyn drasi_lib::StateStoreProvider>>,
mut shutdown_rx: tokio::sync::watch::Receiver<bool>,
min_interval_ms: u64,
max_interval_seconds: u64,
) {
let mut current_interval_ms = min_interval_ms;
let max_interval_ms = max_interval_seconds * 1000;
let mut delta_link: Option<String> = None;
if let Some(ref store) = state_store {
delta_link = Self::load_delta_token(store, &source_id, &entity_name).await;
if delta_link.is_some() {
log::info!("[{source_id}] Resuming from checkpoint for {entity_name}");
} else {
log::info!("[{source_id}] No checkpoint found for {entity_name}, getting current delta token");
}
}
if delta_link.is_none() {
let mut retry_interval_ms: u64 = 1000;
const MAX_RETRY_INTERVAL_MS: u64 = 60_000;
loop {
if *shutdown_rx.borrow() {
log::info!("[{source_id}] Shutting down entity worker for {entity_name} during initial token acquisition");
return;
}
match Self::get_initial_delta_token(&client, &entity_set_name, select.as_deref())
.await
{
Ok(token) => {
log::info!("[{source_id}] Initial delta token obtained for {entity_name}");
if let Some(ref store) = state_store {
Self::save_delta_token(store, &source_id, &entity_name, &token).await;
}
delta_link = Some(token);
break;
}
Err(e) => {
log::error!(
"[{source_id}] Failed to get initial delta token for {entity_name}: {e}. Retrying in {retry_interval_ms}ms"
);
tokio::select! {
_ = tokio::time::sleep(Duration::from_millis(retry_interval_ms)) => {}
_ = shutdown_rx.changed() => {
if *shutdown_rx.borrow() {
log::info!("[{source_id}] Shutting down entity worker for {entity_name}");
return;
}
}
}
retry_interval_ms = (retry_interval_ms * 2).min(MAX_RETRY_INTERVAL_MS);
}
}
}
}
loop {
if *shutdown_rx.borrow() {
log::info!("[{source_id}] Shutting down entity worker for {entity_name}");
break;
}
log::debug!(
"[{source_id}] Polling for changes in entity: {entity_name} (interval: {current_interval_ms}ms)"
);
match Self::poll_for_changes(
&source_id,
&entity_name,
&client,
delta_link.as_deref(),
&dispatchers,
)
.await
{
Ok((new_delta_link, change_count)) => {
let changes_detected = change_count > 0;
if changes_detected {
log::info!(
"[{source_id}] Got {change_count} changes for entity {entity_name}"
);
}
current_interval_ms = Self::next_backoff_interval(
current_interval_ms,
min_interval_ms,
max_interval_ms,
changes_detected,
);
if let Some(ref dl) = new_delta_link {
delta_link = Some(dl.clone());
if let Some(ref store) = state_store {
Self::save_delta_token(store, &source_id, &entity_name, dl).await;
}
}
if changes_detected {
continue;
}
}
Err(e) => {
log::error!("[{source_id}] Error polling entity {entity_name}: {e}");
current_interval_ms = 5000;
}
}
tokio::select! {
_ = tokio::time::sleep(Duration::from_millis(current_interval_ms)) => {}
_ = shutdown_rx.changed() => {
if *shutdown_rx.borrow() {
log::info!("[{source_id}] Shutting down entity worker for {entity_name}");
break;
}
}
}
}
}
async fn get_initial_delta_token(
client: &DataverseClient,
entity_set_name: &str,
select: Option<&str>,
) -> Result<String> {
let mut response = client
.initial_change_tracking(entity_set_name, select)
.await?;
while response.next_link.is_some() {
let next = response
.next_link
.as_ref()
.expect("next_link checked above");
response = client.follow_next_link(next).await?;
}
response.delta_link.ok_or_else(|| {
anyhow::anyhow!("No delta link returned from initial change tracking request")
})
}
async fn poll_for_changes(
source_id: &str,
entity_name: &str,
client: &DataverseClient,
delta_link: Option<&str>,
dispatchers: &Arc<
tokio::sync::RwLock<
Vec<
Box<
dyn drasi_lib::channels::ChangeDispatcher<SourceEventWrapper> + Send + Sync,
>,
>,
>,
>,
) -> Result<(Option<String>, usize)> {
let delta_link =
delta_link.ok_or_else(|| anyhow::anyhow!("No delta link available for polling"))?;
let mut response = client.follow_delta_link(delta_link).await?;
let mut all_changes = Vec::new();
let mut final_delta_link = response.delta_link.clone();
let changes = parse_delta_changes(&response, entity_name);
all_changes.extend(changes);
while response.next_link.is_some() {
let next = response
.next_link
.as_ref()
.expect("next_link checked above");
response = client.follow_next_link(next).await?;
let changes = parse_delta_changes(&response, entity_name);
all_changes.extend(changes);
if response.delta_link.is_some() {
final_delta_link = response.delta_link.clone();
}
}
let change_count = all_changes.len();
for change in &all_changes {
let source_change = Self::convert_to_source_change(source_id, change);
let timestamp = chrono::Utc::now();
let mut profiling = drasi_lib::profiling::ProfilingMetadata::new();
profiling.source_send_ns = Some(drasi_lib::profiling::timestamp_ns());
let wrapper = SourceEventWrapper::with_profiling(
source_id.to_string(),
SourceEvent::Change(source_change),
timestamp,
profiling,
);
if let Err(e) =
SourceBase::dispatch_from_task(dispatchers.clone(), wrapper, source_id).await
{
log::error!("[{source_id}] Failed to dispatch change for {entity_name}: {e}");
}
}
Ok((final_delta_link, change_count))
}
fn convert_to_source_change(source_id: &str, change: &DataverseChange) -> SourceChange {
match change {
DataverseChange::NewOrUpdated {
id,
entity_name,
attributes,
} => {
let mut properties = ElementPropertyMap::new();
for (key, value) in attributes {
let element_value = Self::convert_json_value(value);
properties.insert(key, element_value);
}
let effective_from = attributes
.get("modifiedon")
.and_then(|v| v.as_str())
.and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok())
.map(|dt| dt.timestamp_millis().max(0) as u64)
.unwrap_or_else(|| chrono::Utc::now().timestamp_millis().max(0) as u64);
let element_id = format!("{entity_name}:{id}");
let metadata = ElementMetadata {
reference: ElementReference::new(source_id, &element_id),
labels: Arc::from(vec![Arc::from(entity_name.as_str())]),
effective_from,
};
SourceChange::Update {
element: Element::Node {
metadata,
properties,
},
}
}
DataverseChange::Deleted { id, entity_name } => {
let element_id = format!("{entity_name}:{id}");
let metadata = ElementMetadata {
reference: ElementReference::new(source_id, &element_id),
labels: Arc::from(vec![Arc::from(entity_name.as_str())]),
effective_from: chrono::Utc::now().timestamp_millis().max(0) as u64,
};
SourceChange::Delete { metadata }
}
}
}
fn convert_json_value(value: &serde_json::Value) -> ElementValue {
match value {
serde_json::Value::Null => ElementValue::Null,
serde_json::Value::Bool(b) => ElementValue::Bool(*b),
serde_json::Value::Number(n) => {
if let Some(i) = n.as_i64() {
ElementValue::Integer(i)
} else if let Some(f) = n.as_f64() {
ElementValue::Float(ordered_float::OrderedFloat(f))
} else {
ElementValue::Null
}
}
serde_json::Value::String(s) => ElementValue::String(Arc::from(s.as_str())),
serde_json::Value::Array(arr) => {
if !arr.is_empty() {
if let Some(first_obj) = arr[0].as_object() {
if first_obj.contains_key("Value") {
let values: Vec<ElementValue> = arr
.iter()
.filter_map(|item| {
item.as_object()
.and_then(|obj| obj.get("Value"))
.map(Self::convert_json_value)
})
.collect();
return ElementValue::List(values);
}
}
}
ElementValue::List(arr.iter().map(Self::convert_json_value).collect())
}
serde_json::Value::Object(obj) => {
if obj.contains_key("Value") && obj.len() <= 2 {
if let Some(val) = obj.get("Value") {
return Self::convert_json_value(val);
}
}
let mut map = ElementPropertyMap::new();
for (k, v) in obj {
map.insert(k, Self::convert_json_value(v));
}
ElementValue::Object(map)
}
}
}
}
#[async_trait]
impl Source for DataverseSource {
fn id(&self) -> &str {
&self.base.id
}
fn type_name(&self) -> &str {
"dataverse"
}
fn properties(&self) -> HashMap<String, serde_json::Value> {
let mut props = HashMap::new();
props.insert(
"environment_url".to_string(),
serde_json::Value::String(self.config.environment_url.clone()),
);
props.insert(
"tenant_id".to_string(),
serde_json::Value::String(self.config.tenant_id.clone()),
);
props.insert(
"client_id".to_string(),
serde_json::Value::String(self.config.client_id.clone()),
);
props.insert(
"entities".to_string(),
serde_json::Value::Array(
self.config
.entities
.iter()
.map(|e| serde_json::Value::String(e.clone()))
.collect(),
),
);
props.insert(
"api_version".to_string(),
serde_json::Value::String(self.config.api_version.clone()),
);
props
}
fn auto_start(&self) -> bool {
self.base.get_auto_start()
}
async fn start(&self) -> Result<()> {
log::info!("[{}] Starting Dataverse source", self.base.id);
self.base
.set_status(
ComponentStatus::Starting,
Some("Starting Dataverse source".to_string()),
)
.await;
let base_url = self.config.environment_url.clone();
let provider: Arc<dyn IdentityProvider> = if let Some(injected) =
self.base.identity_provider().await
{
injected
} else if let Some(ref ip) = self.identity_provider {
Arc::from(ip.clone_box())
} else {
let azure_provider = drasi_identity_azure::AzureIdentityProvider::with_client_secret(
"dataverse",
&self.config.tenant_id,
&self.config.client_id,
&self.config.client_secret,
)?
.with_scope(Self::dataverse_scope(&base_url));
Arc::new(azure_provider)
};
let client = Arc::new(DataverseClient::new(
&base_url,
&self.config.api_version,
provider,
));
let (shutdown_tx, _) = tokio::sync::watch::channel(false);
let shutdown_tx = Arc::new(shutdown_tx);
let dispatchers = self.base.dispatchers.clone();
let state_store = self.base.state_store().await;
let source_id = self.base.id.clone();
let entity_count = self.config.entities.len() as f64;
let effective_max_interval_seconds =
(self.config.max_interval_seconds as f64 * entity_count.sqrt()).max(1.0) as u64;
log::info!(
"[{}] Effective max polling interval: {}s (base {}s * sqrt({} entities))",
self.base.id,
effective_max_interval_seconds,
self.config.max_interval_seconds,
self.config.entities.len()
);
let instance_id = self
.base
.context()
.await
.map(|c| c.instance_id)
.unwrap_or_default();
let mut task_handles = Vec::new();
for entity_name in &self.config.entities {
let entity_set_name = self.config.entity_set_name(entity_name);
let select = self.config.select_columns(entity_name);
let source_id = source_id.clone();
let entity_name = entity_name.clone();
let client = client.clone();
let dispatchers = dispatchers.clone();
let state_store = state_store.clone();
let shutdown_rx = shutdown_tx.subscribe();
let min_interval_ms = self.config.min_interval_ms;
let max_interval_seconds = effective_max_interval_seconds;
let instance_id = instance_id.clone();
let span = tracing::info_span!(
"dataverse_entity_worker",
instance_id = %instance_id,
component_id = %source_id,
component_type = "source",
entity = %entity_name
);
let handle = tokio::spawn(
async move {
Self::run_entity_worker(
source_id,
entity_name,
entity_set_name,
select,
client,
dispatchers,
state_store,
shutdown_rx,
min_interval_ms,
max_interval_seconds,
)
.await;
}
.instrument(span),
);
task_handles.push(handle);
}
let source_id = self.base.id.clone();
let combined_handle = tokio::spawn(async move {
for (i, handle) in task_handles.into_iter().enumerate() {
if let Err(e) = handle.await {
log::error!("[{source_id}] Entity worker {i} terminated with error: {e}");
}
}
log::info!("[{source_id}] All entity workers stopped");
});
*self.base.task_handle.write().await = Some(combined_handle);
{
let mut lock = self.base.shutdown_tx.write().await;
let shutdown_tx_for_stop = shutdown_tx.clone();
let (bridge_tx, bridge_rx) = tokio::sync::oneshot::channel::<()>();
tokio::spawn(async move {
let _ = bridge_rx.await;
let _ = shutdown_tx_for_stop.send(true);
});
*lock = Some(bridge_tx);
}
self.base
.set_status(
ComponentStatus::Running,
Some(format!(
"Dataverse source running, monitoring {} entities",
self.config.entities.len()
)),
)
.await;
Ok(())
}
async fn stop(&self) -> Result<()> {
log::info!("[{}] Stopping Dataverse source", self.base.id);
self.base
.set_status(
ComponentStatus::Stopping,
Some("Stopping Dataverse source".to_string()),
)
.await;
if let Some(tx) = self.base.shutdown_tx.write().await.take() {
let _ = tx.send(());
}
if let Some(handle) = self.base.task_handle.write().await.take() {
let mut handle = handle;
if tokio::time::timeout(Duration::from_secs(10), &mut handle)
.await
.is_err()
{
handle.abort();
let _ = handle.await;
}
}
self.base
.set_status(
ComponentStatus::Stopped,
Some("Dataverse source stopped".to_string()),
)
.await;
Ok(())
}
async fn status(&self) -> ComponentStatus {
self.base.get_status().await
}
async fn subscribe(
&self,
settings: drasi_lib::config::SourceSubscriptionSettings,
) -> Result<drasi_lib::channels::SubscriptionResponse> {
self.base
.subscribe_with_bootstrap(&settings, "Dataverse")
.await
}
fn as_any(&self) -> &dyn std::any::Any {
self
}
async fn initialize(&self, context: drasi_lib::context::SourceRuntimeContext) {
self.base.initialize(context).await;
}
async fn set_bootstrap_provider(
&self,
provider: Box<dyn drasi_lib::bootstrap::BootstrapProvider + 'static>,
) {
self.base.set_bootstrap_provider(provider).await;
}
}
pub struct DataverseSourceBuilder {
id: String,
environment_url: String,
tenant_id: String,
client_id: String,
client_secret: String,
entities: Vec<String>,
entity_set_overrides: HashMap<String, String>,
entity_columns: HashMap<String, Vec<String>>,
min_interval_ms: u64,
max_interval_seconds: u64,
api_version: String,
dispatch_mode: Option<DispatchMode>,
dispatch_buffer_capacity: Option<usize>,
bootstrap_provider: Option<Box<dyn drasi_lib::bootstrap::BootstrapProvider + 'static>>,
identity_provider: Option<Box<dyn IdentityProvider>>,
auto_start: bool,
}
impl DataverseSourceBuilder {
pub fn new(id: impl Into<String>) -> Self {
Self {
id: id.into(),
environment_url: String::new(),
tenant_id: String::new(),
client_id: String::new(),
client_secret: String::new(),
entities: Vec::new(),
entity_set_overrides: HashMap::new(),
entity_columns: HashMap::new(),
min_interval_ms: 500,
max_interval_seconds: 30,
api_version: "v9.2".to_string(),
dispatch_mode: None,
dispatch_buffer_capacity: None,
bootstrap_provider: None,
identity_provider: None,
auto_start: true,
}
}
pub fn with_environment_url(mut self, url: impl Into<String>) -> Self {
self.environment_url = url.into();
self
}
pub fn with_tenant_id(mut self, tenant_id: impl Into<String>) -> Self {
self.tenant_id = tenant_id.into();
self
}
pub fn with_client_id(mut self, client_id: impl Into<String>) -> Self {
self.client_id = client_id.into();
self
}
pub fn with_client_secret(mut self, client_secret: impl Into<String>) -> Self {
self.client_secret = client_secret.into();
self
}
pub fn with_identity_provider(mut self, provider: impl IdentityProvider + 'static) -> Self {
self.identity_provider = Some(Box::new(provider));
self
}
pub fn with_entities(mut self, entities: Vec<String>) -> Self {
self.entities = entities;
self
}
pub fn with_entity(mut self, entity: impl Into<String>) -> Self {
self.entities.push(entity.into());
self
}
pub fn with_entity_set_override(
mut self,
entity_name: impl Into<String>,
entity_set_name: impl Into<String>,
) -> Self {
self.entity_set_overrides
.insert(entity_name.into(), entity_set_name.into());
self
}
pub fn with_entity_columns(mut self, entity: impl Into<String>, columns: Vec<String>) -> Self {
self.entity_columns.insert(entity.into(), columns);
self
}
pub fn with_min_interval_ms(mut self, ms: u64) -> Self {
self.min_interval_ms = ms;
self
}
pub fn with_max_interval_seconds(mut self, seconds: u64) -> Self {
self.max_interval_seconds = seconds;
self
}
pub fn with_api_version(mut self, version: impl Into<String>) -> Self {
self.api_version = version.into();
self
}
pub fn with_dispatch_mode(mut self, mode: DispatchMode) -> Self {
self.dispatch_mode = Some(mode);
self
}
pub fn with_dispatch_buffer_capacity(mut self, capacity: usize) -> Self {
self.dispatch_buffer_capacity = Some(capacity);
self
}
pub fn with_bootstrap_provider(
mut self,
provider: impl drasi_lib::bootstrap::BootstrapProvider + 'static,
) -> Self {
self.bootstrap_provider = Some(Box::new(provider));
self
}
pub fn with_auto_start(mut self, auto_start: bool) -> Self {
self.auto_start = auto_start;
self
}
pub fn build(self) -> Result<DataverseSource> {
let config = DataverseSourceConfig {
environment_url: self.environment_url,
tenant_id: self.tenant_id,
client_id: self.client_id,
client_secret: self.client_secret,
entities: self.entities,
entity_set_overrides: self.entity_set_overrides,
entity_columns: self.entity_columns,
min_interval_ms: self.min_interval_ms,
max_interval_seconds: self.max_interval_seconds,
api_version: self.api_version,
};
let no_builtin_credentials = config.tenant_id.is_empty()
&& config.client_id.is_empty()
&& config.client_secret.is_empty();
if self.identity_provider.is_some() || no_builtin_credentials {
config
.validate_with_identity_provider()
.map_err(|e| anyhow::anyhow!(e))?;
} else {
config.validate().map_err(|e| anyhow::anyhow!(e))?;
}
let mut params = SourceBaseParams::new(&self.id).with_auto_start(self.auto_start);
if let Some(mode) = self.dispatch_mode {
params = params.with_dispatch_mode(mode);
}
if let Some(capacity) = self.dispatch_buffer_capacity {
params = params.with_dispatch_buffer_capacity(capacity);
}
if let Some(provider) = self.bootstrap_provider {
params = params.with_bootstrap_provider(provider);
}
Ok(DataverseSource {
base: SourceBase::new(params)?,
config,
identity_provider: self.identity_provider,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
mod construction {
use super::*;
#[test]
fn test_builder_creates_source() {
let source = DataverseSource::builder("dv-source")
.with_environment_url("https://myorg.crm.dynamics.com")
.with_tenant_id("tenant-1")
.with_client_id("client-1")
.with_client_secret("secret-1")
.with_entities(vec!["account".to_string()])
.build();
assert!(source.is_ok());
}
#[test]
fn test_builder_fails_without_entities() {
let source = DataverseSource::builder("dv-source")
.with_environment_url("https://myorg.crm.dynamics.com")
.with_tenant_id("tenant-1")
.with_client_id("client-1")
.with_client_secret("secret-1")
.build();
assert!(source.is_err());
}
#[test]
fn test_builder_fails_without_url() {
let source = DataverseSource::builder("dv-source")
.with_tenant_id("tenant-1")
.with_client_id("client-1")
.with_client_secret("secret-1")
.with_entities(vec!["account".to_string()])
.build();
assert!(source.is_err());
}
#[test]
fn test_new_with_valid_config() {
let config = DataverseSourceConfig {
environment_url: "https://test.crm.dynamics.com".to_string(),
tenant_id: "t".to_string(),
client_id: "c".to_string(),
client_secret: "s".to_string(),
entities: vec!["account".to_string()],
entity_set_overrides: HashMap::new(),
entity_columns: HashMap::new(),
min_interval_ms: 500,
max_interval_seconds: 30,
api_version: "v9.2".to_string(),
};
let source = DataverseSource::new("test-source", config);
assert!(source.is_ok());
}
}
mod properties {
use super::*;
#[test]
fn test_id_returns_correct_value() {
let source = DataverseSource::builder("my-dv-source")
.with_environment_url("https://test.crm.dynamics.com")
.with_tenant_id("t")
.with_client_id("c")
.with_client_secret("s")
.with_entities(vec!["account".to_string()])
.build()
.expect("should build");
assert_eq!(source.id(), "my-dv-source");
}
#[test]
fn test_type_name_returns_dataverse() {
let source = DataverseSource::builder("test")
.with_environment_url("https://test.crm.dynamics.com")
.with_tenant_id("t")
.with_client_id("c")
.with_client_secret("s")
.with_entities(vec!["account".to_string()])
.build()
.expect("should build");
assert_eq!(source.type_name(), "dataverse");
}
#[test]
fn test_properties_does_not_expose_secret() {
let source = DataverseSource::builder("test")
.with_environment_url("https://test.crm.dynamics.com")
.with_tenant_id("t")
.with_client_id("c")
.with_client_secret("super-secret-value")
.with_entities(vec!["account".to_string()])
.build()
.expect("should build");
let props = source.properties();
assert!(props.contains_key("environment_url"));
assert!(props.contains_key("tenant_id"));
assert!(props.contains_key("client_id"));
assert!(props.contains_key("entities"));
assert!(!props.contains_key("client_secret"));
}
#[test]
fn test_properties_contains_correct_values() {
let source = DataverseSource::builder("test")
.with_environment_url("https://myorg.crm.dynamics.com")
.with_tenant_id("tenant-123")
.with_client_id("client-456")
.with_client_secret("s")
.with_entities(vec!["account".to_string(), "contact".to_string()])
.build()
.expect("should build");
let props = source.properties();
assert_eq!(
props.get("environment_url"),
Some(&serde_json::Value::String(
"https://myorg.crm.dynamics.com".to_string()
))
);
assert_eq!(
props.get("tenant_id"),
Some(&serde_json::Value::String("tenant-123".to_string()))
);
}
}
mod lifecycle {
use super::*;
#[tokio::test]
async fn test_initial_status_is_stopped() {
let source = DataverseSource::builder("test")
.with_environment_url("https://test.crm.dynamics.com")
.with_tenant_id("t")
.with_client_id("c")
.with_client_secret("s")
.with_entities(vec!["account".to_string()])
.build()
.expect("should build");
assert_eq!(source.status().await, ComponentStatus::Stopped);
}
}
mod builder {
use super::*;
#[test]
fn test_builder_defaults() {
let source = DataverseSource::builder("test")
.with_environment_url("https://test.crm.dynamics.com")
.with_tenant_id("t")
.with_client_id("c")
.with_client_secret("s")
.with_entities(vec!["account".to_string()])
.build()
.expect("should build");
assert_eq!(source.config.min_interval_ms, 500);
assert_eq!(source.config.max_interval_seconds, 30);
assert_eq!(source.config.api_version, "v9.2");
assert!(source.identity_provider.is_none());
}
#[test]
fn test_builder_custom_values() {
let source = DataverseSource::builder("test")
.with_environment_url("https://custom.crm.dynamics.com")
.with_tenant_id("custom-tenant")
.with_client_id("custom-client")
.with_client_secret("custom-secret")
.with_entities(vec!["account".to_string()])
.with_min_interval_ms(200)
.with_max_interval_seconds(60)
.with_api_version("v9.1")
.build()
.expect("should build");
assert_eq!(
source.config.environment_url,
"https://custom.crm.dynamics.com"
);
assert_eq!(source.config.min_interval_ms, 200);
assert_eq!(source.config.max_interval_seconds, 60);
assert_eq!(source.config.api_version, "v9.1");
}
#[test]
fn test_builder_with_entity() {
let source = DataverseSource::builder("test")
.with_environment_url("https://test.crm.dynamics.com")
.with_tenant_id("t")
.with_client_id("c")
.with_client_secret("s")
.with_entity("account")
.with_entity("contact")
.build()
.expect("should build");
assert_eq!(source.config.entities, vec!["account", "contact"]);
}
#[test]
fn test_builder_with_entity_set_override() {
let source = DataverseSource::builder("test")
.with_environment_url("https://test.crm.dynamics.com")
.with_tenant_id("t")
.with_client_id("c")
.with_client_secret("s")
.with_entity("activityparty")
.with_entity_set_override("activityparty", "activityparties")
.build()
.expect("should build");
assert_eq!(
source.config.entity_set_name("activityparty"),
"activityparties"
);
}
#[test]
fn test_builder_with_entity_columns() {
let source = DataverseSource::builder("test")
.with_environment_url("https://test.crm.dynamics.com")
.with_tenant_id("t")
.with_client_id("c")
.with_client_secret("s")
.with_entity("account")
.with_entity_columns("account", vec!["name".to_string(), "revenue".to_string()])
.build()
.expect("should build");
assert_eq!(
source.config.select_columns("account"),
Some("name,revenue,accountid".to_string())
);
}
#[test]
fn test_builder_with_identity_provider() {
let provider = drasi_lib::identity::PasswordIdentityProvider::new("user", "token");
let source = DataverseSource::builder("test")
.with_environment_url("https://test.crm.dynamics.com")
.with_entities(vec!["account".to_string()])
.with_identity_provider(provider)
.build()
.expect("should build with identity provider and no client credentials");
assert!(source.identity_provider.is_some());
}
#[test]
fn test_builder_with_identity_provider_still_needs_url() {
let provider = drasi_lib::identity::PasswordIdentityProvider::new("user", "token");
let result = DataverseSource::builder("test")
.with_entities(vec!["account".to_string()])
.with_identity_provider(provider)
.build();
assert!(result.is_err(), "should fail without environment_url");
}
#[test]
fn test_builder_with_identity_provider_still_needs_entities() {
let provider = drasi_lib::identity::PasswordIdentityProvider::new("user", "token");
let result = DataverseSource::builder("test")
.with_environment_url("https://test.crm.dynamics.com")
.with_identity_provider(provider)
.build();
assert!(result.is_err(), "should fail without entities");
}
}
mod change_conversion {
use super::*;
#[test]
fn test_convert_new_or_updated() {
let mut attributes = serde_json::Map::new();
attributes.insert(
"name".to_string(),
serde_json::Value::String("Contoso".to_string()),
);
attributes.insert("revenue".to_string(), serde_json::json!(1000000.0));
attributes.insert(
"accountid".to_string(),
serde_json::Value::String("abc-123".to_string()),
);
let change = DataverseChange::NewOrUpdated {
id: "abc-123".to_string(),
entity_name: "account".to_string(),
attributes,
};
let source_change = DataverseSource::convert_to_source_change("test-source", &change);
match source_change {
SourceChange::Update { element } => match element {
Element::Node {
metadata,
properties,
} => {
assert_eq!(metadata.reference.element_id.as_ref(), "account:abc-123");
assert_eq!(metadata.reference.source_id.as_ref(), "test-source");
assert_eq!(metadata.labels.len(), 1);
assert_eq!(metadata.labels[0].as_ref(), "account");
assert!(properties.get("name").is_some());
}
_ => panic!("Expected Node element"),
},
_ => panic!("Expected Update change"),
}
}
#[test]
fn test_convert_deleted() {
let change = DataverseChange::Deleted {
id: "def-456".to_string(),
entity_name: "contact".to_string(),
};
let source_change = DataverseSource::convert_to_source_change("test-source", &change);
match source_change {
SourceChange::Delete { metadata } => {
assert_eq!(metadata.reference.element_id.as_ref(), "contact:def-456");
assert_eq!(metadata.reference.source_id.as_ref(), "test-source");
assert_eq!(metadata.labels[0].as_ref(), "contact");
}
_ => panic!("Expected Delete change"),
}
}
#[test]
fn test_convert_json_value_primitives() {
assert_eq!(
DataverseSource::convert_json_value(&serde_json::Value::Null),
ElementValue::Null
);
assert_eq!(
DataverseSource::convert_json_value(&serde_json::json!(true)),
ElementValue::Bool(true)
);
assert_eq!(
DataverseSource::convert_json_value(&serde_json::json!(42)),
ElementValue::Integer(42)
);
assert_eq!(
DataverseSource::convert_json_value(&serde_json::json!(3.15)),
ElementValue::Float(ordered_float::OrderedFloat(3.15))
);
assert_eq!(
DataverseSource::convert_json_value(&serde_json::json!("hello")),
ElementValue::String(Arc::from("hello"))
);
}
#[test]
fn test_convert_json_value_extracts_value() {
let json = serde_json::json!({"Value": 123});
assert_eq!(
DataverseSource::convert_json_value(&json),
ElementValue::Integer(123)
);
}
#[test]
fn test_convert_json_value_multi_select_choice() {
let json = serde_json::json!([{"Value": 1}, {"Value": 2}]);
let result = DataverseSource::convert_json_value(&json);
match result {
ElementValue::List(values) => {
assert_eq!(values.len(), 2);
assert_eq!(values[0], ElementValue::Integer(1));
assert_eq!(values[1], ElementValue::Integer(2));
}
_ => panic!("Expected List"),
}
}
}
mod backoff {
use super::*;
#[test]
fn resets_to_min_when_changes_detected() {
let next = DataverseSource::next_backoff_interval(20_000, 500, 30_000, true);
assert_eq!(next, 500);
}
#[test]
fn slow_backoff_under_threshold() {
let next = DataverseSource::next_backoff_interval(1000, 500, 30_000, false);
assert_eq!(next, 1200);
let next = DataverseSource::next_backoff_interval(4000, 500, 30_000, false);
assert_eq!(next, 4800);
}
#[test]
fn fast_backoff_above_threshold() {
let next = DataverseSource::next_backoff_interval(5000, 500, 30_000, false);
assert_eq!(next, 7500);
let next = DataverseSource::next_backoff_interval(10_000, 500, 30_000, false);
assert_eq!(next, 15_000);
}
#[test]
fn does_not_exceed_max() {
let next = DataverseSource::next_backoff_interval(25_000, 500, 30_000, false);
assert_eq!(next, 30_000);
let next = DataverseSource::next_backoff_interval(30_000, 500, 30_000, false);
assert_eq!(next, 30_000);
}
#[test]
fn full_progression_no_changes() {
let min = 500;
let max = 30_000;
let mut current = min;
let mut steps = 0;
while current < max {
let next = DataverseSource::next_backoff_interval(current, min, max, false);
assert!(
next > current || next == max,
"interval should grow or hit the cap (was {current}, became {next})"
);
assert!(
next <= max,
"interval must never exceed max ({next} > {max})"
);
current = next;
steps += 1;
assert!(steps < 100, "backoff failed to converge");
}
assert_eq!(current, max);
}
#[test]
fn full_progression_with_change_resets() {
let min = 500;
let max = 30_000;
let mut current = min;
current = DataverseSource::next_backoff_interval(current, min, max, false);
current = DataverseSource::next_backoff_interval(current, min, max, false);
assert!(current > min);
current = DataverseSource::next_backoff_interval(current, min, max, true);
assert_eq!(current, min);
}
}
mod state_store_helpers {
use super::*;
use drasi_lib::MemoryStateStoreProvider;
fn store() -> Arc<dyn drasi_lib::StateStoreProvider> {
Arc::new(MemoryStateStoreProvider::new())
}
#[test]
fn delta_token_key_matches_platform_format() {
assert_eq!(
DataverseSource::delta_token_key("account"),
"account-deltatoken"
);
}
#[tokio::test]
async fn load_returns_none_on_empty_store() {
let s = store();
let result = DataverseSource::load_delta_token(&s, "src-1", "account").await;
assert!(
result.is_none(),
"no checkpoint should be present initially"
);
}
#[tokio::test]
async fn save_then_load_round_trip() {
let s = store();
DataverseSource::save_delta_token(&s, "src-1", "account", "delta-token-123").await;
let loaded = DataverseSource::load_delta_token(&s, "src-1", "account").await;
assert_eq!(loaded.as_deref(), Some("delta-token-123"));
}
#[tokio::test]
async fn checkpoints_are_isolated_per_entity() {
let s = store();
DataverseSource::save_delta_token(&s, "src-1", "account", "token-A").await;
DataverseSource::save_delta_token(&s, "src-1", "contact", "token-B").await;
assert_eq!(
DataverseSource::load_delta_token(&s, "src-1", "account")
.await
.as_deref(),
Some("token-A")
);
assert_eq!(
DataverseSource::load_delta_token(&s, "src-1", "contact")
.await
.as_deref(),
Some("token-B")
);
}
#[tokio::test]
async fn checkpoints_are_isolated_per_source() {
let s = store();
DataverseSource::save_delta_token(&s, "src-1", "account", "token-A").await;
DataverseSource::save_delta_token(&s, "src-2", "account", "token-B").await;
assert_eq!(
DataverseSource::load_delta_token(&s, "src-1", "account")
.await
.as_deref(),
Some("token-A")
);
assert_eq!(
DataverseSource::load_delta_token(&s, "src-2", "account")
.await
.as_deref(),
Some("token-B")
);
}
#[tokio::test]
async fn save_overwrites_previous_value() {
let s = store();
DataverseSource::save_delta_token(&s, "src-1", "account", "token-old").await;
DataverseSource::save_delta_token(&s, "src-1", "account", "token-new").await;
assert_eq!(
DataverseSource::load_delta_token(&s, "src-1", "account")
.await
.as_deref(),
Some("token-new")
);
}
#[tokio::test]
async fn load_skips_invalid_utf8() {
let s = store();
let key = DataverseSource::delta_token_key("account");
s.set("src-1", &key, vec![0xff, 0xfe, 0xfd])
.await
.expect("set should succeed");
let loaded = DataverseSource::load_delta_token(&s, "src-1", "account").await;
assert!(loaded.is_none(), "non-UTF8 stored value should be ignored");
}
}
}
#[cfg(feature = "dynamic-plugin")]
drasi_plugin_sdk::export_plugin!(
plugin_id = "dataverse-source",
core_version = env!("CARGO_PKG_VERSION"),
lib_version = env!("CARGO_PKG_VERSION"),
plugin_version = env!("CARGO_PKG_VERSION"),
source_descriptors = [descriptor::DataverseSourceDescriptor],
reaction_descriptors = [],
bootstrap_descriptors = [],
);