Skip to main content

laminar_connectors/mongodb/config/
mod.rs

1//! `MongoDB` connector configuration.
2//!
3//! Provides [`MongoDbSourceConfig`] for the CDC change stream source and
4//! [`MongoDbSinkConfig`] for the write sink. Both support construction
5//! from a generic [`ConnectorConfig`] key-value map and programmatic builders.
6//!
7//! User-supplied change-stream pipelines are limited to `$match` stages so they
8//! cannot alter `MongoDB`'s resume token.
9
10use std::time::Duration;
11
12use crate::config::ConnectorConfig;
13use crate::error::ConnectorError;
14
15use super::timeseries::{CollectionKind, TimeSeriesConfig, TimeSeriesGranularity};
16use super::write_model::WriteMode;
17
18const REMOVED_SOURCE_CONFIG_KEYS: &[&str] = &[
19    "batch.size",
20    "max.buffered.events",
21    "max.await.time.ms",
22    "resume.token.store",
23    "split.large.events",
24    "max.poll.records",
25];
26
27const SOURCE_CONFIG_KEYS: &[&str] = &[
28    "connection.uri",
29    "database",
30    "collection",
31    "output.mode",
32    "snapshot.mode",
33    "full.document.mode",
34    "objectid.columns",
35    "document.json.column",
36    "pipeline",
37    "max.buffered.bytes",
38    "laminar.source.name",
39    "_arrow_schema",
40    "_primary_key_columns",
41];
42
43pub(super) const DEFAULT_MAX_BUFFERED_BYTES: usize = 64 * 1024 * 1024;
44pub(super) const MAX_PIPELINE_STAGES: usize = 64;
45pub(super) const MAX_PIPELINE_JSON_BYTES: usize = 256 * 1024;
46const MIN_BUFFERED_BYTES: usize = 1024 * 1024;
47const MAX_BUFFERED_BYTES: usize = 4 * 1024 * 1024 * 1024;
48const ESTIMATED_BUFFERED_ITEM_BYTES: usize = 64 * 1024;
49const MAX_READER_CHANNEL_ITEMS: usize = 4096;
50const MAX_CURSOR_BATCH_ITEMS: u32 = 1000;
51
52const SINK_CONFIG_KEYS: &[&str] = &[
53    "auto.create",
54    "connection.uri",
55    "database",
56    "collection",
57    "flush.interval.ms",
58    "write.mode",
59    "write.mode.key_fields",
60    "replay.source.namespace",
61    "timeseries.time_field",
62    "timeseries.meta_field",
63    "timeseries.granularity",
64    "timeseries.bucket_max_span_seconds",
65    "timeseries.bucket_rounding_seconds",
66    "timeseries.expire_after_seconds",
67    "delivery.guarantee",
68    "sink.write.timeout.ms",
69    "_arrow_schema",
70];
71
72const REMOVED_SINK_CONFIG_KEYS: &[&str] = &["batch.size", "write_concern.timeout_ms"];
73
74/// Mode for requesting full documents on update events.
75///
76/// Controls the `fullDocument` option on the change stream cursor.
77/// The choice has significant correctness and performance implications.
78#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
79#[serde(rename_all = "snake_case")]
80pub enum FullDocumentMode {
81    /// Default: only the delta (`updateDescription`) for update events.
82    /// `fullDocument` is `None` on updates.
83    #[default]
84    Delta,
85
86    /// `fullDocument: "required"` — the collection must have
87    /// `changeStreamPreAndPostImages` enabled. Admission fails before the
88    /// change stream opens when the option is disabled.
89    #[serde(rename = "required")]
90    RequirePostImage,
91}
92
93str_enum!(FullDocumentMode, lowercase, ConnectorError, "unknown full document mode",
94    Delta => "delta";
95    RequirePostImage => "required"
96);
97
98/// What a `MongoDB` CDC source emits for each change.
99#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
100#[serde(rename_all = "snake_case")]
101pub enum SourceOutputMode {
102    /// Every captured event becomes an immutable, versioned history record.
103    #[default]
104    History,
105    /// Full documents become keyed puts and deletes become key-only tombstones.
106    Document,
107}
108
109str_enum!(SourceOutputMode, lowercase, ConnectorError, "unknown output mode",
110    History => "history";
111    Document => "document"
112);
113
114/// Whether a fresh source copies the existing collection before streaming changes.
115#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
116#[serde(rename_all = "snake_case")]
117pub enum SnapshotMode {
118    /// Capture only changes made after the source first opens.
119    #[default]
120    Never,
121    /// Copy the collection at one snapshot time, then stream every change from that time.
122    Initial,
123}
124
125str_enum!(SnapshotMode, lowercase, ConnectorError, "unknown snapshot mode",
126    Never => "never";
127    Initial => "initial"
128);
129
130/// Configuration for the `MongoDB` CDC source connector.
131#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
132#[serde(deny_unknown_fields)]
133pub struct MongoDbSourceConfig {
134    /// `MongoDB` connection URI (e.g., `mongodb://host:27017`).
135    pub connection_uri: String,
136
137    /// Database name.
138    pub database: String,
139
140    /// Fixed collection name.
141    pub collection: String,
142
143    /// History records or keyed document mutations.
144    #[serde(default)]
145    pub output_mode: SourceOutputMode,
146
147    /// Changes only, or an initial collection snapshot followed by changes.
148    #[serde(default)]
149    pub snapshot_mode: SnapshotMode,
150
151    /// Full document retrieval mode for update events.
152    pub full_document_mode: FullDocumentMode,
153
154    /// Document-mode `Utf8` columns whose BSON values must be `ObjectId`s.
155    #[serde(default)]
156    pub objectid_columns: Vec<String>,
157
158    /// Document-mode `Utf8` column that receives the complete canonical Extended JSON document.
159    #[serde(default)]
160    pub document_json_column: Option<String>,
161
162    /// Additional `$match` stages applied when the change stream opens.
163    #[serde(default)]
164    pub pipeline: Vec<serde_json::Value>,
165
166    /// Maximum retained decoded bytes across the reader channel and poll buffer.
167    #[serde(default = "default_max_buffered_bytes")]
168    pub max_buffered_bytes: usize,
169}
170
171fn default_max_buffered_bytes() -> usize {
172    DEFAULT_MAX_BUFFERED_BYTES
173}
174
175impl Default for MongoDbSourceConfig {
176    fn default() -> Self {
177        Self {
178            connection_uri: "mongodb://localhost:27017".to_string(),
179            database: String::new(),
180            collection: String::new(),
181            output_mode: SourceOutputMode::default(),
182            snapshot_mode: SnapshotMode::default(),
183            full_document_mode: FullDocumentMode::default(),
184            objectid_columns: Vec::new(),
185            document_json_column: None,
186            pipeline: Vec::new(),
187            max_buffered_bytes: default_max_buffered_bytes(),
188        }
189    }
190}
191
192impl MongoDbSourceConfig {
193    /// Creates a new source config with required fields.
194    #[must_use]
195    pub fn new(connection_uri: &str, database: &str, collection: &str) -> Self {
196        Self {
197            connection_uri: connection_uri.to_string(),
198            database: database.to_string(),
199            collection: collection.to_string(),
200            ..Self::default()
201        }
202    }
203
204    /// Private queue capacity derived from the one public ownership budget.
205    pub(crate) fn reader_channel_capacity(&self) -> usize {
206        (self.max_buffered_bytes / ESTIMATED_BUFFERED_ITEM_BYTES).clamp(1, MAX_READER_CHANNEL_ITEMS)
207    }
208
209    /// Cursor batch hint derived from the one public ownership budget.
210    pub(crate) fn cursor_batch_size(&self) -> u32 {
211        u32::try_from(self.reader_channel_capacity())
212            .unwrap_or(MAX_CURSOR_BATCH_ITEMS)
213            .min(MAX_CURSOR_BATCH_ITEMS)
214    }
215
216    /// Validates the configuration.
217    ///
218    /// # Errors
219    ///
220    /// Returns `ConnectorError::ConfigurationError` for invalid settings.
221    pub fn validate(&self) -> Result<(), ConnectorError> {
222        crate::config::require_non_empty(&self.connection_uri, "connection_uri")?;
223        crate::config::require_non_empty(&self.database, "database")?;
224        crate::config::require_non_empty(&self.collection, "collection")?;
225        if self.collection == "*" {
226            return Err(ConnectorError::ConfigurationError(
227                "MongoDB CDC collection must name one fixed collection; database-wide wildcard \
228                 streams cannot be bound to an immutable collection UUID"
229                    .into(),
230            ));
231        }
232
233        if !(MIN_BUFFERED_BYTES..=MAX_BUFFERED_BYTES).contains(&self.max_buffered_bytes) {
234            return Err(ConnectorError::ConfigurationError(format!(
235                "max.buffered.bytes must be between {MIN_BUFFERED_BYTES} and \
236                     {MAX_BUFFERED_BYTES}"
237            )));
238        }
239
240        validate_pipeline(&self.pipeline)?;
241        self.validate_output_shape()
242    }
243
244    fn validate_output_shape(&self) -> Result<(), ConnectorError> {
245        if self.snapshot_mode == SnapshotMode::Initial && !self.pipeline.is_empty() {
246            return Err(ConnectorError::ConfigurationError(
247                "MongoDB CDC snapshot.mode=initial cannot use a change-stream pipeline; its \
248                 $match stages filter change events, not the copied documents"
249                    .into(),
250            ));
251        }
252        match self.output_mode {
253            SourceOutputMode::History => {
254                if !self.objectid_columns.is_empty() || self.document_json_column.is_some() {
255                    return Err(ConnectorError::ConfigurationError(
256                        "MongoDB CDC objectid.columns and document.json.column require \
257                         output.mode=document"
258                            .into(),
259                    ));
260                }
261            }
262            SourceOutputMode::Document => {
263                if self.full_document_mode != FullDocumentMode::RequirePostImage {
264                    return Err(ConnectorError::ConfigurationError(
265                        "MongoDB CDC output.mode=document requires full.document.mode=required: \
266                         a keyed put needs the exact post-image of every update"
267                            .into(),
268                    ));
269                }
270                if !self.pipeline.is_empty() {
271                    return Err(ConnectorError::ConfigurationError(
272                        "MongoDB CDC output.mode=document cannot use a change-stream pipeline; \
273                         filtering changes would leave stale documents in the target"
274                            .into(),
275                    ));
276                }
277                let mut seen = std::collections::HashSet::new();
278                for column in self
279                    .objectid_columns
280                    .iter()
281                    .chain(self.document_json_column.iter())
282                {
283                    if column.trim().is_empty() || !seen.insert(column.as_str()) {
284                        return Err(ConnectorError::ConfigurationError(format!(
285                            "MongoDB CDC projection column '{column}' is empty or listed twice"
286                        )));
287                    }
288                }
289            }
290        }
291        Ok(())
292    }
293
294    /// Parses configuration from a generic [`ConnectorConfig`].
295    ///
296    /// # Errors
297    ///
298    /// Returns `ConnectorError` if required keys are missing or invalid.
299    pub fn from_config(config: &ConnectorConfig) -> Result<Self, ConnectorError> {
300        if let Some(key) = REMOVED_SOURCE_CONFIG_KEYS
301            .iter()
302            .find(|key| config.get(key).is_some())
303        {
304            let reason = if *key == "max.buffered.events" {
305                "retained ownership is bounded by max.buffered.bytes instead"
306            } else {
307                "the connector did not execute it"
308            };
309            return Err(ConnectorError::ConfigurationError(format!(
310                "MongoDB CDC property '{key}' is not supported: {reason}"
311            )));
312        }
313
314        config.reject_unknown_properties(SOURCE_CONFIG_KEYS, "MongoDB CDC")?;
315
316        let mut cfg = Self {
317            connection_uri: config.require("connection.uri")?.to_string(),
318            database: config.require("database")?.to_string(),
319            collection: config.require("collection")?.to_string(),
320            ..Self::default()
321        };
322
323        if let Some(mode) = config.get_parsed::<SourceOutputMode>("output.mode")? {
324            cfg.output_mode = mode;
325        }
326        if let Some(mode) = config.get_parsed::<SnapshotMode>("snapshot.mode")? {
327            cfg.snapshot_mode = mode;
328        }
329        if let Some(mode) = config.get_parsed::<FullDocumentMode>("full.document.mode")? {
330            cfg.full_document_mode = mode;
331        }
332        if let Some(columns) = config.get("objectid.columns") {
333            cfg.objectid_columns = columns.split(',').map(|c| c.trim().to_string()).collect();
334        }
335        cfg.document_json_column = config
336            .get("document.json.column")
337            .map(|column| column.trim().to_string());
338        if let Some(pipeline) = config.get("pipeline") {
339            cfg.pipeline = parse_pipeline_property(pipeline)?;
340        }
341        if let Some(max) = config.get_parsed::<usize>("max.buffered.bytes")? {
342            cfg.max_buffered_bytes = max;
343        }
344
345        cfg.validate()?;
346        Ok(cfg)
347    }
348}
349
350fn canonicalize_json(value: serde_json::Value) -> serde_json::Value {
351    match value {
352        serde_json::Value::Array(values) => {
353            serde_json::Value::Array(values.into_iter().map(canonicalize_json).collect())
354        }
355        serde_json::Value::Object(object) => {
356            let mut fields: Vec<_> = object.into_iter().collect();
357            fields.sort_unstable_by(|left, right| left.0.cmp(&right.0));
358            serde_json::Value::Object(
359                fields
360                    .into_iter()
361                    .map(|(key, value)| (key, canonicalize_json(value)))
362                    .collect(),
363            )
364        }
365        scalar => scalar,
366    }
367}
368
369pub(crate) fn canonical_pipeline_json(pipeline: &[serde_json::Value]) -> String {
370    let canonical = canonicalize_json(serde_json::Value::Array(pipeline.to_vec()));
371    serde_json::to_string(&canonical).expect("serde_json::Value serialization cannot fail")
372}
373
374fn normalized_pipeline(
375    pipeline: &[serde_json::Value],
376) -> Result<Vec<serde_json::Value>, ConnectorError> {
377    if pipeline.len() > MAX_PIPELINE_STAGES {
378        return Err(ConnectorError::ConfigurationError(format!(
379            "MongoDB CDC pipeline has {} stages; the maximum is {MAX_PIPELINE_STAGES}",
380            pipeline.len()
381        )));
382    }
383
384    let encoded = serde_json::to_vec(pipeline).map_err(|error| {
385        ConnectorError::ConfigurationError(format!(
386            "MongoDB CDC pipeline cannot be encoded as JSON: {error}"
387        ))
388    })?;
389    if encoded.len() > MAX_PIPELINE_JSON_BYTES {
390        return Err(ConnectorError::ConfigurationError(format!(
391            "MongoDB CDC pipeline is {} bytes; the maximum is {MAX_PIPELINE_JSON_BYTES}",
392            encoded.len()
393        )));
394    }
395
396    let canonical: Vec<_> = pipeline.iter().cloned().map(canonicalize_json).collect();
397    for (i, stage) in canonical.iter().enumerate() {
398        let obj = stage.as_object().ok_or_else(|| {
399            ConnectorError::ConfigurationError(format!("pipeline stage {i} must be a JSON object"))
400        })?;
401        if obj.len() != 1 || !obj.contains_key("$match") {
402            return Err(ConnectorError::ConfigurationError(format!(
403                "pipeline stage {i} is unsafe: MongoDB CDC only supports $match stages because \
404                 projection/replacement stages can alter the resume token"
405            )));
406        }
407        if !obj["$match"].is_object() {
408            return Err(ConnectorError::ConfigurationError(format!(
409                "pipeline stage {i} must contain a JSON object as $match"
410            )));
411        }
412        mongodb::bson::to_document(stage).map_err(|error| {
413            ConnectorError::ConfigurationError(format!(
414                "pipeline stage {i} cannot be represented as BSON: {error}"
415            ))
416        })?;
417    }
418
419    let canonical_bytes = canonical_pipeline_json(&canonical).len();
420    if canonical_bytes > MAX_PIPELINE_JSON_BYTES {
421        return Err(ConnectorError::ConfigurationError(format!(
422            "canonical MongoDB CDC pipeline is {canonical_bytes} bytes; the maximum is \
423             {MAX_PIPELINE_JSON_BYTES}"
424        )));
425    }
426    Ok(canonical)
427}
428
429fn parse_pipeline_property(raw: &str) -> Result<Vec<serde_json::Value>, ConnectorError> {
430    if raw.len() > MAX_PIPELINE_JSON_BYTES {
431        return Err(ConnectorError::ConfigurationError(format!(
432            "MongoDB CDC pipeline property is {} bytes; the maximum is \
433             {MAX_PIPELINE_JSON_BYTES}",
434            raw.len()
435        )));
436    }
437    let value: serde_json::Value = serde_json::from_str(raw).map_err(|error| {
438        ConnectorError::ConfigurationError(format!(
439            "MongoDB CDC pipeline must be valid JSON: {error}"
440        ))
441    })?;
442    let serde_json::Value::Array(pipeline) = value else {
443        return Err(ConnectorError::ConfigurationError(
444            "MongoDB CDC pipeline must be a JSON array".into(),
445        ));
446    };
447    normalized_pipeline(&pipeline)
448}
449
450/// Restrict change-stream pipelines to bounded `$match` stages.
451fn validate_pipeline(pipeline: &[serde_json::Value]) -> Result<(), ConnectorError> {
452    normalized_pipeline(pipeline).map(|_| ())
453}
454
455impl MongoDbSourceConfig {
456    pub(crate) fn normalize_pipeline(&mut self) -> Result<(), ConnectorError> {
457        self.pipeline = normalized_pipeline(&self.pipeline)?;
458        Ok(())
459    }
460}
461
462fn validate_sink_properties(config: &ConnectorConfig) -> Result<(), ConnectorError> {
463    if let Some(key) = REMOVED_SINK_CONFIG_KEYS
464        .iter()
465        .find(|key| config.get(key).is_some())
466    {
467        let replacement = match *key {
468            "batch.size" => "fixed memory and MongoDB wire limits govern batching",
469            "write_concern.timeout_ms" => {
470                "sink.write.timeout.ms governs the complete write deadline"
471            }
472            _ => unreachable!("removed sink key must have a migration message"),
473        };
474        return Err(ConnectorError::ConfigurationError(format!(
475            "MongoDB sink property '{key}' is not supported; {replacement}"
476        )));
477    }
478    config.reject_unknown_properties(SINK_CONFIG_KEYS, "MongoDB sink")?;
479
480    Ok(())
481}
482
483fn replay_source_namespace(
484    config: &ConnectorConfig,
485    write_mode: &WriteMode,
486) -> Result<Option<String>, ConnectorError> {
487    let Some(namespace) = config.get("replay.source.namespace") else {
488        return Ok(None);
489    };
490    if !matches!(write_mode, WriteMode::CdcReplay) {
491        return Err(ConnectorError::ConfigurationError(
492            "MongoDB sink property 'replay.source.namespace' is only valid for \
493             write.mode=cdc_replay"
494                .into(),
495        ));
496    }
497    match namespace.split_once('.') {
498        Some((database, collection)) if !database.is_empty() && !collection.is_empty() => {
499            Ok(Some(namespace.to_string()))
500        }
501        _ => Err(ConnectorError::ConfigurationError(format!(
502            "replay.source.namespace '{namespace}' must be '<database>.<collection>'"
503        ))),
504    }
505}
506
507/// Configuration for the `MongoDB` sink connector.
508#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
509#[serde(deny_unknown_fields)]
510pub struct MongoDbSinkConfig {
511    /// `MongoDB` connection URI.
512    pub connection_uri: String,
513
514    /// Target database name.
515    pub database: String,
516
517    /// Target collection name.
518    pub collection: String,
519
520    /// Whether the target is a standard or time series collection.
521    #[serde(default)]
522    pub collection_kind: CollectionKind,
523
524    /// Explicit permission to create a missing standard collection during preparation.
525    #[serde(default)]
526    pub auto_create: bool,
527
528    /// Write operation mode.
529    #[serde(default)]
530    pub write_mode: WriteMode,
531
532    /// Maximum time between flushes in milliseconds.
533    #[serde(default = "default_flush_interval_ms")]
534    pub flush_interval_ms: u64,
535
536    /// `database.collection` whose history records `cdc_replay` applies to this target.
537    /// Defaults to the target namespace itself.
538    #[serde(default)]
539    pub replay_source_namespace: Option<String>,
540}
541
542fn default_flush_interval_ms() -> u64 {
543    250
544}
545
546impl Default for MongoDbSinkConfig {
547    fn default() -> Self {
548        Self {
549            connection_uri: "mongodb://localhost:27017".to_string(),
550            database: String::new(),
551            collection: String::new(),
552            collection_kind: CollectionKind::default(),
553            auto_create: false,
554            write_mode: WriteMode::default(),
555            flush_interval_ms: default_flush_interval_ms(),
556            replay_source_namespace: None,
557        }
558    }
559}
560
561impl MongoDbSinkConfig {
562    /// Creates a new sink config with required fields.
563    #[must_use]
564    pub fn new(connection_uri: &str, database: &str, collection: &str) -> Self {
565        Self {
566            connection_uri: connection_uri.to_string(),
567            database: database.to_string(),
568            collection: collection.to_string(),
569            ..Self::default()
570        }
571    }
572
573    /// Returns the flush interval as a `Duration`.
574    #[must_use]
575    pub fn flush_interval(&self) -> Duration {
576        Duration::from_millis(self.flush_interval_ms)
577    }
578
579    /// Validates the configuration.
580    ///
581    /// # Errors
582    ///
583    /// Returns `ConnectorError::ConfigurationError` for invalid settings.
584    pub fn validate(&self) -> Result<(), ConnectorError> {
585        crate::config::require_non_empty(&self.connection_uri, "connection_uri")?;
586        crate::config::require_non_empty(&self.database, "database")?;
587        crate::config::require_non_empty(&self.collection, "collection")?;
588        if self.collection == "*" {
589            return Err(ConnectorError::ConfigurationError(
590                "MongoDB sink collection must name one fixed destination".into(),
591            ));
592        }
593
594        if self.flush_interval_ms == 0 {
595            return Err(ConnectorError::ConfigurationError(
596                "flush_interval_ms must be > 0".to_string(),
597            ));
598        }
599        if let WriteMode::Upsert { key_fields } = &self.write_mode {
600            let mut unique = std::collections::HashSet::with_capacity(key_fields.len());
601            for key in key_fields {
602                if key.trim().is_empty() {
603                    return Err(ConnectorError::ConfigurationError(
604                        "write.mode.key_fields must not contain empty fields".to_string(),
605                    ));
606                }
607                if matches!(key.as_str(), "_op" | "__weight") {
608                    return Err(ConnectorError::ConfigurationError(format!(
609                        "write.mode.key_fields cannot use engine metadata field '{key}'"
610                    )));
611                }
612                if key.starts_with('$') || key.contains('.') {
613                    return Err(ConnectorError::ConfigurationError(format!(
614                        "write.mode.key_fields entry '{key}' must be a top-level MongoDB field and cannot start with '$' or contain '.'"
615                    )));
616                }
617                if !unique.insert(key.as_str()) {
618                    return Err(ConnectorError::ConfigurationError(format!(
619                        "write.mode.key_fields contains duplicate field '{key}'"
620                    )));
621                }
622            }
623            if unique.is_empty() {
624                return Err(ConnectorError::ConfigurationError(
625                    "write.mode.key_fields must contain at least one field".to_string(),
626                ));
627            }
628        }
629
630        // Time series collections only support Insert mode.
631        if let CollectionKind::TimeSeries(time_series) = &self.collection_kind {
632            time_series.validate()?;
633            super::write_model::validate_timeseries_write_mode(&self.write_mode)?;
634        }
635
636        Ok(())
637    }
638
639    /// Parses configuration from a generic [`ConnectorConfig`].
640    ///
641    /// # Errors
642    ///
643    /// Returns `ConnectorError` if required keys are missing or invalid.
644    pub fn from_config(config: &ConnectorConfig) -> Result<Self, ConnectorError> {
645        validate_sink_properties(config)?;
646
647        let mut cfg = Self {
648            connection_uri: config.require("connection.uri")?.to_string(),
649            database: config.require("database")?.to_string(),
650            collection: config.require("collection")?.to_string(),
651            ..Self::default()
652        };
653
654        cfg.auto_create = config.get_parsed::<bool>("auto.create")?.unwrap_or(false);
655        if let Some(interval) = config.get_parsed::<u64>("flush.interval.ms")? {
656            cfg.flush_interval_ms = interval;
657        }
658        if let Some(mode) = config.get("write.mode") {
659            cfg.write_mode = match mode {
660                "insert" => WriteMode::Insert,
661                "cdc_replay" | "cdc-replay" => WriteMode::CdcReplay,
662                "upsert" => {
663                    let keys = config
664                        .require("write.mode.key_fields")?
665                        .split(',')
666                        .map(|s| s.trim().to_string())
667                        .collect();
668                    WriteMode::Upsert { key_fields: keys }
669                }
670                other => {
671                    return Err(ConnectorError::ConfigurationError(format!(
672                        "unknown write.mode: {other}"
673                    )));
674                }
675            };
676        }
677
678        cfg.replay_source_namespace = replay_source_namespace(config, &cfg.write_mode)?;
679        if config.get("write.mode.key_fields").is_some()
680            && !matches!(&cfg.write_mode, WriteMode::Upsert { .. })
681        {
682            return Err(ConnectorError::ConfigurationError(
683                "MongoDB sink property 'write.mode.key_fields' is only valid for write.mode=upsert"
684                    .to_string(),
685            ));
686        }
687        if let Some(time_field) = config.get("timeseries.time_field") {
688            if time_field.trim().is_empty() {
689                return Err(ConnectorError::ConfigurationError(
690                    "timeseries.time_field must not be empty".to_string(),
691                ));
692            }
693            let meta_field = config
694                .get("timeseries.meta_field")
695                .map(str::trim)
696                .filter(|value| !value.is_empty())
697                .map(String::from);
698            let granularity = if let Some(gran_str) = config.get("timeseries.granularity") {
699                match gran_str.to_lowercase().as_str() {
700                    "seconds" => TimeSeriesGranularity::Seconds,
701                    "minutes" => TimeSeriesGranularity::Minutes,
702                    "hours" => TimeSeriesGranularity::Hours,
703                    "custom" => {
704                        let span =
705                            config.require_parsed::<u32>("timeseries.bucket_max_span_seconds")?;
706                        let rounding =
707                            config.require_parsed::<u32>("timeseries.bucket_rounding_seconds")?;
708                        TimeSeriesGranularity::custom(span, rounding)?
709                    }
710                    other => {
711                        return Err(ConnectorError::ConfigurationError(format!(
712                            "unknown timeseries granularity: {other}"
713                        )));
714                    }
715                }
716            } else {
717                TimeSeriesGranularity::Seconds
718            };
719
720            let expire_after_seconds =
721                config.get_parsed::<u64>("timeseries.expire_after_seconds")?;
722
723            cfg.collection_kind = CollectionKind::TimeSeries(TimeSeriesConfig {
724                time_field: time_field.to_string(),
725                meta_field,
726                granularity,
727                expire_after_seconds,
728            });
729        } else if let Some(key) = [
730            "timeseries.meta_field",
731            "timeseries.granularity",
732            "timeseries.bucket_max_span_seconds",
733            "timeseries.bucket_rounding_seconds",
734            "timeseries.expire_after_seconds",
735        ]
736        .into_iter()
737        .find(|key| config.get(key).is_some())
738        {
739            return Err(ConnectorError::ConfigurationError(format!(
740                "MongoDB sink property '{key}' requires 'timeseries.time_field'"
741            )));
742        }
743
744        if !matches!(
745            &cfg.collection_kind,
746            CollectionKind::TimeSeries(TimeSeriesConfig {
747                granularity: TimeSeriesGranularity::Custom { .. },
748                ..
749            })
750        ) {
751            if let Some(key) = [
752                "timeseries.bucket_max_span_seconds",
753                "timeseries.bucket_rounding_seconds",
754            ]
755            .into_iter()
756            .find(|key| config.get(key).is_some())
757            {
758                return Err(ConnectorError::ConfigurationError(format!(
759                    "MongoDB sink property '{key}' is only valid for timeseries.granularity=custom"
760                )));
761            }
762        }
763
764        cfg.validate()?;
765        Ok(cfg)
766    }
767}
768
769#[cfg(test)]
770mod tests;