1use crate::auth_catalog::{self, AuthCatalog};
9use crate::error::{CliError, CliResult};
10use faucet_core::{Sink, Source};
11use serde::de::DeserializeOwned;
12use serde_json::Value;
13use std::collections::BTreeMap;
14use std::sync::{Arc, OnceLock};
15
16pub type SourceFactory = Arc<dyn Fn(Value) -> CliResult<Box<dyn Source>> + Send + Sync>;
26
27pub type SinkFactory = Arc<dyn Fn(Value) -> CliResult<Box<dyn Sink>> + Send + Sync>;
30
31pub type SchemaFn = Arc<dyn Fn() -> Value + Send + Sync>;
35
36struct SourceEntry {
37 factory: SourceFactory,
38 schema: SchemaFn,
39 description: &'static str,
40}
41
42struct SinkEntry {
43 factory: SinkFactory,
44 schema: SchemaFn,
45 description: &'static str,
46}
47
48#[derive(Default)]
77pub struct PluginRegistry {
78 sources: BTreeMap<&'static str, SourceEntry>,
79 sinks: BTreeMap<&'static str, SinkEntry>,
80 errors: Vec<String>,
84}
85
86impl PluginRegistry {
87 pub fn new() -> Self {
89 Self::default()
90 }
91
92 pub fn with_builtins() -> Self {
98 Self::default()
99 }
100
101 #[must_use]
105 pub fn register_source<F>(self, name: &str, factory: F) -> Self
106 where
107 F: Fn(Value) -> CliResult<Box<dyn Source>> + Send + Sync + 'static,
108 {
109 self.register_source_with(name, factory, || serde_json::json!({"type": "object"}), "")
110 }
111
112 #[must_use]
115 pub fn register_source_with<F, S>(
116 mut self,
117 name: &str,
118 factory: F,
119 schema: S,
120 description: &str,
121 ) -> Self
122 where
123 F: Fn(Value) -> CliResult<Box<dyn Source>> + Send + Sync + 'static,
124 S: Fn() -> Value + Send + Sync + 'static,
125 {
126 let key = leak_str(name);
127 if builtin_source_descriptions().iter().any(|(n, _)| *n == key) {
128 self.errors.push(format!(
129 "cannot register source `{name}`: a built-in source already uses that name"
130 ));
131 return self;
132 }
133 if self.sources.contains_key(key) {
134 self.errors
135 .push(format!("source `{name}` is registered more than once"));
136 return self;
137 }
138 self.sources.insert(
139 key,
140 SourceEntry {
141 factory: Arc::new(factory),
142 schema: Arc::new(schema),
143 description: leak_str(description),
144 },
145 );
146 self
147 }
148
149 #[must_use]
151 pub fn register_sink<F>(self, name: &str, factory: F) -> Self
152 where
153 F: Fn(Value) -> CliResult<Box<dyn Sink>> + Send + Sync + 'static,
154 {
155 self.register_sink_with(name, factory, || serde_json::json!({"type": "object"}), "")
156 }
157
158 #[must_use]
160 pub fn register_sink_with<F, S>(
161 mut self,
162 name: &str,
163 factory: F,
164 schema: S,
165 description: &str,
166 ) -> Self
167 where
168 F: Fn(Value) -> CliResult<Box<dyn Sink>> + Send + Sync + 'static,
169 S: Fn() -> Value + Send + Sync + 'static,
170 {
171 let key = leak_str(name);
172 if builtin_sink_descriptions().iter().any(|(n, _)| *n == key) {
173 self.errors.push(format!(
174 "cannot register sink `{name}`: a built-in sink already uses that name"
175 ));
176 return self;
177 }
178 if self.sinks.contains_key(key) {
179 self.errors
180 .push(format!("sink `{name}` is registered more than once"));
181 return self;
182 }
183 self.sinks.insert(
184 key,
185 SinkEntry {
186 factory: Arc::new(factory),
187 schema: Arc::new(schema),
188 description: leak_str(description),
189 },
190 );
191 self
192 }
193
194 pub fn install(self) -> CliResult<()> {
198 if !self.errors.is_empty() {
199 return Err(CliError::Config(self.errors.join("; ")));
200 }
201 GLOBAL_REGISTRY
202 .set(self)
203 .map_err(|_| CliError::Config("connector registry already installed".to_owned()))
204 }
205
206 fn custom_source_descriptions(&self) -> Vec<(&'static str, &'static str)> {
207 self.sources
208 .iter()
209 .map(|(name, e)| {
210 (
211 *name,
212 if e.description.is_empty() {
213 "custom source connector"
214 } else {
215 e.description
216 },
217 )
218 })
219 .collect()
220 }
221
222 fn custom_sink_descriptions(&self) -> Vec<(&'static str, &'static str)> {
223 self.sinks
224 .iter()
225 .map(|(name, e)| {
226 (
227 *name,
228 if e.description.is_empty() {
229 "custom sink connector"
230 } else {
231 e.description
232 },
233 )
234 })
235 .collect()
236 }
237}
238
239fn leak_str(s: &str) -> &'static str {
244 Box::leak(s.to_owned().into_boxed_str())
245}
246
247static GLOBAL_REGISTRY: OnceLock<PluginRegistry> = OnceLock::new();
248
249fn global() -> &'static PluginRegistry {
252 GLOBAL_REGISTRY.get_or_init(PluginRegistry::default)
253}
254
255pub async fn build_source(
259 kind: &str,
260 config: Value,
261 auth: &AuthCatalog,
262 retry_policy: Option<&faucet_core::RetryPolicy>,
263) -> CliResult<Box<dyn Source>> {
264 if let Some(entry) = global().sources.get(kind) {
270 return (entry.factory)(config);
271 }
272 let auth_ref = auth_catalog::auth_ref(&config);
273 match kind {
274 #[cfg(feature = "source-rest")]
275 "rest" => {
276 let cfg = decode::<faucet_source_rest::RestStreamConfig>("source", "rest", config)?;
277 let mut s = faucet_source_rest::RestStream::new(cfg)?;
278 if let Some(name) = &auth_ref {
279 s = s.with_auth_provider(auth_catalog::resolve(auth, name)?);
280 }
281 if let Some(rp) = retry_policy {
282 s = s.with_retry_policy(rp.clone());
283 }
284 Ok(Box::new(s))
285 }
286 #[cfg(feature = "source-graphql")]
287 "graphql" => {
288 let cfg =
289 decode::<faucet_source_graphql::GraphqlStreamConfig>("source", "graphql", config)?;
290 let mut s = faucet_source_graphql::GraphqlStream::new(cfg);
291 if let Some(name) = &auth_ref {
292 s = s.with_auth_provider(auth_catalog::resolve(auth, name)?);
293 }
294 if let Some(rp) = retry_policy {
295 s = s.with_retry_policy(rp.clone());
296 }
297 Ok(Box::new(s))
298 }
299 #[cfg(feature = "source-xml")]
300 "xml" => {
301 let cfg = decode::<faucet_source_xml::XmlStreamConfig>("source", "xml", config)?;
302 let mut s = faucet_source_xml::XmlStream::new(cfg);
303 if let Some(name) = &auth_ref {
304 s = s.with_auth_provider(auth_catalog::resolve(auth, name)?);
305 }
306 if let Some(rp) = retry_policy {
307 s = s.with_retry_policy(rp.clone());
308 }
309 Ok(Box::new(s))
310 }
311 #[cfg(feature = "source-grpc")]
312 "grpc" => {
313 let cfg = decode::<faucet_source_grpc::GrpcStreamConfig>("source", "grpc", config)?;
314 let mut s = faucet_source_grpc::GrpcStream::new(cfg)?;
315 if let Some(name) = &auth_ref {
316 s = s.with_auth_provider(auth_catalog::resolve(auth, name)?);
317 }
318 Ok(Box::new(s))
319 }
320 #[cfg(feature = "source-postgres")]
321 "postgres" => {
322 let cfg = decode::<faucet_source_postgres::PostgresSourceConfig>(
323 "source", "postgres", config,
324 )?;
325 Ok(Box::new(
326 faucet_source_postgres::PostgresSource::new(cfg).await?,
327 ))
328 }
329 #[cfg(feature = "source-postgres-cdc")]
330 "postgres-cdc" => {
331 let cfg = decode::<faucet_source_postgres_cdc::PostgresCdcSourceConfig>(
332 "source",
333 "postgres-cdc",
334 config,
335 )?;
336 Ok(Box::new(
337 faucet_source_postgres_cdc::PostgresCdcSource::new(cfg).await?,
338 ))
339 }
340 #[cfg(feature = "source-mysql")]
341 "mysql" => {
342 let cfg = decode::<faucet_source_mysql::MysqlSourceConfig>("source", "mysql", config)?;
343 Ok(Box::new(faucet_source_mysql::MysqlSource::new(cfg).await?))
344 }
345 #[cfg(feature = "source-mssql")]
346 "mssql" => {
347 let cfg = decode::<faucet_source_mssql::MssqlSourceConfig>("source", "mssql", config)?;
348 Ok(Box::new(faucet_source_mssql::MssqlSource::new(cfg).await?))
349 }
350 #[cfg(feature = "source-sqlite")]
351 "sqlite" => {
352 let cfg =
353 decode::<faucet_source_sqlite::SqliteSourceConfig>("source", "sqlite", config)?;
354 Ok(Box::new(
355 faucet_source_sqlite::SqliteSource::new(cfg).await?,
356 ))
357 }
358 #[cfg(feature = "source-duckdb")]
359 "duckdb" => {
360 let cfg =
361 decode::<faucet_source_duckdb::DuckdbSourceConfig>("source", "duckdb", config)?;
362 Ok(Box::new(
363 faucet_source_duckdb::DuckdbSource::new(cfg).await?,
364 ))
365 }
366 #[cfg(feature = "source-sqs")]
367 "sqs" => {
368 let cfg = decode::<faucet_source_sqs::SqsSourceConfig>("source", "sqs", config)?;
369 Ok(Box::new(faucet_source_sqs::SqsSource::new(cfg).await?))
370 }
371 #[cfg(feature = "source-nats")]
372 "nats" => {
373 let cfg = decode::<faucet_source_nats::NatsSourceConfig>("source", "nats", config)?;
374 Ok(Box::new(faucet_source_nats::NatsSource::new(cfg).await?))
375 }
376 #[cfg(feature = "source-sftp")]
377 "sftp" => {
378 let cfg = decode::<faucet_source_sftp::SftpSourceConfig>("source", "sftp", config)?;
379 Ok(Box::new(faucet_source_sftp::SftpSource::new(cfg)?))
380 }
381 #[cfg(feature = "source-s3")]
382 "s3" => {
383 let cfg = decode::<faucet_source_s3::S3SourceConfig>("source", "s3", config)?;
384 Ok(Box::new(faucet_source_s3::S3Source::new(cfg).await?))
385 }
386 #[cfg(feature = "source-mongodb")]
387 "mongodb" => {
388 let cfg =
389 decode::<faucet_source_mongodb::MongoSourceConfig>("source", "mongodb", config)?;
390 Ok(Box::new(
391 faucet_source_mongodb::MongoSource::new(cfg).await?,
392 ))
393 }
394 #[cfg(feature = "source-mongodb-cdc")]
395 "mongodb-cdc" => {
396 let cfg = decode::<faucet_source_mongodb_cdc::MongoCdcSourceConfig>(
397 "source",
398 "mongodb-cdc",
399 config,
400 )?;
401 Ok(Box::new(
402 faucet_source_mongodb_cdc::MongoCdcSource::new(cfg).await?,
403 ))
404 }
405 #[cfg(feature = "source-mysql-cdc")]
406 "mysql-cdc" => {
407 let cfg = decode::<faucet_source_mysql_cdc::MysqlCdcSourceConfig>(
408 "source",
409 "mysql-cdc",
410 config,
411 )?;
412 Ok(Box::new(
413 faucet_source_mysql_cdc::MysqlCdcSource::new(cfg).await?,
414 ))
415 }
416 #[cfg(feature = "source-redis")]
417 "redis" => {
418 let cfg = decode::<faucet_source_redis::RedisSourceConfig>("source", "redis", config)?;
419 Ok(Box::new(faucet_source_redis::RedisSource::new(cfg)?))
420 }
421 #[cfg(feature = "source-webhook")]
422 "webhook" => {
423 let cfg =
424 decode::<faucet_source_webhook::WebhookSourceConfig>("source", "webhook", config)?;
425 Ok(Box::new(faucet_source_webhook::WebhookSource::new(cfg)))
426 }
427 #[cfg(feature = "source-websocket")]
428 "websocket" => {
429 let cfg = decode::<faucet_source_websocket::WebsocketSourceConfig>(
430 "source",
431 "websocket",
432 config,
433 )?;
434 let mut s = faucet_source_websocket::WebsocketSource::new(cfg)?;
435 if let Some(name) = &auth_ref {
436 s = s.with_auth_provider(auth_catalog::resolve(auth, name)?);
437 }
438 Ok(Box::new(s))
439 }
440 #[cfg(feature = "source-csv")]
441 "csv" => {
442 let cfg = decode::<faucet_source_csv::CsvSourceConfig>("source", "csv", config)?;
443 Ok(Box::new(faucet_source_csv::CsvSource::new(cfg)))
444 }
445 #[cfg(feature = "source-singer")]
446 "singer" => {
447 let cfg =
448 decode::<faucet_source_singer::SingerSourceConfig>("source", "singer", config)?;
449 Ok(Box::new(faucet_source_singer::SingerSource::new(cfg)))
450 }
451 #[cfg(feature = "source-elasticsearch")]
452 "elasticsearch" => {
453 let cfg = decode::<faucet_source_elasticsearch::ElasticsearchSourceConfig>(
454 "source",
455 "elasticsearch",
456 config,
457 )?;
458 let mut s = faucet_source_elasticsearch::ElasticsearchSource::new(cfg)?;
459 if let Some(name) = &auth_ref {
460 s = s.with_auth_provider(auth_catalog::resolve(auth, name)?);
461 }
462 Ok(Box::new(s))
463 }
464 #[cfg(feature = "source-kafka")]
465 "kafka" => {
466 let cfg = decode::<faucet_source_kafka::KafkaSourceConfig>("source", "kafka", config)?;
467 Ok(Box::new(faucet_source_kafka::KafkaSource::new(cfg).await?))
468 }
469 #[cfg(feature = "source-kinesis")]
470 "kinesis" => {
471 let cfg =
472 decode::<faucet_source_kinesis::KinesisSourceConfig>("source", "kinesis", config)?;
473 Ok(Box::new(
474 faucet_source_kinesis::KinesisSource::new(cfg).await?,
475 ))
476 }
477 #[cfg(feature = "source-spanner")]
478 "spanner" => {
479 let cfg =
480 decode::<faucet_source_spanner::SpannerSourceConfig>("source", "spanner", config)?;
481 Ok(Box::new(
482 faucet_source_spanner::SpannerSource::new(cfg).await?,
483 ))
484 }
485 #[cfg(feature = "source-parquet")]
486 "parquet" => {
487 let cfg =
488 decode::<faucet_source_parquet::ParquetSourceConfig>("source", "parquet", config)?;
489 Ok(Box::new(
490 faucet_source_parquet::ParquetSource::new(cfg).await?,
491 ))
492 }
493 #[cfg(feature = "source-delta")]
494 "delta" => {
495 let cfg = decode::<faucet_source_delta::DeltaSourceConfig>("source", "delta", config)?;
496 Ok(Box::new(faucet_source_delta::DeltaSource::new(cfg).await?))
497 }
498 #[cfg(feature = "source-databricks")]
499 "databricks" => {
500 let cfg = decode::<faucet_source_databricks::DatabricksSourceConfig>(
501 "source",
502 "databricks",
503 config,
504 )?;
505 let mut s = faucet_source_databricks::DatabricksSource::new(cfg)?;
506 if let Some(name) = &auth_ref {
507 s = s.with_auth_provider(auth_catalog::resolve(auth, name)?);
508 }
509 Ok(Box::new(s))
510 }
511 #[cfg(feature = "source-gcs")]
512 "gcs" => {
513 let cfg = decode::<faucet_source_gcs::GcsSourceConfig>("source", "gcs", config)?;
514 Ok(Box::new(faucet_source_gcs::GcsSource::new(cfg).await?))
515 }
516 #[cfg(feature = "source-bigquery")]
517 "bigquery" => {
518 let cfg = decode::<faucet_source_bigquery::BigQuerySourceConfig>(
519 "source", "bigquery", config,
520 )?;
521 Ok(Box::new(
522 faucet_source_bigquery::BigQuerySource::new(cfg).await?,
523 ))
524 }
525 #[cfg(feature = "source-snowflake")]
526 "snowflake" => {
527 let cfg = decode::<faucet_source_snowflake::SnowflakeSourceConfig>(
528 "source",
529 "snowflake",
530 config,
531 )?;
532 let mut s = faucet_source_snowflake::SnowflakeSource::new(cfg)?;
533 if let Some(name) = &auth_ref {
534 s = s.with_auth_provider(auth_catalog::resolve(auth, name)?);
535 }
536 Ok(Box::new(s))
537 }
538 #[cfg(feature = "source-mssql-cdc")]
539 "mssql-cdc" => {
540 let cfg = decode::<faucet_source_mssql_cdc::MssqlCdcSourceConfig>(
541 "source",
542 "mssql-cdc",
543 config,
544 )?;
545 Ok(Box::new(
546 faucet_source_mssql_cdc::MssqlCdcSource::new(cfg).await?,
547 ))
548 }
549 #[cfg(feature = "source-redshift")]
550 "redshift" => {
551 let cfg = decode::<faucet_source_redshift::RedshiftSourceConfig>(
552 "source", "redshift", config,
553 )?;
554 Ok(Box::new(faucet_source_redshift::RedshiftSource::new(cfg)?))
555 }
556 #[cfg(feature = "source-pubsub")]
557 "pubsub" => {
558 let cfg =
559 decode::<faucet_source_pubsub::PubsubSourceConfig>("source", "pubsub", config)?;
560 Ok(Box::new(
561 faucet_source_pubsub::PubsubSource::new(cfg).await?,
562 ))
563 }
564 #[cfg(feature = "source-clickhouse")]
565 "clickhouse" => {
566 let cfg = decode::<faucet_source_clickhouse::ClickHouseSourceConfig>(
567 "source",
568 "clickhouse",
569 config,
570 )?;
571 Ok(Box::new(faucet_source_clickhouse::ClickHouseSource::new(
572 cfg,
573 )?))
574 }
575 #[cfg(feature = "source-azure-blob")]
576 "azure-blob" => {
577 let cfg = decode::<faucet_source_azure_blob::AzureBlobSourceConfig>(
578 "source",
579 "azure-blob",
580 config,
581 )?;
582 Ok(Box::new(
583 faucet_source_azure_blob::AzureBlobSource::new(cfg).await?,
584 ))
585 }
586 other => Err(unknown(other, "source", source_kinds())),
587 }
588}
589
590pub async fn build_sink(kind: &str, config: Value, auth: &AuthCatalog) -> CliResult<Box<dyn Sink>> {
594 if let Some(entry) = global().sinks.get(kind) {
595 return (entry.factory)(config);
596 }
597 let auth_ref = auth_catalog::auth_ref(&config);
598 match kind {
599 #[cfg(feature = "sink-bigquery")]
600 "bigquery" => {
601 let cfg =
602 decode::<faucet_sink_bigquery::BigQuerySinkConfig>("sink", "bigquery", config)?;
603 Ok(Box::new(
604 faucet_sink_bigquery::BigQuerySink::new(cfg).await?,
605 ))
606 }
607 #[cfg(feature = "sink-iceberg")]
608 "iceberg" => {
609 let cfg = decode::<faucet_sink_iceberg::IcebergSinkConfig>("sink", "iceberg", config)?;
610 Ok(Box::new(faucet_sink_iceberg::IcebergSink::new(cfg).await?))
611 }
612 #[cfg(feature = "sink-delta")]
613 "delta" => {
614 let cfg = decode::<faucet_sink_delta::DeltaSinkConfig>("sink", "delta", config)?;
615 Ok(Box::new(faucet_sink_delta::DeltaSink::new(cfg).await?))
616 }
617 #[cfg(feature = "sink-postgres")]
618 "postgres" => {
619 let cfg =
620 decode::<faucet_sink_postgres::PostgresSinkConfig>("sink", "postgres", config)?;
621 Ok(Box::new(
622 faucet_sink_postgres::PostgresSink::new(cfg).await?,
623 ))
624 }
625 #[cfg(feature = "sink-jsonl")]
626 "jsonl" => {
627 let cfg = decode::<faucet_sink_jsonl::JsonlSinkConfig>("sink", "jsonl", config)?;
628 Ok(Box::new(faucet_sink_jsonl::JsonlSink::new(cfg)))
629 }
630 #[cfg(feature = "sink-snowflake")]
631 "snowflake" => {
632 let cfg =
633 decode::<faucet_sink_snowflake::SnowflakeSinkConfig>("sink", "snowflake", config)?;
634 let mut s = faucet_sink_snowflake::SnowflakeSink::new(cfg)?;
635 if let Some(name) = &auth_ref {
636 s = s.with_auth_provider(auth_catalog::resolve(auth, name)?);
637 }
638 Ok(Box::new(s))
639 }
640 #[cfg(feature = "sink-mysql")]
641 "mysql" => {
642 let cfg = decode::<faucet_sink_mysql::MysqlSinkConfig>("sink", "mysql", config)?;
643 Ok(Box::new(faucet_sink_mysql::MysqlSink::new(cfg).await?))
644 }
645 #[cfg(feature = "sink-mssql")]
646 "mssql" => {
647 let cfg = decode::<faucet_sink_mssql::MssqlSinkConfig>("sink", "mssql", config)?;
648 Ok(Box::new(faucet_sink_mssql::MssqlSink::new(cfg).await?))
649 }
650 #[cfg(feature = "sink-sqlite")]
651 "sqlite" => {
652 let cfg = decode::<faucet_sink_sqlite::SqliteSinkConfig>("sink", "sqlite", config)?;
653 Ok(Box::new(faucet_sink_sqlite::SqliteSink::new(cfg).await?))
654 }
655 #[cfg(feature = "sink-duckdb")]
656 "duckdb" => {
657 let cfg = decode::<faucet_sink_duckdb::DuckdbSinkConfig>("sink", "duckdb", config)?;
658 Ok(Box::new(faucet_sink_duckdb::DuckdbSink::new(cfg).await?))
659 }
660 #[cfg(feature = "sink-sqs")]
661 "sqs" => {
662 let cfg = decode::<faucet_sink_sqs::SqsSinkConfig>("sink", "sqs", config)?;
663 Ok(Box::new(faucet_sink_sqs::SqsSink::new(cfg).await?))
664 }
665 #[cfg(feature = "sink-nats")]
666 "nats" => {
667 let cfg = decode::<faucet_sink_nats::NatsSinkConfig>("sink", "nats", config)?;
668 Ok(Box::new(faucet_sink_nats::NatsSink::new(cfg).await?))
669 }
670 #[cfg(feature = "sink-sftp")]
671 "sftp" => {
672 let cfg = decode::<faucet_sink_sftp::SftpSinkConfig>("sink", "sftp", config)?;
673 Ok(Box::new(faucet_sink_sftp::SftpSink::new(cfg)?))
674 }
675 #[cfg(feature = "sink-s3")]
676 "s3" => {
677 let cfg = decode::<faucet_sink_s3::S3SinkConfig>("sink", "s3", config)?;
678 Ok(Box::new(faucet_sink_s3::S3Sink::new(cfg).await?))
679 }
680 #[cfg(feature = "sink-mongodb")]
681 "mongodb" => {
682 let cfg = decode::<faucet_sink_mongodb::MongoSinkConfig>("sink", "mongodb", config)?;
683 Ok(Box::new(faucet_sink_mongodb::MongoSink::new(cfg).await?))
684 }
685 #[cfg(feature = "sink-redis")]
686 "redis" => {
687 let cfg = decode::<faucet_sink_redis::RedisSinkConfig>("sink", "redis", config)?;
688 Ok(Box::new(faucet_sink_redis::RedisSink::new(cfg).await?))
689 }
690 #[cfg(feature = "sink-csv")]
691 "csv" => {
692 let cfg = decode::<faucet_sink_csv::CsvSinkConfig>("sink", "csv", config)?;
693 Ok(Box::new(faucet_sink_csv::CsvSink::new(cfg)))
694 }
695 #[cfg(feature = "sink-elasticsearch")]
696 "elasticsearch" => {
697 let cfg = decode::<faucet_sink_elasticsearch::ElasticsearchSinkConfig>(
698 "sink",
699 "elasticsearch",
700 config,
701 )?;
702 let mut s = faucet_sink_elasticsearch::ElasticsearchSink::new(cfg)?;
703 if let Some(name) = &auth_ref {
704 s = s.with_auth_provider(auth_catalog::resolve(auth, name)?);
705 }
706 Ok(Box::new(s))
707 }
708 #[cfg(feature = "sink-kafka")]
709 "kafka" => {
710 let cfg = decode::<faucet_sink_kafka::KafkaSinkConfig>("sink", "kafka", config)?;
711 Ok(Box::new(faucet_sink_kafka::KafkaSink::new(cfg).await?))
712 }
713 #[cfg(feature = "sink-kinesis")]
714 "kinesis" => {
715 let cfg = decode::<faucet_sink_kinesis::KinesisSinkConfig>("sink", "kinesis", config)?;
716 Ok(Box::new(faucet_sink_kinesis::KinesisSink::new(cfg).await?))
717 }
718 #[cfg(feature = "sink-spanner")]
719 "spanner" => {
720 let cfg = decode::<faucet_sink_spanner::SpannerSinkConfig>("sink", "spanner", config)?;
721 Ok(Box::new(faucet_sink_spanner::SpannerSink::new(cfg).await?))
722 }
723 #[cfg(feature = "sink-http")]
724 "http" => {
725 let cfg = decode::<faucet_sink_http::HttpSinkConfig>("sink", "http", config)?;
726 let mut s = faucet_sink_http::HttpSink::new(cfg);
727 if let Some(name) = &auth_ref {
728 s = s.with_auth_provider(auth_catalog::resolve(auth, name)?);
729 }
730 Ok(Box::new(s))
731 }
732 #[cfg(feature = "sink-stdout")]
733 "stdout" => {
734 let cfg = decode::<faucet_sink_stdout::StdoutSinkConfig>("sink", "stdout", config)?;
735 Ok(Box::new(faucet_sink_stdout::StdoutSink::new(cfg)))
736 }
737 #[cfg(feature = "sink-parquet")]
738 "parquet" => {
739 let cfg = decode::<faucet_sink_parquet::ParquetSinkConfig>("sink", "parquet", config)?;
740 Ok(Box::new(faucet_sink_parquet::ParquetSink::new(cfg).await?))
741 }
742 #[cfg(feature = "sink-gcs")]
743 "gcs" => {
744 let cfg = decode::<faucet_sink_gcs::GcsSinkConfig>("sink", "gcs", config)?;
745 Ok(Box::new(faucet_sink_gcs::GcsSink::new(cfg).await?))
746 }
747 #[cfg(feature = "sink-redshift")]
748 "redshift" => {
749 let cfg =
750 decode::<faucet_sink_redshift::RedshiftSinkConfig>("sink", "redshift", config)?;
751 Ok(Box::new(
752 faucet_sink_redshift::RedshiftSink::new(cfg).await?,
753 ))
754 }
755 #[cfg(feature = "sink-pubsub")]
756 "pubsub" => {
757 let cfg = decode::<faucet_sink_pubsub::PubsubSinkConfig>("sink", "pubsub", config)?;
758 Ok(Box::new(faucet_sink_pubsub::PubsubSink::new(cfg).await?))
759 }
760 #[cfg(feature = "sink-clickhouse")]
761 "clickhouse" => {
762 let cfg = decode::<faucet_sink_clickhouse::ClickHouseSinkConfig>(
763 "sink",
764 "clickhouse",
765 config,
766 )?;
767 Ok(Box::new(faucet_sink_clickhouse::ClickHouseSink::new(cfg)?))
768 }
769 #[cfg(feature = "sink-azure-blob")]
770 "azure-blob" => {
771 let cfg = decode::<faucet_sink_azure_blob::AzureBlobSinkConfig>(
772 "sink",
773 "azure-blob",
774 config,
775 )?;
776 Ok(Box::new(
777 faucet_sink_azure_blob::AzureBlobSink::new(cfg).await?,
778 ))
779 }
780 other => Err(unknown(other, "sink", sink_kinds())),
781 }
782}
783
784pub const EXACTLY_ONCE_SOURCE_KINDS: &[&str] = &[
791 "postgres-cdc",
792 "mysql-cdc",
793 "mssql-cdc",
794 "mongodb-cdc",
795 "kafka",
796];
797
798pub const IDEMPOTENT_SINK_KINDS: &[&str] = &[
803 "sqlite",
804 "postgres",
805 "mysql",
806 "mssql",
807 "iceberg",
808 "bigquery",
809 "kafka",
810 "snowflake",
811 "redis",
812 "mongodb",
813 "spanner",
814];
815
816pub const SCHEMA_EVOLUTION_SINK_KINDS: &[&str] = &[
821 "postgres",
822 "mysql",
823 "mssql",
824 "sqlite",
825 "bigquery",
826 "elasticsearch",
827 "spanner",
828 "iceberg",
829];
830
831pub const UPSERT_SINK_KINDS: &[&str] = &[
835 "postgres",
836 "sqlite",
837 "mysql",
838 "mssql",
839 "mongodb",
840 "elasticsearch",
841 "bigquery",
842 "spanner",
843];
844
845pub const CLEANUP_SINK_KINDS: &[&str] = &[
856 "postgres",
857 "sqlite",
858 "mysql",
859 "mssql",
860 "mongodb",
861 "elasticsearch",
862 "bigquery",
863 "spanner",
864];
865
866pub const DISCOVER_SOURCE_KINDS: &[&str] = &[
870 "postgres",
871 "mysql",
872 "mssql",
873 "sqlite",
874 "mongodb",
875 "elasticsearch",
876 "bigquery",
877 "snowflake",
878 "spanner",
879 "s3",
880 "gcs",
881];
882
883pub fn source_supports_discover(kind: &str) -> bool {
885 DISCOVER_SOURCE_KINDS.contains(&kind)
886}
887
888pub fn source_replay_guarantee(kind: &str) -> faucet_core::ReplayGuarantee {
893 if EXACTLY_ONCE_SOURCE_KINDS.contains(&kind) {
894 faucet_core::ReplayGuarantee::Deterministic
895 } else {
896 faucet_core::ReplayGuarantee::NonDeterministic
897 }
898}
899
900pub fn sink_guarantee(kind: &str) -> faucet_core::SinkGuarantee {
904 if IDEMPOTENT_SINK_KINDS.contains(&kind) {
905 faucet_core::SinkGuarantee::AtomicWatermark
906 } else if UPSERT_SINK_KINDS.contains(&kind) {
907 faucet_core::SinkGuarantee::KeyedUpsert
908 } else {
909 faucet_core::SinkGuarantee::AtLeastOnce
910 }
911}
912
913pub fn source_supports_exactly_once(kind: &str) -> bool {
915 source_replay_guarantee(kind) == faucet_core::ReplayGuarantee::Deterministic
916}
917
918pub fn sink_supports_idempotent_writes(kind: &str) -> bool {
920 sink_guarantee(kind) == faucet_core::SinkGuarantee::AtomicWatermark
921}
922
923pub fn sink_supports_schema_evolution(kind: &str) -> bool {
925 SCHEMA_EVOLUTION_SINK_KINDS.contains(&kind)
926}
927
928pub fn sink_supports_cleanup(kind: &str) -> bool {
930 CLEANUP_SINK_KINDS.contains(&kind)
931}
932
933pub fn sink_supported_write_modes(kind: &str) -> &'static [faucet_core::WriteMode] {
936 use faucet_core::WriteMode;
937 if UPSERT_SINK_KINDS.contains(&kind) {
938 &[WriteMode::Append, WriteMode::Upsert, WriteMode::Delete]
939 } else {
940 &[WriteMode::Append]
941 }
942}
943
944pub fn source_schema(kind: &str) -> CliResult<Value> {
946 if let Some(entry) = global().sources.get(kind) {
947 return Ok((entry.schema)());
948 }
949 match kind {
950 #[cfg(feature = "source-rest")]
951 "rest" => Ok(schema::<faucet_source_rest::RestStreamConfig>()),
952 #[cfg(feature = "source-graphql")]
953 "graphql" => Ok(schema::<faucet_source_graphql::GraphqlStreamConfig>()),
954 #[cfg(feature = "source-xml")]
955 "xml" => Ok(schema::<faucet_source_xml::XmlStreamConfig>()),
956 #[cfg(feature = "source-grpc")]
957 "grpc" => Ok(schema::<faucet_source_grpc::GrpcStreamConfig>()),
958 #[cfg(feature = "source-postgres")]
959 "postgres" => Ok(schema::<faucet_source_postgres::PostgresSourceConfig>()),
960 #[cfg(feature = "source-postgres-cdc")]
961 "postgres-cdc" => Ok(schema::<faucet_source_postgres_cdc::PostgresCdcSourceConfig>()),
962 #[cfg(feature = "source-mysql")]
963 "mysql" => Ok(schema::<faucet_source_mysql::MysqlSourceConfig>()),
964 #[cfg(feature = "source-mssql")]
965 "mssql" => Ok(schema::<faucet_source_mssql::MssqlSourceConfig>()),
966 #[cfg(feature = "source-sqlite")]
967 "sqlite" => Ok(schema::<faucet_source_sqlite::SqliteSourceConfig>()),
968 #[cfg(feature = "source-duckdb")]
969 "duckdb" => Ok(schema::<faucet_source_duckdb::DuckdbSourceConfig>()),
970 #[cfg(feature = "source-sqs")]
971 "sqs" => Ok(schema::<faucet_source_sqs::SqsSourceConfig>()),
972 #[cfg(feature = "source-nats")]
973 "nats" => Ok(schema::<faucet_source_nats::NatsSourceConfig>()),
974 #[cfg(feature = "source-sftp")]
975 "sftp" => Ok(schema::<faucet_source_sftp::SftpSourceConfig>()),
976 #[cfg(feature = "source-s3")]
977 "s3" => Ok(schema::<faucet_source_s3::S3SourceConfig>()),
978 #[cfg(feature = "source-mongodb")]
979 "mongodb" => Ok(schema::<faucet_source_mongodb::MongoSourceConfig>()),
980 #[cfg(feature = "source-mongodb-cdc")]
981 "mongodb-cdc" => Ok(schema::<faucet_source_mongodb_cdc::MongoCdcSourceConfig>()),
982 #[cfg(feature = "source-mysql-cdc")]
983 "mysql-cdc" => Ok(schema::<faucet_source_mysql_cdc::MysqlCdcSourceConfig>()),
984 #[cfg(feature = "source-redis")]
985 "redis" => Ok(schema::<faucet_source_redis::RedisSourceConfig>()),
986 #[cfg(feature = "source-webhook")]
987 "webhook" => Ok(schema::<faucet_source_webhook::WebhookSourceConfig>()),
988 #[cfg(feature = "source-websocket")]
989 "websocket" => Ok(schema::<faucet_source_websocket::WebsocketSourceConfig>()),
990 #[cfg(feature = "source-csv")]
991 "csv" => Ok(schema::<faucet_source_csv::CsvSourceConfig>()),
992 #[cfg(feature = "source-singer")]
993 "singer" => Ok(schema::<faucet_source_singer::SingerSourceConfig>()),
994 #[cfg(feature = "source-elasticsearch")]
995 "elasticsearch" => Ok(schema::<
996 faucet_source_elasticsearch::ElasticsearchSourceConfig,
997 >()),
998 #[cfg(feature = "source-kafka")]
999 "kafka" => Ok(schema::<faucet_source_kafka::KafkaSourceConfig>()),
1000 #[cfg(feature = "source-kinesis")]
1001 "kinesis" => Ok(schema::<faucet_source_kinesis::KinesisSourceConfig>()),
1002 #[cfg(feature = "source-spanner")]
1003 "spanner" => Ok(schema::<faucet_source_spanner::SpannerSourceConfig>()),
1004 #[cfg(feature = "source-parquet")]
1005 "parquet" => Ok(schema::<faucet_source_parquet::ParquetSourceConfig>()),
1006 #[cfg(feature = "source-delta")]
1007 "delta" => Ok(schema::<faucet_source_delta::DeltaSourceConfig>()),
1008 #[cfg(feature = "source-databricks")]
1009 "databricks" => Ok(schema::<faucet_source_databricks::DatabricksSourceConfig>()),
1010 #[cfg(feature = "source-gcs")]
1011 "gcs" => Ok(schema::<faucet_source_gcs::GcsSourceConfig>()),
1012 #[cfg(feature = "source-bigquery")]
1013 "bigquery" => Ok(schema::<faucet_source_bigquery::BigQuerySourceConfig>()),
1014 #[cfg(feature = "source-snowflake")]
1015 "snowflake" => Ok(schema::<faucet_source_snowflake::SnowflakeSourceConfig>()),
1016 #[cfg(feature = "source-mssql-cdc")]
1017 "mssql-cdc" => Ok(schema::<faucet_source_mssql_cdc::MssqlCdcSourceConfig>()),
1018 #[cfg(feature = "source-redshift")]
1019 "redshift" => Ok(schema::<faucet_source_redshift::RedshiftSourceConfig>()),
1020 #[cfg(feature = "source-pubsub")]
1021 "pubsub" => Ok(schema::<faucet_source_pubsub::PubsubSourceConfig>()),
1022 #[cfg(feature = "source-clickhouse")]
1023 "clickhouse" => Ok(schema::<faucet_source_clickhouse::ClickHouseSourceConfig>()),
1024 #[cfg(feature = "source-azure-blob")]
1025 "azure-blob" => Ok(schema::<faucet_source_azure_blob::AzureBlobSourceConfig>()),
1026 other => Err(unknown(other, "source", source_kinds())),
1027 }
1028}
1029
1030pub fn source_exists(kind: &str) -> bool {
1032 source_schema(kind).is_ok()
1033}
1034
1035pub fn sink_exists(kind: &str) -> bool {
1037 sink_schema(kind).is_ok()
1038}
1039
1040pub fn sink_schema(kind: &str) -> CliResult<Value> {
1042 if let Some(entry) = global().sinks.get(kind) {
1043 return Ok((entry.schema)());
1044 }
1045 match kind {
1046 #[cfg(feature = "sink-bigquery")]
1047 "bigquery" => Ok(schema::<faucet_sink_bigquery::BigQuerySinkConfig>()),
1048 #[cfg(feature = "sink-iceberg")]
1049 "iceberg" => Ok(schema::<faucet_sink_iceberg::IcebergSinkConfig>()),
1050 #[cfg(feature = "sink-delta")]
1051 "delta" => Ok(schema::<faucet_sink_delta::DeltaSinkConfig>()),
1052 #[cfg(feature = "sink-postgres")]
1053 "postgres" => Ok(schema::<faucet_sink_postgres::PostgresSinkConfig>()),
1054 #[cfg(feature = "sink-jsonl")]
1055 "jsonl" => Ok(schema::<faucet_sink_jsonl::JsonlSinkConfig>()),
1056 #[cfg(feature = "sink-snowflake")]
1057 "snowflake" => Ok(schema::<faucet_sink_snowflake::SnowflakeSinkConfig>()),
1058 #[cfg(feature = "sink-mysql")]
1059 "mysql" => Ok(schema::<faucet_sink_mysql::MysqlSinkConfig>()),
1060 #[cfg(feature = "sink-mssql")]
1061 "mssql" => Ok(schema::<faucet_sink_mssql::MssqlSinkConfig>()),
1062 #[cfg(feature = "sink-sqlite")]
1063 "sqlite" => Ok(schema::<faucet_sink_sqlite::SqliteSinkConfig>()),
1064 #[cfg(feature = "sink-duckdb")]
1065 "duckdb" => Ok(schema::<faucet_sink_duckdb::DuckdbSinkConfig>()),
1066 #[cfg(feature = "sink-sqs")]
1067 "sqs" => Ok(schema::<faucet_sink_sqs::SqsSinkConfig>()),
1068 #[cfg(feature = "sink-nats")]
1069 "nats" => Ok(schema::<faucet_sink_nats::NatsSinkConfig>()),
1070 #[cfg(feature = "sink-sftp")]
1071 "sftp" => Ok(schema::<faucet_sink_sftp::SftpSinkConfig>()),
1072 #[cfg(feature = "sink-s3")]
1073 "s3" => Ok(schema::<faucet_sink_s3::S3SinkConfig>()),
1074 #[cfg(feature = "sink-mongodb")]
1075 "mongodb" => Ok(schema::<faucet_sink_mongodb::MongoSinkConfig>()),
1076 #[cfg(feature = "sink-redis")]
1077 "redis" => Ok(schema::<faucet_sink_redis::RedisSinkConfig>()),
1078 #[cfg(feature = "sink-csv")]
1079 "csv" => Ok(schema::<faucet_sink_csv::CsvSinkConfig>()),
1080 #[cfg(feature = "sink-elasticsearch")]
1081 "elasticsearch" => Ok(schema::<faucet_sink_elasticsearch::ElasticsearchSinkConfig>()),
1082 #[cfg(feature = "sink-kafka")]
1083 "kafka" => Ok(schema::<faucet_sink_kafka::KafkaSinkConfig>()),
1084 #[cfg(feature = "sink-kinesis")]
1085 "kinesis" => Ok(schema::<faucet_sink_kinesis::KinesisSinkConfig>()),
1086 #[cfg(feature = "sink-spanner")]
1087 "spanner" => Ok(schema::<faucet_sink_spanner::SpannerSinkConfig>()),
1088 #[cfg(feature = "sink-http")]
1089 "http" => Ok(schema::<faucet_sink_http::HttpSinkConfig>()),
1090 #[cfg(feature = "sink-stdout")]
1091 "stdout" => Ok(schema::<faucet_sink_stdout::StdoutSinkConfig>()),
1092 #[cfg(feature = "sink-parquet")]
1093 "parquet" => Ok(schema::<faucet_sink_parquet::ParquetSinkConfig>()),
1094 #[cfg(feature = "sink-gcs")]
1095 "gcs" => Ok(schema::<faucet_sink_gcs::GcsSinkConfig>()),
1096 #[cfg(feature = "sink-redshift")]
1097 "redshift" => Ok(schema::<faucet_sink_redshift::RedshiftSinkConfig>()),
1098 #[cfg(feature = "sink-pubsub")]
1099 "pubsub" => Ok(schema::<faucet_sink_pubsub::PubsubSinkConfig>()),
1100 #[cfg(feature = "sink-clickhouse")]
1101 "clickhouse" => Ok(schema::<faucet_sink_clickhouse::ClickHouseSinkConfig>()),
1102 #[cfg(feature = "sink-azure-blob")]
1103 "azure-blob" => Ok(schema::<faucet_sink_azure_blob::AzureBlobSinkConfig>()),
1104 other => Err(unknown(other, "sink", sink_kinds())),
1105 }
1106}
1107
1108pub fn source_descriptions() -> Vec<(&'static str, &'static str)> {
1112 let mut v = builtin_source_descriptions();
1113 v.extend(global().custom_source_descriptions());
1114 v
1115}
1116
1117#[allow(clippy::vec_init_then_push)]
1119fn builtin_source_descriptions() -> Vec<(&'static str, &'static str)> {
1120 let mut v: Vec<(&'static str, &'static str)> = Vec::new();
1121 #[cfg(feature = "source-rest")]
1122 v.push(("rest", "REST API source with pagination, auth, transforms"));
1123 #[cfg(feature = "source-graphql")]
1124 v.push(("graphql", "GraphQL API source with cursor pagination"));
1125 #[cfg(feature = "source-xml")]
1126 v.push(("xml", "XML / SOAP API source with XML→JSON conversion"));
1127 #[cfg(feature = "source-grpc")]
1128 v.push(("grpc", "gRPC source with dynamic protobuf"));
1129 #[cfg(feature = "source-postgres")]
1130 v.push(("postgres", "PostgreSQL query source"));
1131 #[cfg(feature = "source-postgres-cdc")]
1132 v.push((
1133 "postgres-cdc",
1134 "PostgreSQL CDC source (logical replication)",
1135 ));
1136 #[cfg(feature = "source-mysql")]
1137 v.push(("mysql", "MySQL query source"));
1138 #[cfg(feature = "source-mssql")]
1139 v.push(("mssql", "Microsoft SQL Server query source"));
1140 #[cfg(feature = "source-sqlite")]
1141 v.push(("sqlite", "SQLite query source"));
1142 #[cfg(feature = "source-duckdb")]
1143 v.push((
1144 "duckdb",
1145 "DuckDB query source. Runs SQL against a DuckDB file or in-memory database and streams rows as JSON with bounded memory.",
1146 ));
1147 #[cfg(feature = "source-sqs")]
1148 v.push((
1149 "sqs",
1150 "AWS SQS source. Long-polls ReceiveMessage, deletes after the batch is emitted (at-least-once), with idle/max-messages termination.",
1151 ));
1152 #[cfg(feature = "source-nats")]
1153 v.push((
1154 "nats",
1155 "NATS source. Subscribes to a subject (or a JetStream durable consumer) and drains with idle/max-messages termination.",
1156 ));
1157 #[cfg(feature = "source-sftp")]
1158 v.push((
1159 "sftp",
1160 "SFTP source. Lists/globs a remote directory and streams JSONL / JSON-array / raw-text files over SSH.",
1161 ));
1162 #[cfg(feature = "source-s3")]
1163 v.push(("s3", "AWS S3 object source"));
1164 #[cfg(feature = "source-mongodb")]
1165 v.push(("mongodb", "MongoDB query source"));
1166 #[cfg(feature = "source-mongodb-cdc")]
1167 v.push(("mongodb-cdc", "MongoDB CDC source (Change Streams)"));
1168 #[cfg(feature = "source-mysql-cdc")]
1169 v.push(("mysql-cdc", "MySQL CDC source (binlog replication)"));
1170 #[cfg(feature = "source-mssql-cdc")]
1171 v.push((
1172 "mssql-cdc",
1173 "Microsoft SQL Server CDC source (change data capture, exactly-once capable)",
1174 ));
1175 #[cfg(feature = "source-redshift")]
1176 v.push((
1177 "redshift",
1178 "Amazon Redshift query source (PostgreSQL wire; streaming rows, incremental replication)",
1179 ));
1180 #[cfg(feature = "source-pubsub")]
1181 v.push((
1182 "pubsub",
1183 "Google Cloud Pub/Sub consumer — streaming pull with per-message records, attribute mapping, and ack at durable page boundaries (at-least-once)",
1184 ));
1185 #[cfg(feature = "source-clickhouse")]
1186 v.push((
1187 "clickhouse",
1188 "ClickHouse query source (HTTP interface, JSONEachRow streaming)",
1189 ));
1190 #[cfg(feature = "source-azure-blob")]
1191 v.push((
1192 "azure-blob",
1193 "Azure Blob Storage / ADLS Gen2 source — JSONL, JSON array, or raw text",
1194 ));
1195 #[cfg(feature = "source-redis")]
1196 v.push(("redis", "Redis (streams, lists, keys) source"));
1197 #[cfg(feature = "source-webhook")]
1198 v.push(("webhook", "Webhook HTTP receiver source"));
1199 #[cfg(feature = "source-websocket")]
1200 v.push((
1201 "websocket",
1202 "WebSocket streaming source — connects, subscribes, streams each message as a record",
1203 ));
1204 #[cfg(feature = "source-csv")]
1205 v.push(("csv", "CSV file source"));
1206 #[cfg(feature = "source-singer")]
1207 v.push((
1208 "singer",
1209 "Singer tap bridge (runs an external Singer tap; single-stream v0, Tier-2/experimental)",
1210 ));
1211 #[cfg(feature = "source-elasticsearch")]
1212 v.push(("elasticsearch", "Elasticsearch search / scroll source"));
1213 #[cfg(feature = "source-kafka")]
1214 v.push(("kafka", "Apache Kafka consumer (rdkafka). Subscribes to topics and drains messages with idle/max-messages termination."));
1215 #[cfg(feature = "source-kinesis")]
1216 v.push(("kinesis", "AWS Kinesis Data Streams consumer. Per-shard workers with resumable sequence-number checkpoints and idle/max-messages termination."));
1217 #[cfg(feature = "source-spanner")]
1218 v.push(("spanner", "Google Cloud Spanner query source. Streaming SQL reads with incremental replication bookmarks, stale reads, and PK-range sharding."));
1219 #[cfg(feature = "source-parquet")]
1220 v.push(("parquet", "Apache Parquet file source (local path, glob, or S3). Streams record batches via the Arrow async reader."));
1221 #[cfg(feature = "source-delta")]
1222 v.push(("delta", "Apache Delta Lake source (local FS or S3/Azure/GCS). Streams active data files with time travel and projection pushdown."));
1223 #[cfg(feature = "source-databricks")]
1224 v.push(("databricks", "Databricks SQL query source (Statement Execution API). Streams typed query results with chunk pagination and incremental replication."));
1225 #[cfg(feature = "source-gcs")]
1226 v.push((
1227 "gcs",
1228 "Google Cloud Storage source — JSONL, JSON array, or raw text",
1229 ));
1230 #[cfg(feature = "source-bigquery")]
1231 v.push((
1232 "bigquery",
1233 "Google BigQuery query source (jobs.query + jobs.getQueryResults)",
1234 ));
1235 #[cfg(feature = "source-snowflake")]
1236 v.push((
1237 "snowflake",
1238 "Snowflake query source (SQL REST API with partition paging)",
1239 ));
1240 v
1241}
1242
1243pub fn sink_descriptions() -> Vec<(&'static str, &'static str)> {
1247 let mut v = builtin_sink_descriptions();
1248 v.extend(global().custom_sink_descriptions());
1249 v
1250}
1251
1252#[allow(clippy::vec_init_then_push)]
1254fn builtin_sink_descriptions() -> Vec<(&'static str, &'static str)> {
1255 let mut v: Vec<(&'static str, &'static str)> = Vec::new();
1256 #[cfg(feature = "sink-bigquery")]
1257 v.push(("bigquery", "Google BigQuery streaming-insert sink"));
1258 #[cfg(feature = "sink-iceberg")]
1259 v.push((
1260 "iceberg",
1261 "Apache Iceberg sink (append, REST/Glue/SQL/HMS catalogs)",
1262 ));
1263 #[cfg(feature = "sink-postgres")]
1264 v.push(("postgres", "PostgreSQL sink (JSONB or auto-mapped columns)"));
1265 #[cfg(feature = "sink-jsonl")]
1266 v.push(("jsonl", "JSON Lines file sink"));
1267 #[cfg(feature = "sink-snowflake")]
1268 v.push(("snowflake", "Snowflake SQL REST API sink"));
1269 #[cfg(feature = "sink-mysql")]
1270 v.push(("mysql", "MySQL sink"));
1271 #[cfg(feature = "sink-mssql")]
1272 v.push((
1273 "mssql",
1274 "Microsoft SQL Server sink (auto-mapped columns or JSON column)",
1275 ));
1276 #[cfg(feature = "sink-sqlite")]
1277 v.push(("sqlite", "SQLite sink"));
1278 #[cfg(feature = "sink-duckdb")]
1279 v.push((
1280 "duckdb",
1281 "DuckDB sink. Transaction-wrapped multi-row INSERT (JSON column or auto-mapped columns).",
1282 ));
1283 #[cfg(feature = "sink-sqs")]
1284 v.push((
1285 "sqs",
1286 "AWS SQS sink. Batched SendMessageBatch (10-message chunks) with per-entry partial-failure retry; FIFO group/dedup support.",
1287 ));
1288 #[cfg(feature = "sink-nats")]
1289 v.push((
1290 "nats",
1291 "NATS sink. Publishes records to a subject (optionally subject-per-record) and flushes per batch.",
1292 ));
1293 #[cfg(feature = "sink-sftp")]
1294 v.push((
1295 "sftp",
1296 "SFTP sink. Writes JSONL files over SSH with atomic temp-then-rename uploads.",
1297 ));
1298 #[cfg(feature = "sink-s3")]
1299 v.push(("s3", "AWS S3 object sink"));
1300 #[cfg(feature = "sink-mongodb")]
1301 v.push(("mongodb", "MongoDB insert sink"));
1302 #[cfg(feature = "sink-redis")]
1303 v.push(("redis", "Redis (streams, lists, key-value) sink"));
1304 #[cfg(feature = "sink-csv")]
1305 v.push(("csv", "CSV file sink"));
1306 #[cfg(feature = "sink-elasticsearch")]
1307 v.push(("elasticsearch", "Elasticsearch bulk index sink"));
1308 #[cfg(feature = "sink-kafka")]
1309 v.push(("kafka", "Apache Kafka producer (rdkafka). FuturesUnordered batched sends with QueueFull retry; supports fixed or per-record topic routing."));
1310 #[cfg(feature = "sink-kinesis")]
1311 v.push(("kinesis", "AWS Kinesis Data Streams producer. Batched PutRecords with partition-key routing and partial-failure retry (DLQ-routable)."));
1312 #[cfg(feature = "sink-spanner")]
1313 v.push(("spanner", "Google Cloud Spanner sink. Batched mutations with upsert/delete write modes, exactly-once commit tokens, and schema evolution."));
1314 #[cfg(feature = "sink-http")]
1315 v.push(("http", "HTTP POST sink (individual or array batch)"));
1316 #[cfg(feature = "sink-stdout")]
1317 v.push(("stdout", "Stdout / stderr sink (JSON Lines, pretty, TSV)"));
1318 #[cfg(feature = "sink-parquet")]
1319 v.push(("parquet", "Apache Parquet file sink (local path or S3). Schema-inferred, configurable compression, row/byte rollover."));
1320 #[cfg(feature = "sink-delta")]
1321 v.push(("delta", "Apache Delta Lake sink (local FS or S3/Azure/GCS). Append-only, schema-inferred table creation, one commit per flush."));
1322 #[cfg(feature = "sink-gcs")]
1323 v.push(("gcs", "Google Cloud Storage sink — JSONL files"));
1324 #[cfg(feature = "sink-redshift")]
1325 v.push((
1326 "redshift",
1327 "Amazon Redshift sink (COPY-from-S3 or multi-row INSERT)",
1328 ));
1329 #[cfg(feature = "sink-pubsub")]
1330 v.push((
1331 "pubsub",
1332 "Google Cloud Pub/Sub producer — batched publish with optional ordering keys, bounded concurrency, and partial-failure retry (DLQ-routable)",
1333 ));
1334 #[cfg(feature = "sink-clickhouse")]
1335 v.push((
1336 "clickhouse",
1337 "ClickHouse sink (HTTP INSERT … FORMAT JSONEachRow; optional async inserts)",
1338 ));
1339 #[cfg(feature = "sink-azure-blob")]
1340 v.push((
1341 "azure-blob",
1342 "Azure Blob Storage / ADLS Gen2 sink — JSONL files",
1343 ));
1344 v
1345}
1346
1347pub fn source_kinds() -> Vec<&'static str> {
1349 source_descriptions().into_iter().map(|(k, _)| k).collect()
1350}
1351
1352pub fn sink_kinds() -> Vec<&'static str> {
1354 sink_descriptions().into_iter().map(|(k, _)| k).collect()
1355}
1356
1357fn decode<T: DeserializeOwned>(kind: &'static str, name: &str, config: Value) -> CliResult<T> {
1358 serde_json::from_value(config).map_err(|e| CliError::InvalidConnectorConfig {
1359 kind,
1360 name: name.to_owned(),
1361 message: scrub_config_error(&e.to_string()),
1362 })
1363}
1364
1365fn scrub_config_error(msg: &str) -> String {
1375 const MAX_CHARS: usize = 200;
1376 let mut out = String::with_capacity(msg.len());
1377 let mut in_quote = false;
1378 for c in msg.chars() {
1379 if c == '"' {
1380 if !in_quote {
1381 out.push_str("\"<redacted>\"");
1382 }
1383 in_quote = !in_quote;
1384 continue;
1385 }
1386 if !in_quote {
1387 out.push(c);
1388 }
1389 }
1390 if out.chars().count() > MAX_CHARS {
1391 let truncated: String = out.chars().take(MAX_CHARS).collect();
1392 return format!("{truncated}…");
1393 }
1394 out
1395}
1396
1397fn schema<T: faucet_core::JsonSchema>() -> Value {
1398 serde_json::to_value(faucet_core::schema_for!(T))
1399 .unwrap_or_else(|_| serde_json::json!({"type": "object"}))
1400}
1401
1402fn unknown(name: &str, kind: &'static str, available: Vec<&'static str>) -> CliError {
1403 CliError::UnknownConnector {
1404 kind,
1405 name: name.to_owned(),
1406 available: if available.is_empty() {
1407 "(none — rebuild faucet-cli with the relevant feature enabled)".to_owned()
1408 } else {
1409 available.join(", ")
1410 },
1411 }
1412}
1413
1414#[cfg(test)]
1415mod tests {
1416 use super::*;
1417
1418 #[derive(Clone)]
1421 struct DummySource;
1422 #[faucet_core::async_trait]
1423 impl Source for DummySource {
1424 async fn fetch_with_context(
1425 &self,
1426 _ctx: &std::collections::HashMap<String, Value>,
1427 ) -> Result<Vec<Value>, faucet_core::FaucetError> {
1428 Ok(vec![serde_json::json!({"ok": true})])
1429 }
1430 fn config_schema(&self) -> Value {
1431 serde_json::json!({"type": "object"})
1432 }
1433 }
1434
1435 #[test]
1436 fn register_source_rejects_builtin_collision() {
1437 let reg = PluginRegistry::with_builtins()
1440 .register_source("csv", |_| Ok(Box::new(DummySource) as Box<dyn Source>));
1441 let err = reg
1444 .install()
1445 .expect_err("built-in collision must be rejected");
1446 match err {
1447 CliError::Config(msg) => assert!(msg.contains("built-in source"), "{msg}"),
1448 other => panic!("expected Config error, got {other:?}"),
1449 }
1450 }
1451
1452 #[test]
1453 fn register_source_rejects_duplicate() {
1454 let reg = PluginRegistry::new()
1455 .register_source("dup", |_| Ok(Box::new(DummySource) as Box<dyn Source>))
1456 .register_source("dup", |_| Ok(Box::new(DummySource) as Box<dyn Source>));
1457 let err = reg
1458 .install()
1459 .expect_err("duplicate registration must be rejected");
1460 match err {
1461 CliError::Config(msg) => assert!(msg.contains("more than once"), "{msg}"),
1462 other => panic!("expected Config error, got {other:?}"),
1463 }
1464 }
1465
1466 #[test]
1467 fn register_sink_rejects_duplicate() {
1468 let reg = PluginRegistry::new()
1472 .register_sink("dupsink", |_| Err(CliError::Config("unused".into())))
1473 .register_sink("dupsink", |_| Err(CliError::Config("unused".into())));
1474 assert!(
1475 reg.errors.iter().any(|e| e.contains("more than once")),
1476 "{:?}",
1477 reg.errors
1478 );
1479 }
1480
1481 #[test]
1482 fn custom_descriptions_use_default_when_blank() {
1483 let reg = PluginRegistry::new()
1484 .register_source("acme", |_| Ok(Box::new(DummySource) as Box<dyn Source>));
1485 let descs = reg.custom_source_descriptions();
1486 assert_eq!(descs.len(), 1);
1487 assert_eq!(descs[0].0, "acme");
1488 assert_eq!(descs[0].1, "custom source connector");
1489 }
1490
1491 #[test]
1492 fn custom_descriptions_carry_explicit_summary() {
1493 let reg = PluginRegistry::new().register_source_with(
1494 "acme",
1495 |_| Ok(Box::new(DummySource) as Box<dyn Source>),
1496 || serde_json::json!({"type": "object", "title": "acme"}),
1497 "Acme widget source",
1498 );
1499 let descs = reg.custom_source_descriptions();
1500 assert_eq!(descs[0], ("acme", "Acme widget source"));
1501 assert_eq!(
1503 (reg.sources.get("acme").unwrap().schema)()["title"],
1504 serde_json::json!("acme")
1505 );
1506 }
1507
1508 #[test]
1509 fn capability_constants_match_their_predicates() {
1510 for &k in EXACTLY_ONCE_SOURCE_KINDS {
1516 assert!(
1517 source_supports_exactly_once(k),
1518 "{k} should be exactly-once"
1519 );
1520 }
1521 for &k in IDEMPOTENT_SINK_KINDS {
1522 assert!(
1523 sink_supports_idempotent_writes(k),
1524 "{k} should be idempotent"
1525 );
1526 }
1527 for &k in UPSERT_SINK_KINDS {
1528 use faucet_core::WriteMode;
1529 assert!(
1530 sink_supported_write_modes(k).contains(&WriteMode::Upsert),
1531 "{k} should support upsert"
1532 );
1533 }
1534 assert!(IDEMPOTENT_SINK_KINDS.contains(&"bigquery"));
1535 assert!(IDEMPOTENT_SINK_KINDS.contains(&"kafka"));
1536 assert!(UPSERT_SINK_KINDS.contains(&"bigquery"));
1537 }
1538
1539 #[cfg(feature = "source-rest")]
1540 #[test]
1541 fn rest_source_appears_in_listings() {
1542 assert!(source_kinds().contains(&"rest"));
1543 }
1544
1545 #[cfg(feature = "sink-jsonl")]
1546 #[test]
1547 fn jsonl_sink_appears_in_listings() {
1548 assert!(sink_kinds().contains(&"jsonl"));
1549 }
1550
1551 #[tokio::test]
1552 async fn unknown_source_kind_errors() {
1553 let err = build_source("nope", serde_json::json!({}), &AuthCatalog::new(), None)
1554 .await
1555 .err()
1556 .expect("should fail");
1557 match err {
1558 CliError::UnknownConnector { kind, name, .. } => {
1559 assert_eq!(kind, "source");
1560 assert_eq!(name, "nope");
1561 }
1562 other => panic!("expected UnknownConnector, got {other:?}"),
1563 }
1564 }
1565
1566 #[tokio::test]
1567 async fn unknown_sink_kind_errors() {
1568 let err = build_sink("nope", serde_json::json!({}), &AuthCatalog::new())
1569 .await
1570 .err()
1571 .expect("should fail");
1572 assert!(matches!(
1573 err,
1574 CliError::UnknownConnector { kind: "sink", .. }
1575 ));
1576 }
1577
1578 #[cfg(feature = "source-rest")]
1579 #[test]
1580 fn rest_schema_is_object() {
1581 let s = source_schema("rest").unwrap();
1582 assert!(s.is_object());
1583 }
1584
1585 #[cfg(feature = "sink-jsonl")]
1586 #[test]
1587 fn jsonl_schema_is_object() {
1588 let s = sink_schema("jsonl").unwrap();
1589 assert!(s.is_object());
1590 }
1591
1592 #[test]
1593 fn scrub_config_error_redacts_quoted_values() {
1594 let msg =
1597 r#"invalid type: string "sk-super-secret-123", expected a sequence at line 1 column 9"#;
1598 let scrubbed = scrub_config_error(msg);
1599 assert!(!scrubbed.contains("sk-super-secret-123"), "{scrubbed}");
1600 assert!(scrubbed.contains("<redacted>"), "{scrubbed}");
1601 assert!(scrubbed.contains("invalid type"), "{scrubbed}");
1603 assert!(scrubbed.contains("expected a sequence"), "{scrubbed}");
1604 }
1605
1606 #[test]
1607 fn scrub_config_error_truncates_long_messages() {
1608 let msg = "x".repeat(500);
1609 let scrubbed = scrub_config_error(&msg);
1610 assert!(
1611 scrubbed.chars().count() <= 201,
1612 "len {}",
1613 scrubbed.chars().count()
1614 );
1615 assert!(scrubbed.ends_with('…'));
1616 }
1617
1618 #[cfg(feature = "source-csv")]
1622 #[tokio::test]
1623 async fn build_source_csv_succeeds_without_io() {
1624 let src = build_source(
1625 "csv",
1626 serde_json::json!({ "path": "/tmp/does-not-need-to-exist.csv" }),
1627 &AuthCatalog::new(),
1628 None,
1629 )
1630 .await
1631 .expect("csv source should build without I/O");
1632 assert_eq!(src.connector_name(), "CsvSource");
1635 }
1636
1637 #[cfg(feature = "sink-jsonl")]
1640 #[tokio::test]
1641 async fn build_sink_jsonl_succeeds_without_io() {
1642 let sink = build_sink(
1643 "jsonl",
1644 serde_json::json!({ "path": "/tmp/does-not-need-to-exist.jsonl" }),
1645 &AuthCatalog::new(),
1646 )
1647 .await
1648 .expect("jsonl sink should build without I/O");
1649 assert_eq!(sink.connector_name(), "jsonl");
1650 }
1651
1652 #[cfg(feature = "sink-stdout")]
1654 #[tokio::test]
1655 async fn build_sink_stdout_succeeds_without_io() {
1656 let sink = build_sink("stdout", serde_json::json!({}), &AuthCatalog::new())
1657 .await
1658 .expect("stdout sink should build without I/O");
1659 assert_eq!(sink.connector_name(), "StdoutSink");
1662 }
1663
1664 #[cfg(all(feature = "source-delta", feature = "sink-delta"))]
1668 #[tokio::test]
1669 async fn delta_registry_round_trip() {
1670 let dir = tempfile::tempdir().unwrap();
1671 let uri = dir.path().join("reg_delta").to_string_lossy().into_owned();
1672
1673 assert!(source_schema("delta").is_ok());
1674 assert!(sink_schema("delta").is_ok());
1675 assert!(source_descriptions().iter().any(|(n, _)| *n == "delta"));
1676 assert!(sink_descriptions().iter().any(|(n, _)| *n == "delta"));
1677
1678 let sink = build_sink(
1679 "delta",
1680 serde_json::json!({ "table_uri": uri }),
1681 &AuthCatalog::new(),
1682 )
1683 .await
1684 .expect("delta sink builds");
1685 assert_eq!(sink.connector_name(), "delta");
1686 let n = sink
1687 .write_batch(&[serde_json::json!({"id": 1}), serde_json::json!({"id": 2})])
1688 .await
1689 .expect("write");
1690 assert_eq!(n, 2);
1691 sink.flush().await.expect("flush");
1692
1693 let source = build_source(
1694 "delta",
1695 serde_json::json!({ "table_uri": uri }),
1696 &AuthCatalog::new(),
1697 None,
1698 )
1699 .await
1700 .expect("delta source builds");
1701 assert_eq!(source.connector_name(), "delta");
1702 let rows = source
1703 .fetch_with_context(&std::collections::HashMap::new())
1704 .await
1705 .expect("read");
1706 assert_eq!(rows.len(), 2);
1707 }
1708
1709 #[cfg(feature = "source-databricks")]
1712 #[tokio::test]
1713 async fn databricks_registry_source_builds() {
1714 assert!(source_schema("databricks").is_ok());
1715 assert!(
1716 source_descriptions()
1717 .iter()
1718 .any(|(n, _)| *n == "databricks")
1719 );
1720 let cfg = serde_json::json!({
1721 "workspace_url": "https://x.cloud.databricks.com",
1722 "warehouse_id": "wh1",
1723 "sql": "SELECT 1",
1724 "auth": { "type": "pat", "config": { "token": "t" } }
1725 });
1726 let src = build_source("databricks", cfg, &AuthCatalog::new(), None)
1727 .await
1728 .expect("databricks source builds");
1729 assert_eq!(src.connector_name(), "databricks");
1730 }
1731
1732 #[cfg(feature = "source-csv")]
1735 #[tokio::test]
1736 async fn build_source_csv_invalid_config_errors() {
1737 let res = build_source(
1741 "csv",
1742 serde_json::json!({ "path": 42 }),
1743 &AuthCatalog::new(),
1744 None,
1745 )
1746 .await;
1747 match res {
1748 Err(CliError::InvalidConnectorConfig { kind, name, .. }) => {
1749 assert_eq!(kind, "source");
1750 assert_eq!(name, "csv");
1751 }
1752 Ok(_) => panic!("expected InvalidConnectorConfig, got Ok"),
1753 Err(other) => panic!("expected InvalidConnectorConfig, got {other:?}"),
1754 }
1755 }
1756
1757 #[cfg(feature = "source-csv")]
1760 #[test]
1761 fn source_schema_csv_exposes_path_property() {
1762 let schema = source_schema("csv").expect("csv schema");
1763 let props = schema
1764 .get("properties")
1765 .and_then(Value::as_object)
1766 .expect("schema should have a properties object");
1767 assert!(props.contains_key("path"), "schema props: {props:?}");
1768 }
1769
1770 #[cfg(feature = "sink-jsonl")]
1771 #[test]
1772 fn sink_schema_jsonl_exposes_path_property() {
1773 let schema = sink_schema("jsonl").expect("jsonl schema");
1774 let props = schema
1775 .get("properties")
1776 .and_then(Value::as_object)
1777 .expect("schema should have a properties object");
1778 assert!(props.contains_key("path"), "schema props: {props:?}");
1779 }
1780
1781 #[test]
1782 fn unknown_source_schema_errors_with_available_list() {
1783 let err = source_schema("definitely-not-a-source").expect_err("unknown source");
1784 match err {
1785 CliError::UnknownConnector {
1786 kind,
1787 name,
1788 available,
1789 } => {
1790 assert_eq!(kind, "source");
1791 assert_eq!(name, "definitely-not-a-source");
1792 assert!(!available.is_empty());
1794 }
1795 other => panic!("expected UnknownConnector, got {other:?}"),
1796 }
1797 }
1798
1799 #[test]
1800 fn unknown_sink_schema_errors() {
1801 let err = sink_schema("definitely-not-a-sink").expect_err("unknown sink");
1802 assert!(matches!(
1803 err,
1804 CliError::UnknownConnector { kind: "sink", .. }
1805 ));
1806 }
1807
1808 #[cfg(feature = "source-csv")]
1809 #[test]
1810 fn source_exists_is_true_for_known_and_false_for_unknown() {
1811 assert!(source_exists("csv"));
1812 assert!(!source_exists("definitely-not-a-source"));
1813 }
1814
1815 #[cfg(feature = "sink-jsonl")]
1816 #[test]
1817 fn sink_exists_is_true_for_known_and_false_for_unknown() {
1818 assert!(sink_exists("jsonl"));
1819 assert!(!sink_exists("definitely-not-a-sink"));
1820 }
1821
1822 #[test]
1825 fn source_descriptions_are_non_empty_and_consistent() {
1826 let descs = source_descriptions();
1827 assert!(!descs.is_empty());
1828 for (name, summary) in &descs {
1829 assert!(!name.is_empty(), "empty connector name");
1830 assert!(!summary.is_empty(), "empty summary for {name}");
1831 assert!(
1832 source_schema(name).is_ok(),
1833 "listed source `{name}` has no schema"
1834 );
1835 }
1836 }
1837
1838 #[test]
1839 fn sink_descriptions_are_non_empty_and_consistent() {
1840 let descs = sink_descriptions();
1841 assert!(!descs.is_empty());
1842 for (name, summary) in &descs {
1843 assert!(!name.is_empty(), "empty connector name");
1844 assert!(!summary.is_empty(), "empty summary for {name}");
1845 assert!(
1846 sink_schema(name).is_ok(),
1847 "listed sink `{name}` has no schema"
1848 );
1849 }
1850 }
1851
1852 #[cfg(all(feature = "source-csv", feature = "source-rest"))]
1855 #[test]
1856 fn source_kinds_contains_expected_builtins() {
1857 let kinds = source_kinds();
1858 assert!(kinds.contains(&"csv"));
1859 assert!(kinds.contains(&"rest"));
1860 }
1861
1862 #[cfg(all(feature = "sink-jsonl", feature = "sink-stdout"))]
1863 #[test]
1864 fn sink_kinds_contains_expected_builtins() {
1865 let kinds = sink_kinds();
1866 assert!(kinds.contains(&"jsonl"));
1867 assert!(kinds.contains(&"stdout"));
1868 }
1869
1870 #[cfg(feature = "source-rest")]
1874 #[tokio::test]
1875 async fn build_source_injects_referenced_auth_provider() {
1876 let mut specs = std::collections::HashMap::new();
1877 specs.insert(
1878 "tok".to_string(),
1879 serde_json::json!({"type": "static", "config": {"token": "abc"}}),
1880 );
1881 let catalog = auth_catalog::build_auth_catalog(Some(&specs)).expect("catalog");
1882
1883 let src = build_source("rest", rest_config_with_auth_ref("tok"), &catalog, None)
1884 .await
1885 .expect("rest source with a resolvable auth ref should build");
1886 assert_eq!(src.connector_name(), "rest");
1887 }
1888
1889 #[cfg(feature = "source-rest")]
1892 fn rest_config_with_auth_ref(name: &str) -> Value {
1893 let cfg = faucet_source_rest::RestStreamConfig::new("https://api.example.com", "/v1");
1894 let mut v = serde_json::to_value(cfg).expect("serialize rest config");
1895 v.as_object_mut()
1896 .unwrap()
1897 .insert("auth".to_string(), serde_json::json!({ "ref": name }));
1898 v
1899 }
1900
1901 #[cfg(feature = "source-rest")]
1904 #[tokio::test]
1905 async fn build_source_unknown_auth_ref_errors() {
1906 let res = build_source(
1907 "rest",
1908 rest_config_with_auth_ref("missing"),
1909 &AuthCatalog::new(),
1910 None,
1911 )
1912 .await;
1913 match res {
1914 Err(CliError::UnknownAuthProvider { name, .. }) => assert_eq!(name, "missing"),
1915 Ok(_) => panic!("expected UnknownAuthProvider, got Ok"),
1916 Err(other) => panic!("expected UnknownAuthProvider, got {other:?}"),
1917 }
1918 }
1919
1920 #[test]
1921 fn exactly_once_capability_allowlists() {
1922 assert!(source_supports_exactly_once("postgres-cdc"));
1923 assert!(source_supports_exactly_once("mysql-cdc"));
1924 assert!(source_supports_exactly_once("mongodb-cdc"));
1925 assert!(source_supports_exactly_once("kafka"));
1926 assert!(!source_supports_exactly_once("rest"));
1927
1928 assert!(sink_supports_idempotent_writes("postgres"));
1929 assert!(sink_supports_idempotent_writes("iceberg"));
1930 assert!(sink_supports_idempotent_writes("bigquery"));
1931 assert!(sink_supports_idempotent_writes("kafka"));
1932 assert!(sink_supports_idempotent_writes("snowflake"));
1933 assert!(sink_supports_idempotent_writes("redis"));
1934 assert!(sink_supports_idempotent_writes("mongodb"));
1935 assert!(!sink_supports_idempotent_writes("jsonl"));
1936 }
1937
1938 #[test]
1939 fn typed_delivery_capabilities_derive_from_kind_tables() {
1940 use faucet_core::{ReplayGuarantee, SinkGuarantee};
1941 assert_eq!(
1942 source_replay_guarantee("kafka"),
1943 ReplayGuarantee::Deterministic
1944 );
1945 assert_eq!(
1946 source_replay_guarantee("rest"),
1947 ReplayGuarantee::NonDeterministic
1948 );
1949 assert_eq!(sink_guarantee("postgres"), SinkGuarantee::AtomicWatermark);
1950 assert_eq!(sink_guarantee("elasticsearch"), SinkGuarantee::KeyedUpsert);
1952 assert_eq!(sink_guarantee("jsonl"), SinkGuarantee::AtLeastOnce);
1953 }
1954
1955 #[test]
1956 fn sink_supported_write_modes_allowlist() {
1957 use faucet_core::WriteMode;
1958 assert!(sink_supported_write_modes("postgres").contains(&WriteMode::Upsert));
1959 assert!(sink_supported_write_modes("elasticsearch").contains(&WriteMode::Delete));
1960 assert!(sink_supported_write_modes("bigquery").contains(&WriteMode::Upsert));
1961 assert_eq!(sink_supported_write_modes("jsonl"), &[WriteMode::Append]);
1963 assert_eq!(sink_supported_write_modes("kafka"), &[WriteMode::Append]);
1964 }
1965
1966 #[test]
1967 fn sink_supports_schema_evolution_allowlist() {
1968 assert!(sink_supports_schema_evolution("postgres"));
1969 assert!(sink_supports_schema_evolution("mysql"));
1970 assert!(sink_supports_schema_evolution("mssql"));
1971 assert!(sink_supports_schema_evolution("sqlite"));
1972 assert!(sink_supports_schema_evolution("bigquery"));
1973 assert!(sink_supports_schema_evolution("elasticsearch"));
1974 assert!(sink_supports_schema_evolution("iceberg"));
1975 assert!(!sink_supports_schema_evolution("jsonl"));
1976 assert!(!sink_supports_schema_evolution("kafka"));
1977 }
1978}