1use crate::error::FaucetError;
4use crate::pipeline::StreamPage;
5use async_trait::async_trait;
6use futures_core::Stream;
7use serde_json::Value;
8use std::pin::Pin;
9
10#[async_trait]
12pub trait Source: Send + Sync {
13 async fn fetch_with_context(
21 &self,
22 context: &std::collections::HashMap<String, Value>,
23 ) -> Result<Vec<Value>, FaucetError>;
24
25 async fn fetch_all(&self) -> Result<Vec<Value>, FaucetError> {
27 self.fetch_with_context(&std::collections::HashMap::new())
28 .await
29 }
30
31 async fn fetch_with_context_incremental(
37 &self,
38 context: &std::collections::HashMap<String, Value>,
39 ) -> Result<(Vec<Value>, Option<Value>), FaucetError> {
40 let records = self.fetch_with_context(context).await?;
41 Ok((records, None))
42 }
43
44 async fn fetch_all_incremental(&self) -> Result<(Vec<Value>, Option<Value>), FaucetError> {
46 self.fetch_with_context_incremental(&std::collections::HashMap::new())
47 .await
48 }
49
50 fn stream_pages<'a>(
74 &'a self,
75 context: &'a std::collections::HashMap<String, Value>,
76 batch_size: usize,
77 ) -> Pin<Box<dyn Stream<Item = Result<StreamPage, FaucetError>> + Send + 'a>> {
78 Box::pin(async_stream::try_stream! {
79 let (records, bookmark) = self
80 .fetch_with_context_incremental(context)
81 .await?;
82 let total = records.len();
83 let chunk = if batch_size == 0 { usize::MAX } else { batch_size };
86
87 if total == 0 {
88 if bookmark.is_some() {
89 yield StreamPage {
90 records: Vec::new(),
91 bookmark,
92 };
93 }
94 return;
95 }
96
97 let mut iter = records.into_iter();
98 let mut consumed = 0usize;
99 loop {
100 let batch: Vec<Value> = iter.by_ref().take(chunk).collect();
101 if batch.is_empty() {
102 break;
103 }
104 consumed += batch.len();
105 let page_bookmark = if consumed >= total {
106 bookmark.clone()
107 } else {
108 None
109 };
110 yield StreamPage {
111 records: batch,
112 bookmark: page_bookmark,
113 };
114 }
115 })
116 }
117
118 fn config_schema(&self) -> Value {
120 serde_json::json!({"type": "object", "properties": {}})
121 }
122
123 fn state_key(&self) -> Option<String> {
135 None
136 }
137
138 async fn apply_start_bookmark(&self, _bookmark: Value) -> Result<(), FaucetError> {
147 Ok(())
148 }
149
150 async fn capture_resume_position(&self) -> Result<Option<Value>, FaucetError> {
162 Ok(None)
163 }
164
165 fn supports_exactly_once(&self) -> bool {
174 false
175 }
176
177 fn replay_guarantee(&self) -> crate::idempotency::ReplayGuarantee {
186 if self.supports_exactly_once() {
187 crate::idempotency::ReplayGuarantee::Deterministic
188 } else {
189 crate::idempotency::ReplayGuarantee::NonDeterministic
190 }
191 }
192
193 fn is_shardable(&self) -> bool {
200 false
201 }
202
203 async fn enumerate_shards(
215 &self,
216 _target: usize,
217 ) -> Result<Vec<crate::shard::ShardSpec>, FaucetError> {
218 Ok(vec![crate::shard::ShardSpec::whole()])
219 }
220
221 async fn apply_shard(&self, _shard: &crate::shard::ShardSpec) -> Result<(), FaucetError> {
231 Ok(())
232 }
233
234 fn connector_name(&self) -> &'static str {
242 crate::observability::strip_type_name(std::any::type_name::<Self>())
243 }
244
245 fn dataset_uri(&self) -> String {
253 format!("{}://unknown", self.connector_name())
254 }
255
256 async fn check(
269 &self,
270 ctx: &crate::check::CheckContext,
271 ) -> Result<crate::check::CheckReport, FaucetError> {
272 use crate::check::{CheckReport, Probe};
273 use futures::StreamExt;
274
275 let empty = std::collections::HashMap::new();
276 let start = std::time::Instant::now();
277 let mut pages = self.stream_pages(&empty, 1);
278 let probe = match tokio::time::timeout(ctx.timeout, pages.next()).await {
279 Err(_) => Probe::fail("read", start.elapsed(), "timed out fetching first page"),
280 Ok(None) | Ok(Some(Ok(_))) => Probe::pass("read", start.elapsed()),
281 Ok(Some(Err(e))) => Probe::fail("read", start.elapsed(), e.to_string()),
282 };
283 Ok(CheckReport::single(probe))
284 }
285}
286
287pub type RowOutcome = Result<(), FaucetError>;
293
294#[async_trait]
296pub trait Sink: Send + Sync {
297 async fn write_batch(&self, records: &[Value]) -> Result<usize, FaucetError>;
301
302 async fn flush(&self) -> Result<(), FaucetError> {
307 Ok(())
308 }
309
310 async fn write_batch_partial(&self, records: &[Value]) -> Result<Vec<RowOutcome>, FaucetError> {
319 self.write_batch(records).await?;
320 Ok(records.iter().map(|_| Ok(())).collect())
321 }
322
323 fn supports_idempotent_writes(&self) -> bool {
330 false
331 }
332
333 fn sink_guarantee(&self) -> crate::idempotency::SinkGuarantee {
343 if self.supports_idempotent_writes() {
344 crate::idempotency::SinkGuarantee::AtomicWatermark
345 } else if self
346 .supported_write_modes()
347 .contains(&crate::write_mode::WriteMode::Upsert)
348 {
349 crate::idempotency::SinkGuarantee::KeyedUpsert
350 } else {
351 crate::idempotency::SinkGuarantee::AtLeastOnce
352 }
353 }
354
355 fn dedups_by_key(&self) -> bool {
366 false
367 }
368
369 fn supported_write_modes(&self) -> &'static [crate::write_mode::WriteMode] {
375 &[crate::write_mode::WriteMode::Append]
376 }
377
378 async fn current_schema(&self) -> Result<Option<Value>, FaucetError> {
385 Ok(None)
386 }
387
388 fn supports_schema_evolution(&self) -> bool {
392 false
393 }
394
395 async fn evolve_schema(
403 &self,
404 evolution: &crate::drift::SchemaEvolution,
405 ) -> Result<(), FaucetError> {
406 let _ = evolution;
407 Err(FaucetError::Sink(format!(
408 "sink '{}' does not support schema evolution",
409 self.connector_name()
410 )))
411 }
412
413 async fn write_batch_idempotent(
423 &self,
424 records: &[Value],
425 scope: &str,
426 token: &str,
427 ) -> Result<usize, FaucetError> {
428 let _ = (scope, token);
429 self.write_batch(records).await
430 }
431
432 async fn last_committed_token(&self, scope: &str) -> Result<Option<String>, FaucetError> {
435 let _ = scope;
436 Ok(None)
437 }
438
439 fn config_schema(&self) -> Value {
447 serde_json::json!({"type": "object", "properties": {}})
448 }
449
450 fn connector_name(&self) -> &'static str {
453 crate::observability::strip_type_name(std::any::type_name::<Self>())
454 }
455
456 fn dataset_uri(&self) -> String {
464 format!("{}://unknown", self.connector_name())
465 }
466
467 async fn check(
478 &self,
479 _ctx: &crate::check::CheckContext,
480 ) -> Result<crate::check::CheckReport, FaucetError> {
481 Ok(crate::check::CheckReport::not_implemented())
482 }
483}
484
485#[cfg(test)]
486mod tests {
487 use super::*;
488 use serde_json::json;
489
490 struct MockSource {
493 records: Vec<Value>,
494 }
495
496 #[async_trait]
497 impl Source for MockSource {
498 async fn fetch_with_context(
499 &self,
500 _context: &std::collections::HashMap<String, Value>,
501 ) -> Result<Vec<Value>, FaucetError> {
502 Ok(self.records.clone())
503 }
504 }
505
506 struct IncrementalSource {
507 records: Vec<Value>,
508 bookmark: Value,
509 }
510
511 #[async_trait]
512 impl Source for IncrementalSource {
513 async fn fetch_with_context(
514 &self,
515 _context: &std::collections::HashMap<String, Value>,
516 ) -> Result<Vec<Value>, FaucetError> {
517 Ok(self.records.clone())
518 }
519
520 async fn fetch_with_context_incremental(
521 &self,
522 _context: &std::collections::HashMap<String, Value>,
523 ) -> Result<(Vec<Value>, Option<Value>), FaucetError> {
524 Ok((self.records.clone(), Some(self.bookmark.clone())))
525 }
526 }
527
528 struct FailingSource;
529
530 #[async_trait]
531 impl Source for FailingSource {
532 async fn fetch_with_context(
533 &self,
534 _context: &std::collections::HashMap<String, Value>,
535 ) -> Result<Vec<Value>, FaucetError> {
536 Err(FaucetError::Auth("no credentials".into()))
537 }
538 }
539
540 struct MockSink {
543 written: std::sync::Mutex<Vec<Value>>,
544 }
545
546 impl MockSink {
547 fn new() -> Self {
548 Self {
549 written: std::sync::Mutex::new(Vec::new()),
550 }
551 }
552 }
553
554 #[async_trait]
555 impl Sink for MockSink {
556 async fn write_batch(&self, records: &[Value]) -> Result<usize, FaucetError> {
557 let mut w = self.written.lock().unwrap();
558 w.extend(records.iter().cloned());
559 Ok(records.len())
560 }
561 }
562
563 struct FailingSink;
564
565 #[async_trait]
566 impl Sink for FailingSink {
567 async fn write_batch(&self, _records: &[Value]) -> Result<usize, FaucetError> {
568 Err(FaucetError::Sink("write failed".into()))
569 }
570 }
571
572 #[tokio::test]
575 async fn source_fetch_all_returns_records() {
576 let source = MockSource {
577 records: vec![json!({"id": 1}), json!({"id": 2})],
578 };
579 let records = source.fetch_all().await.unwrap();
580 assert_eq!(records.len(), 2);
581 assert_eq!(records[0]["id"], 1);
582 }
583
584 #[tokio::test]
585 async fn source_fetch_all_empty() {
586 let source = MockSource { records: vec![] };
587 let records = source.fetch_all().await.unwrap();
588 assert!(records.is_empty());
589 }
590
591 #[tokio::test]
592 async fn source_default_incremental_returns_none_bookmark() {
593 let source = MockSource {
594 records: vec![json!({"id": 1})],
595 };
596 let (records, bookmark) = source.fetch_all_incremental().await.unwrap();
597 assert_eq!(records.len(), 1);
598 assert!(bookmark.is_none());
599 }
600
601 #[tokio::test]
602 async fn source_custom_incremental_returns_bookmark() {
603 let source = IncrementalSource {
604 records: vec![json!({"id": 1})],
605 bookmark: json!("2024-12-01"),
606 };
607 let (records, bookmark) = source.fetch_all_incremental().await.unwrap();
608 assert_eq!(records.len(), 1);
609 assert_eq!(bookmark, Some(json!("2024-12-01")));
610 }
611
612 #[tokio::test]
613 async fn source_error_propagates() {
614 let source = FailingSource;
615 let result = source.fetch_all().await;
616 assert!(result.is_err());
617 assert!(matches!(result, Err(FaucetError::Auth(_))));
618 }
619
620 #[tokio::test]
621 async fn source_as_trait_object() {
622 let source: Box<dyn Source> = Box::new(MockSource {
623 records: vec![json!({"id": 42})],
624 });
625 let records = source.fetch_all().await.unwrap();
626 assert_eq!(records[0]["id"], 42);
627 }
628
629 #[tokio::test]
632 async fn sink_write_batch_returns_count() {
633 let sink = MockSink::new();
634 let records = vec![json!({"id": 1}), json!({"id": 2}), json!({"id": 3})];
635 let count = sink.write_batch(&records).await.unwrap();
636 assert_eq!(count, 3);
637 }
638
639 #[tokio::test]
640 async fn sink_write_batch_empty() {
641 let sink = MockSink::new();
642 let count = sink.write_batch(&[]).await.unwrap();
643 assert_eq!(count, 0);
644 }
645
646 #[tokio::test]
647 async fn sink_accumulates_records() {
648 let sink = MockSink::new();
649 sink.write_batch(&[json!({"a": 1})]).await.unwrap();
650 sink.write_batch(&[json!({"b": 2})]).await.unwrap();
651 let written = sink.written.lock().unwrap();
652 assert_eq!(written.len(), 2);
653 }
654
655 #[tokio::test]
656 async fn sink_default_flush_is_noop() {
657 let sink = MockSink::new();
658 assert!(sink.flush().await.is_ok());
659 }
660
661 #[tokio::test]
662 async fn sink_error_propagates() {
663 let sink = FailingSink;
664 let result = sink.write_batch(&[json!({"id": 1})]).await;
665 assert!(result.is_err());
666 assert!(matches!(result, Err(FaucetError::Sink(_))));
667 }
668
669 #[tokio::test]
670 async fn sink_as_trait_object() {
671 let sink: Box<dyn Sink> = Box::new(MockSink::new());
672 let count = sink.write_batch(&[json!({"id": 1})]).await.unwrap();
673 assert_eq!(count, 1);
674 }
675
676 use crate::pipeline::DEFAULT_BATCH_SIZE;
679 use futures::StreamExt;
680
681 #[tokio::test]
682 async fn default_stream_pages_chunks_records() {
683 let source = MockSource {
684 records: (0..5).map(|i| json!({"i": i})).collect(),
685 };
686 let ctx = std::collections::HashMap::new();
687 let mut pages = source.stream_pages(&ctx, 2);
688 let mut all = Vec::new();
689 while let Some(page) = pages.next().await {
690 all.push(page.unwrap());
691 }
692 assert_eq!(all.len(), 3);
694 assert_eq!(all[0].records.len(), 2);
695 assert_eq!(all[1].records.len(), 2);
696 assert_eq!(all[2].records.len(), 1);
697 }
698
699 #[tokio::test]
700 async fn default_stream_pages_attaches_bookmark_to_final_page_only() {
701 let source = IncrementalSource {
702 records: (0..5).map(|i| json!({"i": i})).collect(),
703 bookmark: json!("v1"),
704 };
705 let ctx = std::collections::HashMap::new();
706 let mut pages = source.stream_pages(&ctx, 2);
707 let mut collected = Vec::new();
708 while let Some(page) = pages.next().await {
709 collected.push(page.unwrap());
710 }
711 assert_eq!(collected.len(), 3);
712 assert!(collected[0].bookmark.is_none());
713 assert!(collected[1].bookmark.is_none());
714 assert_eq!(collected[2].bookmark, Some(json!("v1")));
715 }
716
717 #[tokio::test]
718 async fn default_stream_pages_single_page_when_batch_size_exceeds_total() {
719 let source = MockSource {
720 records: vec![json!({"id": 1}), json!({"id": 2})],
721 };
722 let ctx = std::collections::HashMap::new();
723 let mut pages = source.stream_pages(&ctx, 100);
724 let mut collected = Vec::new();
725 while let Some(page) = pages.next().await {
726 collected.push(page.unwrap());
727 }
728 assert_eq!(collected.len(), 1);
729 assert_eq!(collected[0].records.len(), 2);
730 }
731
732 #[tokio::test]
733 async fn default_stream_pages_batch_size_zero_emits_single_page() {
734 let source = MockSource {
737 records: (0..50_000).map(|i| json!({"i": i})).collect(),
738 };
739 let ctx = std::collections::HashMap::new();
740 let mut pages = source.stream_pages(&ctx, 0);
741 let mut collected = Vec::new();
742 while let Some(page) = pages.next().await {
743 collected.push(page.unwrap());
744 }
745 assert_eq!(
746 collected.len(),
747 1,
748 "batch_size=0 must emit exactly one page"
749 );
750 assert_eq!(collected[0].records.len(), 50_000);
751 }
752
753 #[tokio::test]
754 async fn default_stream_pages_batch_size_zero_attaches_bookmark_to_sole_page() {
755 let source = IncrementalSource {
756 records: (0..3).map(|i| json!({"i": i})).collect(),
757 bookmark: json!("v1"),
758 };
759 let ctx = std::collections::HashMap::new();
760 let mut pages = source.stream_pages(&ctx, 0);
761 let page = pages.next().await.unwrap().unwrap();
762 assert_eq!(page.records.len(), 3);
763 assert_eq!(page.bookmark, Some(json!("v1")));
764 assert!(pages.next().await.is_none());
765 }
766
767 #[tokio::test]
768 async fn default_stream_pages_empty_source_yields_no_pages() {
769 let source = MockSource { records: vec![] };
770 let ctx = std::collections::HashMap::new();
771 let mut pages = source.stream_pages(&ctx, DEFAULT_BATCH_SIZE);
772 assert!(pages.next().await.is_none());
773 }
774
775 #[tokio::test]
776 async fn default_stream_pages_empty_source_with_bookmark_yields_single_empty_page() {
777 let source = IncrementalSource {
778 records: vec![],
779 bookmark: json!("v0"),
780 };
781 let ctx = std::collections::HashMap::new();
782 let mut pages = source.stream_pages(&ctx, DEFAULT_BATCH_SIZE);
783 let mut collected = Vec::new();
784 while let Some(page) = pages.next().await {
785 collected.push(page.unwrap());
786 }
787 assert_eq!(collected.len(), 1);
790 assert!(collected[0].records.is_empty());
791 assert_eq!(collected[0].bookmark, Some(json!("v0")));
792 }
793
794 #[tokio::test]
795 async fn default_stream_pages_propagates_fetch_errors() {
796 let source = FailingSource;
797 let ctx = std::collections::HashMap::new();
798 let mut pages = source.stream_pages(&ctx, DEFAULT_BATCH_SIZE);
799 let first = pages.next().await.unwrap();
800 assert!(matches!(first, Err(FaucetError::Auth(_))));
801 }
802
803 #[test]
804 fn source_default_connector_name_is_stripped_type_name() {
805 let source = MockSource { records: vec![] };
808 assert_eq!(source.connector_name(), "MockSource");
809 }
810
811 #[test]
812 fn sink_default_connector_name_is_stripped_type_name() {
813 let sink = MockSink::new();
814 assert_eq!(sink.connector_name(), "MockSink");
815 }
816
817 #[test]
818 fn source_default_dataset_uri_uses_connector_name() {
819 let source = MockSource { records: vec![] };
820 assert_eq!(source.dataset_uri(), "MockSource://unknown");
821 }
822
823 #[test]
824 fn sink_default_dataset_uri_uses_connector_name() {
825 let sink = MockSink::new();
826 assert_eq!(sink.dataset_uri(), "MockSink://unknown");
827 }
828
829 #[tokio::test]
832 async fn default_write_batch_partial_success_returns_all_ok() {
833 let sink = MockSink::new();
834 let records = vec![json!({"id": 1}), json!({"id": 2}), json!({"id": 3})];
835 let outcomes = sink.write_batch_partial(&records).await.unwrap();
836 assert_eq!(outcomes.len(), 3);
837 assert!(outcomes.iter().all(|o| o.is_ok()));
838 assert_eq!(sink.written.lock().unwrap().len(), 3);
839 }
840
841 #[tokio::test]
842 async fn default_write_batch_partial_bubbles_outer_err() {
843 let sink = FailingSink;
844 let records = vec![json!({"id": 1}), json!({"id": 2})];
845 let result = sink.write_batch_partial(&records).await;
846 assert!(matches!(result, Err(FaucetError::Sink(_))));
847 }
848
849 #[tokio::test]
850 async fn default_write_batch_partial_empty_returns_empty_vec() {
851 let sink = MockSink::new();
852 let outcomes = sink.write_batch_partial(&[]).await.unwrap();
853 assert!(outcomes.is_empty());
854 }
855
856 #[tokio::test]
857 async fn default_write_batch_partial_callable_through_trait_object() {
858 let sink: Box<dyn Sink> = Box::new(MockSink::new());
859 let records = vec![json!({"id": 1}), json!({"id": 2})];
860 let outcomes = sink.write_batch_partial(&records).await.unwrap();
861 assert_eq!(outcomes.len(), 2);
862 assert!(outcomes.iter().all(|o| o.is_ok()));
863 }
864
865 #[tokio::test]
868 async fn source_default_check_pulls_first_page_and_passes() {
869 let source = MockSource {
870 records: vec![json!({"id": 1}), json!({"id": 2})],
871 };
872 let report = source
873 .check(&crate::check::CheckContext::default())
874 .await
875 .unwrap();
876 assert_eq!(report.failed_count(), 0);
877 assert!(
878 report
879 .probes
880 .iter()
881 .any(|p| p.name == "read" && matches!(p.status, crate::check::ProbeStatus::Pass))
882 );
883 }
884
885 #[tokio::test]
886 async fn source_default_check_passes_on_empty_source() {
887 let source = MockSource { records: vec![] };
888 let report = source
889 .check(&crate::check::CheckContext::default())
890 .await
891 .unwrap();
892 assert_eq!(report.failed_count(), 0);
894 }
895
896 #[tokio::test]
897 async fn source_default_check_fails_when_fetch_errors() {
898 let source = FailingSource;
899 let report = source
900 .check(&crate::check::CheckContext::default())
901 .await
902 .unwrap();
903 assert_eq!(report.failed_count(), 1);
904 assert!(report.probes.iter().any(
905 |p| p.name == "read" && matches!(p.status, crate::check::ProbeStatus::Fail { .. })
906 ));
907 }
908
909 #[tokio::test]
910 async fn sink_default_check_is_not_implemented_skip() {
911 let sink = MockSink::new();
912 let report = sink
913 .check(&crate::check::CheckContext::default())
914 .await
915 .unwrap();
916 assert_eq!(report.probes.len(), 1);
917 assert!(matches!(
918 report.probes[0].status,
919 crate::check::ProbeStatus::Skip { .. }
920 ));
921 }
922
923 #[tokio::test]
924 async fn source_check_callable_through_trait_object() {
925 let source: Box<dyn Source> = Box::new(MockSource {
926 records: vec![json!({"id": 1})],
927 });
928 let report = source
929 .check(&crate::check::CheckContext::default())
930 .await
931 .unwrap();
932 assert_eq!(report.failed_count(), 0);
933 }
934
935 #[tokio::test]
938 async fn sink_default_is_not_idempotent() {
939 let sink = MockSink::new();
940 assert!(!sink.supports_idempotent_writes());
941 let n = sink
943 .write_batch_idempotent(&[json!({"id": 1})], "scope::a", "00000000000000000001")
944 .await
945 .unwrap();
946 assert_eq!(n, 1);
947 assert_eq!(sink.last_committed_token("scope::a").await.unwrap(), None);
948 assert_eq!(sink.written.lock().unwrap().len(), 1);
949 }
950
951 #[test]
952 fn source_default_does_not_support_exactly_once() {
953 let source = MockSource { records: vec![] };
954 assert!(!source.supports_exactly_once());
955 }
956
957 #[test]
958 fn sink_default_supported_write_modes_is_append_only() {
959 use crate::write_mode::WriteMode;
960 let sink = MockSink::new();
961 assert_eq!(sink.supported_write_modes(), &[WriteMode::Append]);
962 }
963
964 #[test]
965 fn supported_write_modes_callable_through_trait_object() {
966 use crate::write_mode::WriteMode;
967 let sink: Box<dyn Sink> = Box::new(MockSink::new());
968 assert!(sink.supported_write_modes().contains(&WriteMode::Append));
969 }
970
971 #[tokio::test]
972 async fn sink_default_current_schema_is_none() {
973 let sink = MockSink::new();
974 assert_eq!(sink.current_schema().await.unwrap(), None);
975 }
976
977 #[test]
978 fn sink_default_does_not_support_schema_evolution() {
979 let sink = MockSink::new();
980 assert!(!sink.supports_schema_evolution());
981 }
982
983 #[tokio::test]
984 async fn sink_default_evolve_schema_is_unsupported_error() {
985 let sink = MockSink::new();
986 let evo = crate::drift::SchemaEvolution::default();
987 let err = sink.evolve_schema(&evo).await.unwrap_err();
988 assert!(matches!(err, FaucetError::Sink(_)));
989 assert!(err.to_string().contains("schema evolution"));
990 }
991
992 #[tokio::test]
993 async fn source_default_capture_resume_position_is_none() {
994 let source = MockSource { records: vec![] };
995 assert_eq!(source.capture_resume_position().await.unwrap(), None);
996 }
997
998 #[tokio::test]
999 async fn capture_resume_position_callable_through_trait_object() {
1000 let source: Box<dyn Source> = Box::new(MockSource { records: vec![] });
1001 assert!(source.capture_resume_position().await.unwrap().is_none());
1002 }
1003
1004 #[tokio::test]
1005 async fn source_default_is_not_shardable() {
1006 let source: Box<dyn Source> = Box::new(MockSource { records: vec![] });
1007 assert!(!source.is_shardable());
1008 }
1009
1010 #[tokio::test]
1011 async fn source_default_enumerates_single_whole_shard() {
1012 let source: Box<dyn Source> = Box::new(MockSource { records: vec![] });
1015 let shards = source.enumerate_shards(8).await.unwrap();
1016 assert_eq!(shards.len(), 1);
1017 assert!(shards[0].is_whole());
1018 }
1019
1020 #[tokio::test]
1021 async fn source_default_apply_shard_is_noop() {
1022 let source: Box<dyn Source> = Box::new(MockSource { records: vec![] });
1023 source
1025 .apply_shard(&crate::shard::ShardSpec::whole())
1026 .await
1027 .unwrap();
1028 }
1029}