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 DISCOVER_SOURCE_KINDS: &[&str] = &[
849 "postgres",
850 "mysql",
851 "mssql",
852 "sqlite",
853 "mongodb",
854 "elasticsearch",
855 "bigquery",
856 "snowflake",
857 "spanner",
858 "s3",
859 "gcs",
860];
861
862pub fn source_supports_discover(kind: &str) -> bool {
864 DISCOVER_SOURCE_KINDS.contains(&kind)
865}
866
867pub fn source_replay_guarantee(kind: &str) -> faucet_core::ReplayGuarantee {
872 if EXACTLY_ONCE_SOURCE_KINDS.contains(&kind) {
873 faucet_core::ReplayGuarantee::Deterministic
874 } else {
875 faucet_core::ReplayGuarantee::NonDeterministic
876 }
877}
878
879pub fn sink_guarantee(kind: &str) -> faucet_core::SinkGuarantee {
883 if IDEMPOTENT_SINK_KINDS.contains(&kind) {
884 faucet_core::SinkGuarantee::AtomicWatermark
885 } else if UPSERT_SINK_KINDS.contains(&kind) {
886 faucet_core::SinkGuarantee::KeyedUpsert
887 } else {
888 faucet_core::SinkGuarantee::AtLeastOnce
889 }
890}
891
892pub fn source_supports_exactly_once(kind: &str) -> bool {
894 source_replay_guarantee(kind) == faucet_core::ReplayGuarantee::Deterministic
895}
896
897pub fn sink_supports_idempotent_writes(kind: &str) -> bool {
899 sink_guarantee(kind) == faucet_core::SinkGuarantee::AtomicWatermark
900}
901
902pub fn sink_supports_schema_evolution(kind: &str) -> bool {
904 SCHEMA_EVOLUTION_SINK_KINDS.contains(&kind)
905}
906
907pub fn sink_supported_write_modes(kind: &str) -> &'static [faucet_core::WriteMode] {
910 use faucet_core::WriteMode;
911 if UPSERT_SINK_KINDS.contains(&kind) {
912 &[WriteMode::Append, WriteMode::Upsert, WriteMode::Delete]
913 } else {
914 &[WriteMode::Append]
915 }
916}
917
918pub fn source_schema(kind: &str) -> CliResult<Value> {
920 if let Some(entry) = global().sources.get(kind) {
921 return Ok((entry.schema)());
922 }
923 match kind {
924 #[cfg(feature = "source-rest")]
925 "rest" => Ok(schema::<faucet_source_rest::RestStreamConfig>()),
926 #[cfg(feature = "source-graphql")]
927 "graphql" => Ok(schema::<faucet_source_graphql::GraphqlStreamConfig>()),
928 #[cfg(feature = "source-xml")]
929 "xml" => Ok(schema::<faucet_source_xml::XmlStreamConfig>()),
930 #[cfg(feature = "source-grpc")]
931 "grpc" => Ok(schema::<faucet_source_grpc::GrpcStreamConfig>()),
932 #[cfg(feature = "source-postgres")]
933 "postgres" => Ok(schema::<faucet_source_postgres::PostgresSourceConfig>()),
934 #[cfg(feature = "source-postgres-cdc")]
935 "postgres-cdc" => Ok(schema::<faucet_source_postgres_cdc::PostgresCdcSourceConfig>()),
936 #[cfg(feature = "source-mysql")]
937 "mysql" => Ok(schema::<faucet_source_mysql::MysqlSourceConfig>()),
938 #[cfg(feature = "source-mssql")]
939 "mssql" => Ok(schema::<faucet_source_mssql::MssqlSourceConfig>()),
940 #[cfg(feature = "source-sqlite")]
941 "sqlite" => Ok(schema::<faucet_source_sqlite::SqliteSourceConfig>()),
942 #[cfg(feature = "source-duckdb")]
943 "duckdb" => Ok(schema::<faucet_source_duckdb::DuckdbSourceConfig>()),
944 #[cfg(feature = "source-sqs")]
945 "sqs" => Ok(schema::<faucet_source_sqs::SqsSourceConfig>()),
946 #[cfg(feature = "source-nats")]
947 "nats" => Ok(schema::<faucet_source_nats::NatsSourceConfig>()),
948 #[cfg(feature = "source-sftp")]
949 "sftp" => Ok(schema::<faucet_source_sftp::SftpSourceConfig>()),
950 #[cfg(feature = "source-s3")]
951 "s3" => Ok(schema::<faucet_source_s3::S3SourceConfig>()),
952 #[cfg(feature = "source-mongodb")]
953 "mongodb" => Ok(schema::<faucet_source_mongodb::MongoSourceConfig>()),
954 #[cfg(feature = "source-mongodb-cdc")]
955 "mongodb-cdc" => Ok(schema::<faucet_source_mongodb_cdc::MongoCdcSourceConfig>()),
956 #[cfg(feature = "source-mysql-cdc")]
957 "mysql-cdc" => Ok(schema::<faucet_source_mysql_cdc::MysqlCdcSourceConfig>()),
958 #[cfg(feature = "source-redis")]
959 "redis" => Ok(schema::<faucet_source_redis::RedisSourceConfig>()),
960 #[cfg(feature = "source-webhook")]
961 "webhook" => Ok(schema::<faucet_source_webhook::WebhookSourceConfig>()),
962 #[cfg(feature = "source-websocket")]
963 "websocket" => Ok(schema::<faucet_source_websocket::WebsocketSourceConfig>()),
964 #[cfg(feature = "source-csv")]
965 "csv" => Ok(schema::<faucet_source_csv::CsvSourceConfig>()),
966 #[cfg(feature = "source-singer")]
967 "singer" => Ok(schema::<faucet_source_singer::SingerSourceConfig>()),
968 #[cfg(feature = "source-elasticsearch")]
969 "elasticsearch" => Ok(schema::<
970 faucet_source_elasticsearch::ElasticsearchSourceConfig,
971 >()),
972 #[cfg(feature = "source-kafka")]
973 "kafka" => Ok(schema::<faucet_source_kafka::KafkaSourceConfig>()),
974 #[cfg(feature = "source-kinesis")]
975 "kinesis" => Ok(schema::<faucet_source_kinesis::KinesisSourceConfig>()),
976 #[cfg(feature = "source-spanner")]
977 "spanner" => Ok(schema::<faucet_source_spanner::SpannerSourceConfig>()),
978 #[cfg(feature = "source-parquet")]
979 "parquet" => Ok(schema::<faucet_source_parquet::ParquetSourceConfig>()),
980 #[cfg(feature = "source-delta")]
981 "delta" => Ok(schema::<faucet_source_delta::DeltaSourceConfig>()),
982 #[cfg(feature = "source-databricks")]
983 "databricks" => Ok(schema::<faucet_source_databricks::DatabricksSourceConfig>()),
984 #[cfg(feature = "source-gcs")]
985 "gcs" => Ok(schema::<faucet_source_gcs::GcsSourceConfig>()),
986 #[cfg(feature = "source-bigquery")]
987 "bigquery" => Ok(schema::<faucet_source_bigquery::BigQuerySourceConfig>()),
988 #[cfg(feature = "source-snowflake")]
989 "snowflake" => Ok(schema::<faucet_source_snowflake::SnowflakeSourceConfig>()),
990 #[cfg(feature = "source-mssql-cdc")]
991 "mssql-cdc" => Ok(schema::<faucet_source_mssql_cdc::MssqlCdcSourceConfig>()),
992 #[cfg(feature = "source-redshift")]
993 "redshift" => Ok(schema::<faucet_source_redshift::RedshiftSourceConfig>()),
994 #[cfg(feature = "source-pubsub")]
995 "pubsub" => Ok(schema::<faucet_source_pubsub::PubsubSourceConfig>()),
996 #[cfg(feature = "source-clickhouse")]
997 "clickhouse" => Ok(schema::<faucet_source_clickhouse::ClickHouseSourceConfig>()),
998 #[cfg(feature = "source-azure-blob")]
999 "azure-blob" => Ok(schema::<faucet_source_azure_blob::AzureBlobSourceConfig>()),
1000 other => Err(unknown(other, "source", source_kinds())),
1001 }
1002}
1003
1004pub fn source_exists(kind: &str) -> bool {
1006 source_schema(kind).is_ok()
1007}
1008
1009pub fn sink_exists(kind: &str) -> bool {
1011 sink_schema(kind).is_ok()
1012}
1013
1014pub fn sink_schema(kind: &str) -> CliResult<Value> {
1016 if let Some(entry) = global().sinks.get(kind) {
1017 return Ok((entry.schema)());
1018 }
1019 match kind {
1020 #[cfg(feature = "sink-bigquery")]
1021 "bigquery" => Ok(schema::<faucet_sink_bigquery::BigQuerySinkConfig>()),
1022 #[cfg(feature = "sink-iceberg")]
1023 "iceberg" => Ok(schema::<faucet_sink_iceberg::IcebergSinkConfig>()),
1024 #[cfg(feature = "sink-delta")]
1025 "delta" => Ok(schema::<faucet_sink_delta::DeltaSinkConfig>()),
1026 #[cfg(feature = "sink-postgres")]
1027 "postgres" => Ok(schema::<faucet_sink_postgres::PostgresSinkConfig>()),
1028 #[cfg(feature = "sink-jsonl")]
1029 "jsonl" => Ok(schema::<faucet_sink_jsonl::JsonlSinkConfig>()),
1030 #[cfg(feature = "sink-snowflake")]
1031 "snowflake" => Ok(schema::<faucet_sink_snowflake::SnowflakeSinkConfig>()),
1032 #[cfg(feature = "sink-mysql")]
1033 "mysql" => Ok(schema::<faucet_sink_mysql::MysqlSinkConfig>()),
1034 #[cfg(feature = "sink-mssql")]
1035 "mssql" => Ok(schema::<faucet_sink_mssql::MssqlSinkConfig>()),
1036 #[cfg(feature = "sink-sqlite")]
1037 "sqlite" => Ok(schema::<faucet_sink_sqlite::SqliteSinkConfig>()),
1038 #[cfg(feature = "sink-duckdb")]
1039 "duckdb" => Ok(schema::<faucet_sink_duckdb::DuckdbSinkConfig>()),
1040 #[cfg(feature = "sink-sqs")]
1041 "sqs" => Ok(schema::<faucet_sink_sqs::SqsSinkConfig>()),
1042 #[cfg(feature = "sink-nats")]
1043 "nats" => Ok(schema::<faucet_sink_nats::NatsSinkConfig>()),
1044 #[cfg(feature = "sink-sftp")]
1045 "sftp" => Ok(schema::<faucet_sink_sftp::SftpSinkConfig>()),
1046 #[cfg(feature = "sink-s3")]
1047 "s3" => Ok(schema::<faucet_sink_s3::S3SinkConfig>()),
1048 #[cfg(feature = "sink-mongodb")]
1049 "mongodb" => Ok(schema::<faucet_sink_mongodb::MongoSinkConfig>()),
1050 #[cfg(feature = "sink-redis")]
1051 "redis" => Ok(schema::<faucet_sink_redis::RedisSinkConfig>()),
1052 #[cfg(feature = "sink-csv")]
1053 "csv" => Ok(schema::<faucet_sink_csv::CsvSinkConfig>()),
1054 #[cfg(feature = "sink-elasticsearch")]
1055 "elasticsearch" => Ok(schema::<faucet_sink_elasticsearch::ElasticsearchSinkConfig>()),
1056 #[cfg(feature = "sink-kafka")]
1057 "kafka" => Ok(schema::<faucet_sink_kafka::KafkaSinkConfig>()),
1058 #[cfg(feature = "sink-kinesis")]
1059 "kinesis" => Ok(schema::<faucet_sink_kinesis::KinesisSinkConfig>()),
1060 #[cfg(feature = "sink-spanner")]
1061 "spanner" => Ok(schema::<faucet_sink_spanner::SpannerSinkConfig>()),
1062 #[cfg(feature = "sink-http")]
1063 "http" => Ok(schema::<faucet_sink_http::HttpSinkConfig>()),
1064 #[cfg(feature = "sink-stdout")]
1065 "stdout" => Ok(schema::<faucet_sink_stdout::StdoutSinkConfig>()),
1066 #[cfg(feature = "sink-parquet")]
1067 "parquet" => Ok(schema::<faucet_sink_parquet::ParquetSinkConfig>()),
1068 #[cfg(feature = "sink-gcs")]
1069 "gcs" => Ok(schema::<faucet_sink_gcs::GcsSinkConfig>()),
1070 #[cfg(feature = "sink-redshift")]
1071 "redshift" => Ok(schema::<faucet_sink_redshift::RedshiftSinkConfig>()),
1072 #[cfg(feature = "sink-pubsub")]
1073 "pubsub" => Ok(schema::<faucet_sink_pubsub::PubsubSinkConfig>()),
1074 #[cfg(feature = "sink-clickhouse")]
1075 "clickhouse" => Ok(schema::<faucet_sink_clickhouse::ClickHouseSinkConfig>()),
1076 #[cfg(feature = "sink-azure-blob")]
1077 "azure-blob" => Ok(schema::<faucet_sink_azure_blob::AzureBlobSinkConfig>()),
1078 other => Err(unknown(other, "sink", sink_kinds())),
1079 }
1080}
1081
1082pub fn source_descriptions() -> Vec<(&'static str, &'static str)> {
1086 let mut v = builtin_source_descriptions();
1087 v.extend(global().custom_source_descriptions());
1088 v
1089}
1090
1091#[allow(clippy::vec_init_then_push)]
1093fn builtin_source_descriptions() -> Vec<(&'static str, &'static str)> {
1094 let mut v: Vec<(&'static str, &'static str)> = Vec::new();
1095 #[cfg(feature = "source-rest")]
1096 v.push(("rest", "REST API source with pagination, auth, transforms"));
1097 #[cfg(feature = "source-graphql")]
1098 v.push(("graphql", "GraphQL API source with cursor pagination"));
1099 #[cfg(feature = "source-xml")]
1100 v.push(("xml", "XML / SOAP API source with XML→JSON conversion"));
1101 #[cfg(feature = "source-grpc")]
1102 v.push(("grpc", "gRPC source with dynamic protobuf"));
1103 #[cfg(feature = "source-postgres")]
1104 v.push(("postgres", "PostgreSQL query source"));
1105 #[cfg(feature = "source-postgres-cdc")]
1106 v.push((
1107 "postgres-cdc",
1108 "PostgreSQL CDC source (logical replication)",
1109 ));
1110 #[cfg(feature = "source-mysql")]
1111 v.push(("mysql", "MySQL query source"));
1112 #[cfg(feature = "source-mssql")]
1113 v.push(("mssql", "Microsoft SQL Server query source"));
1114 #[cfg(feature = "source-sqlite")]
1115 v.push(("sqlite", "SQLite query source"));
1116 #[cfg(feature = "source-duckdb")]
1117 v.push((
1118 "duckdb",
1119 "DuckDB query source. Runs SQL against a DuckDB file or in-memory database and streams rows as JSON with bounded memory.",
1120 ));
1121 #[cfg(feature = "source-sqs")]
1122 v.push((
1123 "sqs",
1124 "AWS SQS source. Long-polls ReceiveMessage, deletes after the batch is emitted (at-least-once), with idle/max-messages termination.",
1125 ));
1126 #[cfg(feature = "source-nats")]
1127 v.push((
1128 "nats",
1129 "NATS source. Subscribes to a subject (or a JetStream durable consumer) and drains with idle/max-messages termination.",
1130 ));
1131 #[cfg(feature = "source-sftp")]
1132 v.push((
1133 "sftp",
1134 "SFTP source. Lists/globs a remote directory and streams JSONL / JSON-array / raw-text files over SSH.",
1135 ));
1136 #[cfg(feature = "source-s3")]
1137 v.push(("s3", "AWS S3 object source"));
1138 #[cfg(feature = "source-mongodb")]
1139 v.push(("mongodb", "MongoDB query source"));
1140 #[cfg(feature = "source-mongodb-cdc")]
1141 v.push(("mongodb-cdc", "MongoDB CDC source (Change Streams)"));
1142 #[cfg(feature = "source-mysql-cdc")]
1143 v.push(("mysql-cdc", "MySQL CDC source (binlog replication)"));
1144 #[cfg(feature = "source-mssql-cdc")]
1145 v.push((
1146 "mssql-cdc",
1147 "Microsoft SQL Server CDC source (change data capture, exactly-once capable)",
1148 ));
1149 #[cfg(feature = "source-redshift")]
1150 v.push((
1151 "redshift",
1152 "Amazon Redshift query source (PostgreSQL wire; streaming rows, incremental replication)",
1153 ));
1154 #[cfg(feature = "source-pubsub")]
1155 v.push((
1156 "pubsub",
1157 "Google Cloud Pub/Sub consumer — streaming pull with per-message records, attribute mapping, and ack at durable page boundaries (at-least-once)",
1158 ));
1159 #[cfg(feature = "source-clickhouse")]
1160 v.push((
1161 "clickhouse",
1162 "ClickHouse query source (HTTP interface, JSONEachRow streaming)",
1163 ));
1164 #[cfg(feature = "source-azure-blob")]
1165 v.push((
1166 "azure-blob",
1167 "Azure Blob Storage / ADLS Gen2 source — JSONL, JSON array, or raw text",
1168 ));
1169 #[cfg(feature = "source-redis")]
1170 v.push(("redis", "Redis (streams, lists, keys) source"));
1171 #[cfg(feature = "source-webhook")]
1172 v.push(("webhook", "Webhook HTTP receiver source"));
1173 #[cfg(feature = "source-websocket")]
1174 v.push((
1175 "websocket",
1176 "WebSocket streaming source — connects, subscribes, streams each message as a record",
1177 ));
1178 #[cfg(feature = "source-csv")]
1179 v.push(("csv", "CSV file source"));
1180 #[cfg(feature = "source-singer")]
1181 v.push((
1182 "singer",
1183 "Singer tap bridge (runs an external Singer tap; single-stream v0, Tier-2/experimental)",
1184 ));
1185 #[cfg(feature = "source-elasticsearch")]
1186 v.push(("elasticsearch", "Elasticsearch search / scroll source"));
1187 #[cfg(feature = "source-kafka")]
1188 v.push(("kafka", "Apache Kafka consumer (rdkafka). Subscribes to topics and drains messages with idle/max-messages termination."));
1189 #[cfg(feature = "source-kinesis")]
1190 v.push(("kinesis", "AWS Kinesis Data Streams consumer. Per-shard workers with resumable sequence-number checkpoints and idle/max-messages termination."));
1191 #[cfg(feature = "source-spanner")]
1192 v.push(("spanner", "Google Cloud Spanner query source. Streaming SQL reads with incremental replication bookmarks, stale reads, and PK-range sharding."));
1193 #[cfg(feature = "source-parquet")]
1194 v.push(("parquet", "Apache Parquet file source (local path, glob, or S3). Streams record batches via the Arrow async reader."));
1195 #[cfg(feature = "source-delta")]
1196 v.push(("delta", "Apache Delta Lake source (local FS or S3/Azure/GCS). Streams active data files with time travel and projection pushdown."));
1197 #[cfg(feature = "source-databricks")]
1198 v.push(("databricks", "Databricks SQL query source (Statement Execution API). Streams typed query results with chunk pagination and incremental replication."));
1199 #[cfg(feature = "source-gcs")]
1200 v.push((
1201 "gcs",
1202 "Google Cloud Storage source — JSONL, JSON array, or raw text",
1203 ));
1204 #[cfg(feature = "source-bigquery")]
1205 v.push((
1206 "bigquery",
1207 "Google BigQuery query source (jobs.query + jobs.getQueryResults)",
1208 ));
1209 #[cfg(feature = "source-snowflake")]
1210 v.push((
1211 "snowflake",
1212 "Snowflake query source (SQL REST API with partition paging)",
1213 ));
1214 v
1215}
1216
1217pub fn sink_descriptions() -> Vec<(&'static str, &'static str)> {
1221 let mut v = builtin_sink_descriptions();
1222 v.extend(global().custom_sink_descriptions());
1223 v
1224}
1225
1226#[allow(clippy::vec_init_then_push)]
1228fn builtin_sink_descriptions() -> Vec<(&'static str, &'static str)> {
1229 let mut v: Vec<(&'static str, &'static str)> = Vec::new();
1230 #[cfg(feature = "sink-bigquery")]
1231 v.push(("bigquery", "Google BigQuery streaming-insert sink"));
1232 #[cfg(feature = "sink-iceberg")]
1233 v.push((
1234 "iceberg",
1235 "Apache Iceberg sink (append, REST/Glue/SQL/HMS catalogs)",
1236 ));
1237 #[cfg(feature = "sink-postgres")]
1238 v.push(("postgres", "PostgreSQL sink (JSONB or auto-mapped columns)"));
1239 #[cfg(feature = "sink-jsonl")]
1240 v.push(("jsonl", "JSON Lines file sink"));
1241 #[cfg(feature = "sink-snowflake")]
1242 v.push(("snowflake", "Snowflake SQL REST API sink"));
1243 #[cfg(feature = "sink-mysql")]
1244 v.push(("mysql", "MySQL sink"));
1245 #[cfg(feature = "sink-mssql")]
1246 v.push((
1247 "mssql",
1248 "Microsoft SQL Server sink (auto-mapped columns or JSON column)",
1249 ));
1250 #[cfg(feature = "sink-sqlite")]
1251 v.push(("sqlite", "SQLite sink"));
1252 #[cfg(feature = "sink-duckdb")]
1253 v.push((
1254 "duckdb",
1255 "DuckDB sink. Transaction-wrapped multi-row INSERT (JSON column or auto-mapped columns).",
1256 ));
1257 #[cfg(feature = "sink-sqs")]
1258 v.push((
1259 "sqs",
1260 "AWS SQS sink. Batched SendMessageBatch (10-message chunks) with per-entry partial-failure retry; FIFO group/dedup support.",
1261 ));
1262 #[cfg(feature = "sink-nats")]
1263 v.push((
1264 "nats",
1265 "NATS sink. Publishes records to a subject (optionally subject-per-record) and flushes per batch.",
1266 ));
1267 #[cfg(feature = "sink-sftp")]
1268 v.push((
1269 "sftp",
1270 "SFTP sink. Writes JSONL files over SSH with atomic temp-then-rename uploads.",
1271 ));
1272 #[cfg(feature = "sink-s3")]
1273 v.push(("s3", "AWS S3 object sink"));
1274 #[cfg(feature = "sink-mongodb")]
1275 v.push(("mongodb", "MongoDB insert sink"));
1276 #[cfg(feature = "sink-redis")]
1277 v.push(("redis", "Redis (streams, lists, key-value) sink"));
1278 #[cfg(feature = "sink-csv")]
1279 v.push(("csv", "CSV file sink"));
1280 #[cfg(feature = "sink-elasticsearch")]
1281 v.push(("elasticsearch", "Elasticsearch bulk index sink"));
1282 #[cfg(feature = "sink-kafka")]
1283 v.push(("kafka", "Apache Kafka producer (rdkafka). FuturesUnordered batched sends with QueueFull retry; supports fixed or per-record topic routing."));
1284 #[cfg(feature = "sink-kinesis")]
1285 v.push(("kinesis", "AWS Kinesis Data Streams producer. Batched PutRecords with partition-key routing and partial-failure retry (DLQ-routable)."));
1286 #[cfg(feature = "sink-spanner")]
1287 v.push(("spanner", "Google Cloud Spanner sink. Batched mutations with upsert/delete write modes, exactly-once commit tokens, and schema evolution."));
1288 #[cfg(feature = "sink-http")]
1289 v.push(("http", "HTTP POST sink (individual or array batch)"));
1290 #[cfg(feature = "sink-stdout")]
1291 v.push(("stdout", "Stdout / stderr sink (JSON Lines, pretty, TSV)"));
1292 #[cfg(feature = "sink-parquet")]
1293 v.push(("parquet", "Apache Parquet file sink (local path or S3). Schema-inferred, configurable compression, row/byte rollover."));
1294 #[cfg(feature = "sink-delta")]
1295 v.push(("delta", "Apache Delta Lake sink (local FS or S3/Azure/GCS). Append-only, schema-inferred table creation, one commit per flush."));
1296 #[cfg(feature = "sink-gcs")]
1297 v.push(("gcs", "Google Cloud Storage sink — JSONL files"));
1298 #[cfg(feature = "sink-redshift")]
1299 v.push((
1300 "redshift",
1301 "Amazon Redshift sink (COPY-from-S3 or multi-row INSERT)",
1302 ));
1303 #[cfg(feature = "sink-pubsub")]
1304 v.push((
1305 "pubsub",
1306 "Google Cloud Pub/Sub producer — batched publish with optional ordering keys, bounded concurrency, and partial-failure retry (DLQ-routable)",
1307 ));
1308 #[cfg(feature = "sink-clickhouse")]
1309 v.push((
1310 "clickhouse",
1311 "ClickHouse sink (HTTP INSERT … FORMAT JSONEachRow; optional async inserts)",
1312 ));
1313 #[cfg(feature = "sink-azure-blob")]
1314 v.push((
1315 "azure-blob",
1316 "Azure Blob Storage / ADLS Gen2 sink — JSONL files",
1317 ));
1318 v
1319}
1320
1321pub fn source_kinds() -> Vec<&'static str> {
1323 source_descriptions().into_iter().map(|(k, _)| k).collect()
1324}
1325
1326pub fn sink_kinds() -> Vec<&'static str> {
1328 sink_descriptions().into_iter().map(|(k, _)| k).collect()
1329}
1330
1331fn decode<T: DeserializeOwned>(kind: &'static str, name: &str, config: Value) -> CliResult<T> {
1332 serde_json::from_value(config).map_err(|e| CliError::InvalidConnectorConfig {
1333 kind,
1334 name: name.to_owned(),
1335 message: scrub_config_error(&e.to_string()),
1336 })
1337}
1338
1339fn scrub_config_error(msg: &str) -> String {
1349 const MAX_CHARS: usize = 200;
1350 let mut out = String::with_capacity(msg.len());
1351 let mut in_quote = false;
1352 for c in msg.chars() {
1353 if c == '"' {
1354 if !in_quote {
1355 out.push_str("\"<redacted>\"");
1356 }
1357 in_quote = !in_quote;
1358 continue;
1359 }
1360 if !in_quote {
1361 out.push(c);
1362 }
1363 }
1364 if out.chars().count() > MAX_CHARS {
1365 let truncated: String = out.chars().take(MAX_CHARS).collect();
1366 return format!("{truncated}…");
1367 }
1368 out
1369}
1370
1371fn schema<T: faucet_core::JsonSchema>() -> Value {
1372 serde_json::to_value(faucet_core::schema_for!(T))
1373 .unwrap_or_else(|_| serde_json::json!({"type": "object"}))
1374}
1375
1376fn unknown(name: &str, kind: &'static str, available: Vec<&'static str>) -> CliError {
1377 CliError::UnknownConnector {
1378 kind,
1379 name: name.to_owned(),
1380 available: if available.is_empty() {
1381 "(none — rebuild faucet-cli with the relevant feature enabled)".to_owned()
1382 } else {
1383 available.join(", ")
1384 },
1385 }
1386}
1387
1388#[cfg(test)]
1389mod tests {
1390 use super::*;
1391
1392 #[derive(Clone)]
1395 struct DummySource;
1396 #[faucet_core::async_trait]
1397 impl Source for DummySource {
1398 async fn fetch_with_context(
1399 &self,
1400 _ctx: &std::collections::HashMap<String, Value>,
1401 ) -> Result<Vec<Value>, faucet_core::FaucetError> {
1402 Ok(vec![serde_json::json!({"ok": true})])
1403 }
1404 fn config_schema(&self) -> Value {
1405 serde_json::json!({"type": "object"})
1406 }
1407 }
1408
1409 #[test]
1410 fn register_source_rejects_builtin_collision() {
1411 let reg = PluginRegistry::with_builtins()
1414 .register_source("csv", |_| Ok(Box::new(DummySource) as Box<dyn Source>));
1415 let err = reg
1418 .install()
1419 .expect_err("built-in collision must be rejected");
1420 match err {
1421 CliError::Config(msg) => assert!(msg.contains("built-in source"), "{msg}"),
1422 other => panic!("expected Config error, got {other:?}"),
1423 }
1424 }
1425
1426 #[test]
1427 fn register_source_rejects_duplicate() {
1428 let reg = PluginRegistry::new()
1429 .register_source("dup", |_| Ok(Box::new(DummySource) as Box<dyn Source>))
1430 .register_source("dup", |_| Ok(Box::new(DummySource) as Box<dyn Source>));
1431 let err = reg
1432 .install()
1433 .expect_err("duplicate registration must be rejected");
1434 match err {
1435 CliError::Config(msg) => assert!(msg.contains("more than once"), "{msg}"),
1436 other => panic!("expected Config error, got {other:?}"),
1437 }
1438 }
1439
1440 #[test]
1441 fn register_sink_rejects_duplicate() {
1442 let reg = PluginRegistry::new()
1446 .register_sink("dupsink", |_| Err(CliError::Config("unused".into())))
1447 .register_sink("dupsink", |_| Err(CliError::Config("unused".into())));
1448 assert!(
1449 reg.errors.iter().any(|e| e.contains("more than once")),
1450 "{:?}",
1451 reg.errors
1452 );
1453 }
1454
1455 #[test]
1456 fn custom_descriptions_use_default_when_blank() {
1457 let reg = PluginRegistry::new()
1458 .register_source("acme", |_| Ok(Box::new(DummySource) as Box<dyn Source>));
1459 let descs = reg.custom_source_descriptions();
1460 assert_eq!(descs.len(), 1);
1461 assert_eq!(descs[0].0, "acme");
1462 assert_eq!(descs[0].1, "custom source connector");
1463 }
1464
1465 #[test]
1466 fn custom_descriptions_carry_explicit_summary() {
1467 let reg = PluginRegistry::new().register_source_with(
1468 "acme",
1469 |_| Ok(Box::new(DummySource) as Box<dyn Source>),
1470 || serde_json::json!({"type": "object", "title": "acme"}),
1471 "Acme widget source",
1472 );
1473 let descs = reg.custom_source_descriptions();
1474 assert_eq!(descs[0], ("acme", "Acme widget source"));
1475 assert_eq!(
1477 (reg.sources.get("acme").unwrap().schema)()["title"],
1478 serde_json::json!("acme")
1479 );
1480 }
1481
1482 #[test]
1483 fn capability_constants_match_their_predicates() {
1484 for &k in EXACTLY_ONCE_SOURCE_KINDS {
1490 assert!(
1491 source_supports_exactly_once(k),
1492 "{k} should be exactly-once"
1493 );
1494 }
1495 for &k in IDEMPOTENT_SINK_KINDS {
1496 assert!(
1497 sink_supports_idempotent_writes(k),
1498 "{k} should be idempotent"
1499 );
1500 }
1501 for &k in UPSERT_SINK_KINDS {
1502 use faucet_core::WriteMode;
1503 assert!(
1504 sink_supported_write_modes(k).contains(&WriteMode::Upsert),
1505 "{k} should support upsert"
1506 );
1507 }
1508 assert!(IDEMPOTENT_SINK_KINDS.contains(&"bigquery"));
1509 assert!(IDEMPOTENT_SINK_KINDS.contains(&"kafka"));
1510 assert!(UPSERT_SINK_KINDS.contains(&"bigquery"));
1511 }
1512
1513 #[cfg(feature = "source-rest")]
1514 #[test]
1515 fn rest_source_appears_in_listings() {
1516 assert!(source_kinds().contains(&"rest"));
1517 }
1518
1519 #[cfg(feature = "sink-jsonl")]
1520 #[test]
1521 fn jsonl_sink_appears_in_listings() {
1522 assert!(sink_kinds().contains(&"jsonl"));
1523 }
1524
1525 #[tokio::test]
1526 async fn unknown_source_kind_errors() {
1527 let err = build_source("nope", serde_json::json!({}), &AuthCatalog::new(), None)
1528 .await
1529 .err()
1530 .expect("should fail");
1531 match err {
1532 CliError::UnknownConnector { kind, name, .. } => {
1533 assert_eq!(kind, "source");
1534 assert_eq!(name, "nope");
1535 }
1536 other => panic!("expected UnknownConnector, got {other:?}"),
1537 }
1538 }
1539
1540 #[tokio::test]
1541 async fn unknown_sink_kind_errors() {
1542 let err = build_sink("nope", serde_json::json!({}), &AuthCatalog::new())
1543 .await
1544 .err()
1545 .expect("should fail");
1546 assert!(matches!(
1547 err,
1548 CliError::UnknownConnector { kind: "sink", .. }
1549 ));
1550 }
1551
1552 #[cfg(feature = "source-rest")]
1553 #[test]
1554 fn rest_schema_is_object() {
1555 let s = source_schema("rest").unwrap();
1556 assert!(s.is_object());
1557 }
1558
1559 #[cfg(feature = "sink-jsonl")]
1560 #[test]
1561 fn jsonl_schema_is_object() {
1562 let s = sink_schema("jsonl").unwrap();
1563 assert!(s.is_object());
1564 }
1565
1566 #[test]
1567 fn scrub_config_error_redacts_quoted_values() {
1568 let msg =
1571 r#"invalid type: string "sk-super-secret-123", expected a sequence at line 1 column 9"#;
1572 let scrubbed = scrub_config_error(msg);
1573 assert!(!scrubbed.contains("sk-super-secret-123"), "{scrubbed}");
1574 assert!(scrubbed.contains("<redacted>"), "{scrubbed}");
1575 assert!(scrubbed.contains("invalid type"), "{scrubbed}");
1577 assert!(scrubbed.contains("expected a sequence"), "{scrubbed}");
1578 }
1579
1580 #[test]
1581 fn scrub_config_error_truncates_long_messages() {
1582 let msg = "x".repeat(500);
1583 let scrubbed = scrub_config_error(&msg);
1584 assert!(
1585 scrubbed.chars().count() <= 201,
1586 "len {}",
1587 scrubbed.chars().count()
1588 );
1589 assert!(scrubbed.ends_with('…'));
1590 }
1591
1592 #[cfg(feature = "source-csv")]
1596 #[tokio::test]
1597 async fn build_source_csv_succeeds_without_io() {
1598 let src = build_source(
1599 "csv",
1600 serde_json::json!({ "path": "/tmp/does-not-need-to-exist.csv" }),
1601 &AuthCatalog::new(),
1602 None,
1603 )
1604 .await
1605 .expect("csv source should build without I/O");
1606 assert_eq!(src.connector_name(), "CsvSource");
1609 }
1610
1611 #[cfg(feature = "sink-jsonl")]
1614 #[tokio::test]
1615 async fn build_sink_jsonl_succeeds_without_io() {
1616 let sink = build_sink(
1617 "jsonl",
1618 serde_json::json!({ "path": "/tmp/does-not-need-to-exist.jsonl" }),
1619 &AuthCatalog::new(),
1620 )
1621 .await
1622 .expect("jsonl sink should build without I/O");
1623 assert_eq!(sink.connector_name(), "jsonl");
1624 }
1625
1626 #[cfg(feature = "sink-stdout")]
1628 #[tokio::test]
1629 async fn build_sink_stdout_succeeds_without_io() {
1630 let sink = build_sink("stdout", serde_json::json!({}), &AuthCatalog::new())
1631 .await
1632 .expect("stdout sink should build without I/O");
1633 assert_eq!(sink.connector_name(), "StdoutSink");
1636 }
1637
1638 #[cfg(all(feature = "source-delta", feature = "sink-delta"))]
1642 #[tokio::test]
1643 async fn delta_registry_round_trip() {
1644 let dir = tempfile::tempdir().unwrap();
1645 let uri = dir.path().join("reg_delta").to_string_lossy().into_owned();
1646
1647 assert!(source_schema("delta").is_ok());
1648 assert!(sink_schema("delta").is_ok());
1649 assert!(source_descriptions().iter().any(|(n, _)| *n == "delta"));
1650 assert!(sink_descriptions().iter().any(|(n, _)| *n == "delta"));
1651
1652 let sink = build_sink(
1653 "delta",
1654 serde_json::json!({ "table_uri": uri }),
1655 &AuthCatalog::new(),
1656 )
1657 .await
1658 .expect("delta sink builds");
1659 assert_eq!(sink.connector_name(), "delta");
1660 let n = sink
1661 .write_batch(&[serde_json::json!({"id": 1}), serde_json::json!({"id": 2})])
1662 .await
1663 .expect("write");
1664 assert_eq!(n, 2);
1665 sink.flush().await.expect("flush");
1666
1667 let source = build_source(
1668 "delta",
1669 serde_json::json!({ "table_uri": uri }),
1670 &AuthCatalog::new(),
1671 None,
1672 )
1673 .await
1674 .expect("delta source builds");
1675 assert_eq!(source.connector_name(), "delta");
1676 let rows = source
1677 .fetch_with_context(&std::collections::HashMap::new())
1678 .await
1679 .expect("read");
1680 assert_eq!(rows.len(), 2);
1681 }
1682
1683 #[cfg(feature = "source-databricks")]
1686 #[tokio::test]
1687 async fn databricks_registry_source_builds() {
1688 assert!(source_schema("databricks").is_ok());
1689 assert!(
1690 source_descriptions()
1691 .iter()
1692 .any(|(n, _)| *n == "databricks")
1693 );
1694 let cfg = serde_json::json!({
1695 "workspace_url": "https://x.cloud.databricks.com",
1696 "warehouse_id": "wh1",
1697 "sql": "SELECT 1",
1698 "auth": { "type": "pat", "config": { "token": "t" } }
1699 });
1700 let src = build_source("databricks", cfg, &AuthCatalog::new(), None)
1701 .await
1702 .expect("databricks source builds");
1703 assert_eq!(src.connector_name(), "databricks");
1704 }
1705
1706 #[cfg(feature = "source-csv")]
1709 #[tokio::test]
1710 async fn build_source_csv_invalid_config_errors() {
1711 let res = build_source(
1715 "csv",
1716 serde_json::json!({ "path": 42 }),
1717 &AuthCatalog::new(),
1718 None,
1719 )
1720 .await;
1721 match res {
1722 Err(CliError::InvalidConnectorConfig { kind, name, .. }) => {
1723 assert_eq!(kind, "source");
1724 assert_eq!(name, "csv");
1725 }
1726 Ok(_) => panic!("expected InvalidConnectorConfig, got Ok"),
1727 Err(other) => panic!("expected InvalidConnectorConfig, got {other:?}"),
1728 }
1729 }
1730
1731 #[cfg(feature = "source-csv")]
1734 #[test]
1735 fn source_schema_csv_exposes_path_property() {
1736 let schema = source_schema("csv").expect("csv schema");
1737 let props = schema
1738 .get("properties")
1739 .and_then(Value::as_object)
1740 .expect("schema should have a properties object");
1741 assert!(props.contains_key("path"), "schema props: {props:?}");
1742 }
1743
1744 #[cfg(feature = "sink-jsonl")]
1745 #[test]
1746 fn sink_schema_jsonl_exposes_path_property() {
1747 let schema = sink_schema("jsonl").expect("jsonl schema");
1748 let props = schema
1749 .get("properties")
1750 .and_then(Value::as_object)
1751 .expect("schema should have a properties object");
1752 assert!(props.contains_key("path"), "schema props: {props:?}");
1753 }
1754
1755 #[test]
1756 fn unknown_source_schema_errors_with_available_list() {
1757 let err = source_schema("definitely-not-a-source").expect_err("unknown source");
1758 match err {
1759 CliError::UnknownConnector {
1760 kind,
1761 name,
1762 available,
1763 } => {
1764 assert_eq!(kind, "source");
1765 assert_eq!(name, "definitely-not-a-source");
1766 assert!(!available.is_empty());
1768 }
1769 other => panic!("expected UnknownConnector, got {other:?}"),
1770 }
1771 }
1772
1773 #[test]
1774 fn unknown_sink_schema_errors() {
1775 let err = sink_schema("definitely-not-a-sink").expect_err("unknown sink");
1776 assert!(matches!(
1777 err,
1778 CliError::UnknownConnector { kind: "sink", .. }
1779 ));
1780 }
1781
1782 #[cfg(feature = "source-csv")]
1783 #[test]
1784 fn source_exists_is_true_for_known_and_false_for_unknown() {
1785 assert!(source_exists("csv"));
1786 assert!(!source_exists("definitely-not-a-source"));
1787 }
1788
1789 #[cfg(feature = "sink-jsonl")]
1790 #[test]
1791 fn sink_exists_is_true_for_known_and_false_for_unknown() {
1792 assert!(sink_exists("jsonl"));
1793 assert!(!sink_exists("definitely-not-a-sink"));
1794 }
1795
1796 #[test]
1799 fn source_descriptions_are_non_empty_and_consistent() {
1800 let descs = source_descriptions();
1801 assert!(!descs.is_empty());
1802 for (name, summary) in &descs {
1803 assert!(!name.is_empty(), "empty connector name");
1804 assert!(!summary.is_empty(), "empty summary for {name}");
1805 assert!(
1806 source_schema(name).is_ok(),
1807 "listed source `{name}` has no schema"
1808 );
1809 }
1810 }
1811
1812 #[test]
1813 fn sink_descriptions_are_non_empty_and_consistent() {
1814 let descs = sink_descriptions();
1815 assert!(!descs.is_empty());
1816 for (name, summary) in &descs {
1817 assert!(!name.is_empty(), "empty connector name");
1818 assert!(!summary.is_empty(), "empty summary for {name}");
1819 assert!(
1820 sink_schema(name).is_ok(),
1821 "listed sink `{name}` has no schema"
1822 );
1823 }
1824 }
1825
1826 #[cfg(all(feature = "source-csv", feature = "source-rest"))]
1829 #[test]
1830 fn source_kinds_contains_expected_builtins() {
1831 let kinds = source_kinds();
1832 assert!(kinds.contains(&"csv"));
1833 assert!(kinds.contains(&"rest"));
1834 }
1835
1836 #[cfg(all(feature = "sink-jsonl", feature = "sink-stdout"))]
1837 #[test]
1838 fn sink_kinds_contains_expected_builtins() {
1839 let kinds = sink_kinds();
1840 assert!(kinds.contains(&"jsonl"));
1841 assert!(kinds.contains(&"stdout"));
1842 }
1843
1844 #[cfg(feature = "source-rest")]
1848 #[tokio::test]
1849 async fn build_source_injects_referenced_auth_provider() {
1850 let mut specs = std::collections::HashMap::new();
1851 specs.insert(
1852 "tok".to_string(),
1853 serde_json::json!({"type": "static", "config": {"token": "abc"}}),
1854 );
1855 let catalog = auth_catalog::build_auth_catalog(Some(&specs)).expect("catalog");
1856
1857 let src = build_source("rest", rest_config_with_auth_ref("tok"), &catalog, None)
1858 .await
1859 .expect("rest source with a resolvable auth ref should build");
1860 assert_eq!(src.connector_name(), "rest");
1861 }
1862
1863 #[cfg(feature = "source-rest")]
1866 fn rest_config_with_auth_ref(name: &str) -> Value {
1867 let cfg = faucet_source_rest::RestStreamConfig::new("https://api.example.com", "/v1");
1868 let mut v = serde_json::to_value(cfg).expect("serialize rest config");
1869 v.as_object_mut()
1870 .unwrap()
1871 .insert("auth".to_string(), serde_json::json!({ "ref": name }));
1872 v
1873 }
1874
1875 #[cfg(feature = "source-rest")]
1878 #[tokio::test]
1879 async fn build_source_unknown_auth_ref_errors() {
1880 let res = build_source(
1881 "rest",
1882 rest_config_with_auth_ref("missing"),
1883 &AuthCatalog::new(),
1884 None,
1885 )
1886 .await;
1887 match res {
1888 Err(CliError::UnknownAuthProvider { name, .. }) => assert_eq!(name, "missing"),
1889 Ok(_) => panic!("expected UnknownAuthProvider, got Ok"),
1890 Err(other) => panic!("expected UnknownAuthProvider, got {other:?}"),
1891 }
1892 }
1893
1894 #[test]
1895 fn exactly_once_capability_allowlists() {
1896 assert!(source_supports_exactly_once("postgres-cdc"));
1897 assert!(source_supports_exactly_once("mysql-cdc"));
1898 assert!(source_supports_exactly_once("mongodb-cdc"));
1899 assert!(source_supports_exactly_once("kafka"));
1900 assert!(!source_supports_exactly_once("rest"));
1901
1902 assert!(sink_supports_idempotent_writes("postgres"));
1903 assert!(sink_supports_idempotent_writes("iceberg"));
1904 assert!(sink_supports_idempotent_writes("bigquery"));
1905 assert!(sink_supports_idempotent_writes("kafka"));
1906 assert!(sink_supports_idempotent_writes("snowflake"));
1907 assert!(sink_supports_idempotent_writes("redis"));
1908 assert!(sink_supports_idempotent_writes("mongodb"));
1909 assert!(!sink_supports_idempotent_writes("jsonl"));
1910 }
1911
1912 #[test]
1913 fn typed_delivery_capabilities_derive_from_kind_tables() {
1914 use faucet_core::{ReplayGuarantee, SinkGuarantee};
1915 assert_eq!(
1916 source_replay_guarantee("kafka"),
1917 ReplayGuarantee::Deterministic
1918 );
1919 assert_eq!(
1920 source_replay_guarantee("rest"),
1921 ReplayGuarantee::NonDeterministic
1922 );
1923 assert_eq!(sink_guarantee("postgres"), SinkGuarantee::AtomicWatermark);
1924 assert_eq!(sink_guarantee("elasticsearch"), SinkGuarantee::KeyedUpsert);
1926 assert_eq!(sink_guarantee("jsonl"), SinkGuarantee::AtLeastOnce);
1927 }
1928
1929 #[test]
1930 fn sink_supported_write_modes_allowlist() {
1931 use faucet_core::WriteMode;
1932 assert!(sink_supported_write_modes("postgres").contains(&WriteMode::Upsert));
1933 assert!(sink_supported_write_modes("elasticsearch").contains(&WriteMode::Delete));
1934 assert!(sink_supported_write_modes("bigquery").contains(&WriteMode::Upsert));
1935 assert_eq!(sink_supported_write_modes("jsonl"), &[WriteMode::Append]);
1937 assert_eq!(sink_supported_write_modes("kafka"), &[WriteMode::Append]);
1938 }
1939
1940 #[test]
1941 fn sink_supports_schema_evolution_allowlist() {
1942 assert!(sink_supports_schema_evolution("postgres"));
1943 assert!(sink_supports_schema_evolution("mysql"));
1944 assert!(sink_supports_schema_evolution("mssql"));
1945 assert!(sink_supports_schema_evolution("sqlite"));
1946 assert!(sink_supports_schema_evolution("bigquery"));
1947 assert!(sink_supports_schema_evolution("elasticsearch"));
1948 assert!(sink_supports_schema_evolution("iceberg"));
1949 assert!(!sink_supports_schema_evolution("jsonl"));
1950 assert!(!sink_supports_schema_evolution("kafka"));
1951 }
1952}