Skip to main content

faucet_core/
traits.rs

1//! Shared traits for faucet sources and sinks.
2
3use 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/// A source fetches records from an external system.
11#[async_trait]
12pub trait Source: Send + Sync {
13    /// Primary fetch method. Receives context from a parent source's records.
14    ///
15    /// An empty context map means this is a root source (no parent).
16    /// Connectors that support being a child should use
17    /// [`substitute_context()`](crate::util::substitute_context) to resolve
18    /// `{placeholder}` tokens in their URL path, query parameters, headers,
19    /// or body. Connectors that don't need parent context ignore the map.
20    async fn fetch_with_context(
21        &self,
22        context: &std::collections::HashMap<String, Value>,
23    ) -> Result<Vec<Value>, FaucetError>;
24
25    /// Convenience: fetch with no parent context.
26    async fn fetch_all(&self) -> Result<Vec<Value>, FaucetError> {
27        self.fetch_with_context(&std::collections::HashMap::new())
28            .await
29    }
30
31    /// Incremental fetch with parent context support.
32    ///
33    /// Returns the records and an optional bookmark value for incremental
34    /// replication. The default delegates to `fetch_with_context` and
35    /// returns `None` for the bookmark.
36    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    /// Convenience: incremental fetch with no parent context.
45    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    /// Stream records page-by-page so the pipeline can write to the sink as
51    /// pages arrive instead of buffering the full result set.
52    ///
53    /// `batch_size` is the *hint* the pipeline passes down; sources are free
54    /// to use a larger or smaller native chunk (e.g. one page per HTTP
55    /// response, one row-group per Parquet file) but should approximate it
56    /// where feasible. The special value `batch_size = 0` means "do not
57    /// batch — emit the entire result set in a single page." Sources that
58    /// stream natively should treat `0` as "skip the chunking layer and
59    /// yield one page after the underlying read completes" (useful for
60    /// small lookup tables or for sinks like SQL `COPY` / BigQuery load
61    /// jobs that prefer one large request).
62    ///
63    /// The default implementation fetches the full result set via
64    /// [`fetch_with_context_incremental`](Self::fetch_with_context_incremental)
65    /// and chunks it in memory by `batch_size`. The bookmark (when present)
66    /// is attached to the *final* page so the pipeline only persists after
67    /// the entire fetch has been written. Sources that can stream natively
68    /// override this method and may emit per-page bookmarks (e.g. CDC).
69    ///
70    /// An empty result with a `Some(bookmark)` still yields one empty page
71    /// carrying the bookmark, so incremental runs that produce no records
72    /// still advance their checkpoint.
73    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            // batch_size == 0 means "no batching" — emit all records as one
84            // page. Otherwise chunk into pages of size `batch_size`.
85            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    /// Return a JSON Schema describing the configuration this source accepts.
119    fn config_schema(&self) -> Value {
120        serde_json::json!({"type": "object", "properties": {}})
121    }
122
123    /// Stable key under which this source's incremental-replication bookmark
124    /// should be persisted in a [`StateStore`](crate::state::StateStore).
125    ///
126    /// Returning `Some(key)` opts this source into resumable runs: when the
127    /// pipeline is configured with a state store via
128    /// [`Pipeline::with_state_store`](crate::Pipeline::with_state_store), it
129    /// reads the bookmark at `key` before fetching and writes the new
130    /// bookmark back only after the sink confirms the batch was written.
131    ///
132    /// The default returns `None`, meaning the source is not persisted.
133    /// Keys must satisfy [`validate_state_key`](crate::state::validate_state_key).
134    fn state_key(&self) -> Option<String> {
135        None
136    }
137
138    /// Apply a bookmark loaded from a [`StateStore`](crate::state::StateStore)
139    /// as this run's starting point.
140    ///
141    /// The default implementation ignores the value, which keeps existing
142    /// sources backwards-compatible. Sources that support incremental
143    /// replication override this — typically by storing the value behind
144    /// interior mutability and consulting it inside
145    /// `fetch_with_context_incremental`.
146    async fn apply_start_bookmark(&self, _bookmark: Value) -> Result<(), FaucetError> {
147        Ok(())
148    }
149
150    /// Capture the source's current replication position **without consuming
151    /// any changes**, ensuring any server-side resource (e.g. a logical
152    /// replication slot) needed to later resume from that position exists.
153    ///
154    /// Returns the position as a bookmark [`Value`] — the same shape
155    /// [`apply_start_bookmark`](Self::apply_start_bookmark) accepts — or `None`
156    /// if this source does not support position capture.
157    ///
158    /// Used by the snapshot→CDC replication orchestrator (`faucet replicate`)
159    /// to anchor the CDC stream at-or-before the bulk snapshot's read point so
160    /// the handoff has no gap. The default returns `None`.
161    async fn capture_resume_position(&self) -> Result<Option<Value>, FaucetError> {
162        Ok(None)
163    }
164
165    /// Whether this source **deterministically replays** the same page sequence
166    /// from a given bookmark — the requirement for exactly-once delivery (a
167    /// non-deterministic replay could cause the pipeline to skip a page whose
168    /// contents differ from the one already committed). Default: `false`.
169    ///
170    /// Sources with a durable monotonic position and per-page bookmarks (CDC)
171    /// override this to return `true`. The pipeline rejects
172    /// `DeliveryMode::ExactlyOnce` against a source that returns `false`.
173    fn supports_exactly_once(&self) -> bool {
174        false
175    }
176
177    /// Whether this source can split its work into independent shards for
178    /// clustered (Mode B) execution. Default: `false` (single whole-dataset
179    /// shard). Sources with a natural partition (object-store prefixes, table
180    /// primary-key ranges) override this to `true` and implement
181    /// [`enumerate_shards`](Self::enumerate_shards) +
182    /// [`apply_shard`](Self::apply_shard).
183    fn is_shardable(&self) -> bool {
184        false
185    }
186
187    /// Enumerate the shards this source splits into, aiming for roughly `target`
188    /// of them (a hint — the source may return fewer, e.g. when the dataset is
189    /// small, or one per natural partition regardless of `target`).
190    ///
191    /// Called **once per run** by the cluster coordinator; enumeration must be
192    /// deterministic enough that re-enumeration yields a compatible set (stable
193    /// shard ids), since it may run on more than one instance and is reconciled
194    /// by idempotent insert. May perform read-only I/O (a `LIST`, a `MIN/MAX`
195    /// query). The default returns a single whole-dataset shard
196    /// ([`ShardSpec::whole`](crate::ShardSpec::whole)), preserving today's
197    /// single-worker behavior.
198    async fn enumerate_shards(
199        &self,
200        _target: usize,
201    ) -> Result<Vec<crate::shard::ShardSpec>, FaucetError> {
202        Ok(vec![crate::shard::ShardSpec::whole()])
203    }
204
205    /// Narrow this source instance to a single shard before streaming.
206    ///
207    /// Called on the worker that claims `shard`, after construction and before
208    /// any `stream_pages` call. Like [`apply_start_bookmark`](Self::apply_start_bookmark)
209    /// this takes `&self` and is expected to record the shard behind interior
210    /// mutability (the source consults it when building its query / listing).
211    /// The default ignores the shard — a non-shardable source only ever receives
212    /// [`ShardSpec::whole`](crate::ShardSpec::whole), so ignoring it streams the
213    /// whole dataset. Implementations should accept the whole shard as a no-op.
214    async fn apply_shard(&self, _shard: &crate::shard::ShardSpec) -> Result<(), FaucetError> {
215        Ok(())
216    }
217
218    /// Stable identifier used as the `connector` label on metrics and the
219    /// `connector` attribute on spans. Defaults to the final segment of
220    /// `std::any::type_name::<Self>()`, e.g. `"RestSource"`. Built-in
221    /// connectors override with a short, friendly snake_case name (e.g.
222    /// `"rest"`). Must return a non-empty string; observability decorators
223    /// fall back to `"unknown"` in release builds if it is empty (and
224    /// `debug_assert!` in debug builds).
225    fn connector_name(&self) -> &'static str {
226        crate::observability::strip_type_name(std::any::type_name::<Self>())
227    }
228
229    /// Logical dataset identity for lineage emission, following OpenLineage
230    /// naming conventions (<https://openlineage.io/docs/spec/naming>).
231    ///
232    /// The default returns `"<connector_name>://unknown"`. Built-in connectors
233    /// override with a credential-free URI derived from their config. Strip any
234    /// credentials with [`redact_uri_credentials`](crate::redact_uri_credentials).
235    /// Informational metadata only — never used for I/O.
236    fn dataset_uri(&self) -> String {
237        format!("{}://unknown", self.connector_name())
238    }
239
240    /// Run a fast, non-mutating preflight probe (used by `faucet doctor`).
241    ///
242    /// The default pulls a **single page** via
243    /// [`stream_pages`](Self::stream_pages) and reports success/failure — it
244    /// exercises the real read path (DNS, TLS, auth, the first request, the
245    /// first-record decode) but never paginates the full dataset and never
246    /// repeats. The page stream is dropped immediately after the first page.
247    ///
248    /// Sources whose first page *blocks* waiting for inbound data (webhook,
249    /// websocket) or has *side effects* (CDC consuming WAL) override this with a
250    /// cheaper, side-effect-free probe. Probe-level failures are returned as a
251    /// [`ProbeStatus::Fail`](crate::check::ProbeStatus) inside `Ok(report)`.
252    async fn check(
253        &self,
254        ctx: &crate::check::CheckContext,
255    ) -> Result<crate::check::CheckReport, FaucetError> {
256        use crate::check::{CheckReport, Probe};
257        use futures::StreamExt;
258
259        let empty = std::collections::HashMap::new();
260        let start = std::time::Instant::now();
261        let mut pages = self.stream_pages(&empty, 1);
262        let probe = match tokio::time::timeout(ctx.timeout, pages.next()).await {
263            Err(_) => Probe::fail("read", start.elapsed(), "timed out fetching first page"),
264            Ok(None) | Ok(Some(Ok(_))) => Probe::pass("read", start.elapsed()),
265            Ok(Some(Err(e))) => Probe::fail("read", start.elapsed(), e.to_string()),
266        };
267        Ok(CheckReport::single(probe))
268    }
269}
270
271/// Per-row outcome from [`Sink::write_batch_partial`].
272///
273/// `Ok(())` — the row was durably written to the sink.
274/// `Err(_)` — the row failed; the pipeline will route it to the DLQ when
275/// one is configured.
276pub type RowOutcome = Result<(), FaucetError>;
277
278/// A sink writes records to an external system.
279#[async_trait]
280pub trait Sink: Send + Sync {
281    /// Write a batch of records to the destination.
282    ///
283    /// Returns the number of records successfully written.
284    async fn write_batch(&self, records: &[Value]) -> Result<usize, FaucetError>;
285
286    /// Flush any buffered data to the destination.
287    ///
288    /// The default implementation is a no-op (suitable for sinks that
289    /// write immediately in `write_batch`).
290    async fn flush(&self) -> Result<(), FaucetError> {
291        Ok(())
292    }
293
294    /// Write a batch and report per-row outcomes.
295    ///
296    /// Sinks whose underlying API exposes per-row results (BigQuery
297    /// `insertAll`, Elasticsearch `_bulk`) override this. The default
298    /// implementation delegates to [`Self::write_batch`] and maps a single success
299    /// onto a uniform all-`Ok(())` vector. An outer failure is bubbled up
300    /// unchanged so the pipeline's DLQ router can apply its `on_batch_error`
301    /// policy at a single decision point.
302    async fn write_batch_partial(&self, records: &[Value]) -> Result<Vec<RowOutcome>, FaucetError> {
303        self.write_batch(records).await?;
304        Ok(records.iter().map(|_| Ok(())).collect())
305    }
306
307    /// Whether this sink can durably commit a page's rows **and** a commit token
308    /// in a single atomic transaction. Default: `false` (at-least-once only).
309    ///
310    /// Only return `true` from a sink that genuinely commits both atomically —
311    /// see [`write_batch_idempotent`](Self::write_batch_idempotent). The pipeline
312    /// rejects `DeliveryMode::ExactlyOnce` against a sink that returns `false`.
313    fn supports_idempotent_writes(&self) -> bool {
314        false
315    }
316
317    /// Write modes this sink can apply. Default: append-only. Sinks that
318    /// implement key-based merge override this to include
319    /// [`WriteMode::Upsert`](crate::write_mode::WriteMode) /
320    /// [`WriteMode::Delete`](crate::write_mode::WriteMode). The CLI rejects a
321    /// configured mode that is not in this set at config-load time.
322    fn supported_write_modes(&self) -> &'static [crate::write_mode::WriteMode] {
323        &[crate::write_mode::WriteMode::Append]
324    }
325
326    /// The sink's live destination schema as an `infer_schema`-shaped object
327    /// (`{"type":"object","properties":{ <col>: <type-fragment>, … }}`), or
328    /// `None` for a schemaless sink or a target that does not exist yet.
329    ///
330    /// Used by the schema-drift policy to diff each page's shape against the
331    /// real destination. Default: `Ok(None)` (drift handling is inert).
332    async fn current_schema(&self) -> Result<Option<Value>, FaucetError> {
333        Ok(None)
334    }
335
336    /// Whether this sink can apply additive/widening DDL via
337    /// [`evolve_schema`](Self::evolve_schema). Default: `false`. The CLI rejects
338    /// `on_drift: evolve` against a sink that returns `false` at config-load.
339    fn supports_schema_evolution(&self) -> bool {
340        false
341    }
342
343    /// Apply an additive schema evolution (new columns, lossless widenings,
344    /// nullability relaxations) to the destination. MUST be idempotent
345    /// (`ADD COLUMN IF NOT EXISTS` semantics) so concurrent runs converge.
346    ///
347    /// Default: a typed "unsupported" error. Override only when the backend
348    /// supports in-place additive DDL (and return `true` from
349    /// `supports_schema_evolution`).
350    async fn evolve_schema(
351        &self,
352        evolution: &crate::drift::SchemaEvolution,
353    ) -> Result<(), FaucetError> {
354        let _ = evolution;
355        Err(FaucetError::Sink(format!(
356            "sink '{}' does not support schema evolution",
357            self.connector_name()
358        )))
359    }
360
361    /// Write `records` AND durably record `token` for `scope`, atomically.
362    ///
363    /// `scope` namespaces the watermark (the pipeline passes the per-row state
364    /// key, e.g. `"{name}::{row_id}"`). `token` is a monotonic, fixed-width
365    /// string (see [`format_token`](crate::format_token)).
366    ///
367    /// The default is **not** idempotent: it ignores the token and delegates to
368    /// [`write_batch`](Self::write_batch). Override only when the commit is
369    /// genuinely atomic (and return `true` from `supports_idempotent_writes`).
370    async fn write_batch_idempotent(
371        &self,
372        records: &[Value],
373        scope: &str,
374        token: &str,
375    ) -> Result<usize, FaucetError> {
376        let _ = (scope, token);
377        self.write_batch(records).await
378    }
379
380    /// The last token durably committed for `scope`, or `None` if this sink has
381    /// never committed under that scope. Default: `None`.
382    async fn last_committed_token(&self, scope: &str) -> Result<Option<String>, FaucetError> {
383        let _ = scope;
384        Ok(None)
385    }
386
387    /// Return a JSON Schema describing the configuration this sink accepts.
388    ///
389    /// The schema is auto-generated from the config struct using `schemars`.
390    /// Callers can inspect it to discover required fields, types, defaults,
391    /// and descriptions before constructing the sink.
392    ///
393    /// The default returns an empty object schema.
394    fn config_schema(&self) -> Value {
395        serde_json::json!({"type": "object", "properties": {}})
396    }
397
398    /// Stable identifier used as the `connector` label on metrics and the
399    /// `connector` attribute on spans. See `Source::connector_name`.
400    fn connector_name(&self) -> &'static str {
401        crate::observability::strip_type_name(std::any::type_name::<Self>())
402    }
403
404    /// Logical dataset identity for lineage emission, following OpenLineage
405    /// naming conventions (<https://openlineage.io/docs/spec/naming>).
406    ///
407    /// The default returns `"<connector_name>://unknown"`. Built-in connectors
408    /// override with a credential-free URI derived from their config. Strip any
409    /// credentials with [`redact_uri_credentials`](crate::redact_uri_credentials).
410    /// Informational metadata only — never used for I/O.
411    fn dataset_uri(&self) -> String {
412        format!("{}://unknown", self.connector_name())
413    }
414
415    /// Run a fast, non-mutating preflight probe (used by `faucet doctor`).
416    ///
417    /// Unlike sources, a sink has no non-mutating "first page" equivalent
418    /// (`write_batch` mutates the destination), so the default returns
419    /// [`CheckReport::not_implemented`](crate::check::CheckReport::not_implemented).
420    /// Built-in sinks override this with a connect / auth / metadata probe.
421    ///
422    /// The probe **MUST be idempotent and side-effect-free** — no inserts, no
423    /// residual rows or objects — and must never put credentials or connection
424    /// strings in a probe `reason`/`hint`.
425    async fn check(
426        &self,
427        _ctx: &crate::check::CheckContext,
428    ) -> Result<crate::check::CheckReport, FaucetError> {
429        Ok(crate::check::CheckReport::not_implemented())
430    }
431}
432
433#[cfg(test)]
434mod tests {
435    use super::*;
436    use serde_json::json;
437
438    // ── Mock Source ──────────────────────────────────────────────────────────
439
440    struct MockSource {
441        records: Vec<Value>,
442    }
443
444    #[async_trait]
445    impl Source for MockSource {
446        async fn fetch_with_context(
447            &self,
448            _context: &std::collections::HashMap<String, Value>,
449        ) -> Result<Vec<Value>, FaucetError> {
450            Ok(self.records.clone())
451        }
452    }
453
454    struct IncrementalSource {
455        records: Vec<Value>,
456        bookmark: Value,
457    }
458
459    #[async_trait]
460    impl Source for IncrementalSource {
461        async fn fetch_with_context(
462            &self,
463            _context: &std::collections::HashMap<String, Value>,
464        ) -> Result<Vec<Value>, FaucetError> {
465            Ok(self.records.clone())
466        }
467
468        async fn fetch_with_context_incremental(
469            &self,
470            _context: &std::collections::HashMap<String, Value>,
471        ) -> Result<(Vec<Value>, Option<Value>), FaucetError> {
472            Ok((self.records.clone(), Some(self.bookmark.clone())))
473        }
474    }
475
476    struct FailingSource;
477
478    #[async_trait]
479    impl Source for FailingSource {
480        async fn fetch_with_context(
481            &self,
482            _context: &std::collections::HashMap<String, Value>,
483        ) -> Result<Vec<Value>, FaucetError> {
484            Err(FaucetError::Auth("no credentials".into()))
485        }
486    }
487
488    // ── Mock Sink ───────────────────────────────────────────────────────────
489
490    struct MockSink {
491        written: std::sync::Mutex<Vec<Value>>,
492    }
493
494    impl MockSink {
495        fn new() -> Self {
496            Self {
497                written: std::sync::Mutex::new(Vec::new()),
498            }
499        }
500    }
501
502    #[async_trait]
503    impl Sink for MockSink {
504        async fn write_batch(&self, records: &[Value]) -> Result<usize, FaucetError> {
505            let mut w = self.written.lock().unwrap();
506            w.extend(records.iter().cloned());
507            Ok(records.len())
508        }
509    }
510
511    struct FailingSink;
512
513    #[async_trait]
514    impl Sink for FailingSink {
515        async fn write_batch(&self, _records: &[Value]) -> Result<usize, FaucetError> {
516            Err(FaucetError::Sink("write failed".into()))
517        }
518    }
519
520    // ── Source tests ────────────────────────────────────────────────────────
521
522    #[tokio::test]
523    async fn source_fetch_all_returns_records() {
524        let source = MockSource {
525            records: vec![json!({"id": 1}), json!({"id": 2})],
526        };
527        let records = source.fetch_all().await.unwrap();
528        assert_eq!(records.len(), 2);
529        assert_eq!(records[0]["id"], 1);
530    }
531
532    #[tokio::test]
533    async fn source_fetch_all_empty() {
534        let source = MockSource { records: vec![] };
535        let records = source.fetch_all().await.unwrap();
536        assert!(records.is_empty());
537    }
538
539    #[tokio::test]
540    async fn source_default_incremental_returns_none_bookmark() {
541        let source = MockSource {
542            records: vec![json!({"id": 1})],
543        };
544        let (records, bookmark) = source.fetch_all_incremental().await.unwrap();
545        assert_eq!(records.len(), 1);
546        assert!(bookmark.is_none());
547    }
548
549    #[tokio::test]
550    async fn source_custom_incremental_returns_bookmark() {
551        let source = IncrementalSource {
552            records: vec![json!({"id": 1})],
553            bookmark: json!("2024-12-01"),
554        };
555        let (records, bookmark) = source.fetch_all_incremental().await.unwrap();
556        assert_eq!(records.len(), 1);
557        assert_eq!(bookmark, Some(json!("2024-12-01")));
558    }
559
560    #[tokio::test]
561    async fn source_error_propagates() {
562        let source = FailingSource;
563        let result = source.fetch_all().await;
564        assert!(result.is_err());
565        assert!(matches!(result, Err(FaucetError::Auth(_))));
566    }
567
568    #[tokio::test]
569    async fn source_as_trait_object() {
570        let source: Box<dyn Source> = Box::new(MockSource {
571            records: vec![json!({"id": 42})],
572        });
573        let records = source.fetch_all().await.unwrap();
574        assert_eq!(records[0]["id"], 42);
575    }
576
577    // ── Sink tests ──────────────────────────────────────────────────────────
578
579    #[tokio::test]
580    async fn sink_write_batch_returns_count() {
581        let sink = MockSink::new();
582        let records = vec![json!({"id": 1}), json!({"id": 2}), json!({"id": 3})];
583        let count = sink.write_batch(&records).await.unwrap();
584        assert_eq!(count, 3);
585    }
586
587    #[tokio::test]
588    async fn sink_write_batch_empty() {
589        let sink = MockSink::new();
590        let count = sink.write_batch(&[]).await.unwrap();
591        assert_eq!(count, 0);
592    }
593
594    #[tokio::test]
595    async fn sink_accumulates_records() {
596        let sink = MockSink::new();
597        sink.write_batch(&[json!({"a": 1})]).await.unwrap();
598        sink.write_batch(&[json!({"b": 2})]).await.unwrap();
599        let written = sink.written.lock().unwrap();
600        assert_eq!(written.len(), 2);
601    }
602
603    #[tokio::test]
604    async fn sink_default_flush_is_noop() {
605        let sink = MockSink::new();
606        assert!(sink.flush().await.is_ok());
607    }
608
609    #[tokio::test]
610    async fn sink_error_propagates() {
611        let sink = FailingSink;
612        let result = sink.write_batch(&[json!({"id": 1})]).await;
613        assert!(result.is_err());
614        assert!(matches!(result, Err(FaucetError::Sink(_))));
615    }
616
617    #[tokio::test]
618    async fn sink_as_trait_object() {
619        let sink: Box<dyn Sink> = Box::new(MockSink::new());
620        let count = sink.write_batch(&[json!({"id": 1})]).await.unwrap();
621        assert_eq!(count, 1);
622    }
623
624    // ── stream_pages tests ──────────────────────────────────────────────────
625
626    use crate::pipeline::DEFAULT_BATCH_SIZE;
627    use futures::StreamExt;
628
629    #[tokio::test]
630    async fn default_stream_pages_chunks_records() {
631        let source = MockSource {
632            records: (0..5).map(|i| json!({"i": i})).collect(),
633        };
634        let ctx = std::collections::HashMap::new();
635        let mut pages = source.stream_pages(&ctx, 2);
636        let mut all = Vec::new();
637        while let Some(page) = pages.next().await {
638            all.push(page.unwrap());
639        }
640        // 5 records, batch_size=2 → pages of [2, 2, 1]
641        assert_eq!(all.len(), 3);
642        assert_eq!(all[0].records.len(), 2);
643        assert_eq!(all[1].records.len(), 2);
644        assert_eq!(all[2].records.len(), 1);
645    }
646
647    #[tokio::test]
648    async fn default_stream_pages_attaches_bookmark_to_final_page_only() {
649        let source = IncrementalSource {
650            records: (0..5).map(|i| json!({"i": i})).collect(),
651            bookmark: json!("v1"),
652        };
653        let ctx = std::collections::HashMap::new();
654        let mut pages = source.stream_pages(&ctx, 2);
655        let mut collected = Vec::new();
656        while let Some(page) = pages.next().await {
657            collected.push(page.unwrap());
658        }
659        assert_eq!(collected.len(), 3);
660        assert!(collected[0].bookmark.is_none());
661        assert!(collected[1].bookmark.is_none());
662        assert_eq!(collected[2].bookmark, Some(json!("v1")));
663    }
664
665    #[tokio::test]
666    async fn default_stream_pages_single_page_when_batch_size_exceeds_total() {
667        let source = MockSource {
668            records: vec![json!({"id": 1}), json!({"id": 2})],
669        };
670        let ctx = std::collections::HashMap::new();
671        let mut pages = source.stream_pages(&ctx, 100);
672        let mut collected = Vec::new();
673        while let Some(page) = pages.next().await {
674            collected.push(page.unwrap());
675        }
676        assert_eq!(collected.len(), 1);
677        assert_eq!(collected[0].records.len(), 2);
678    }
679
680    #[tokio::test]
681    async fn default_stream_pages_batch_size_zero_emits_single_page() {
682        // batch_size = 0 is the "no batching" sentinel — yields every record
683        // in one page regardless of total count.
684        let source = MockSource {
685            records: (0..50_000).map(|i| json!({"i": i})).collect(),
686        };
687        let ctx = std::collections::HashMap::new();
688        let mut pages = source.stream_pages(&ctx, 0);
689        let mut collected = Vec::new();
690        while let Some(page) = pages.next().await {
691            collected.push(page.unwrap());
692        }
693        assert_eq!(
694            collected.len(),
695            1,
696            "batch_size=0 must emit exactly one page"
697        );
698        assert_eq!(collected[0].records.len(), 50_000);
699    }
700
701    #[tokio::test]
702    async fn default_stream_pages_batch_size_zero_attaches_bookmark_to_sole_page() {
703        let source = IncrementalSource {
704            records: (0..3).map(|i| json!({"i": i})).collect(),
705            bookmark: json!("v1"),
706        };
707        let ctx = std::collections::HashMap::new();
708        let mut pages = source.stream_pages(&ctx, 0);
709        let page = pages.next().await.unwrap().unwrap();
710        assert_eq!(page.records.len(), 3);
711        assert_eq!(page.bookmark, Some(json!("v1")));
712        assert!(pages.next().await.is_none());
713    }
714
715    #[tokio::test]
716    async fn default_stream_pages_empty_source_yields_no_pages() {
717        let source = MockSource { records: vec![] };
718        let ctx = std::collections::HashMap::new();
719        let mut pages = source.stream_pages(&ctx, DEFAULT_BATCH_SIZE);
720        assert!(pages.next().await.is_none());
721    }
722
723    #[tokio::test]
724    async fn default_stream_pages_empty_source_with_bookmark_yields_single_empty_page() {
725        let source = IncrementalSource {
726            records: vec![],
727            bookmark: json!("v0"),
728        };
729        let ctx = std::collections::HashMap::new();
730        let mut pages = source.stream_pages(&ctx, DEFAULT_BATCH_SIZE);
731        let mut collected = Vec::new();
732        while let Some(page) = pages.next().await {
733            collected.push(page.unwrap());
734        }
735        // One empty-records page that carries the bookmark, so the pipeline
736        // still persists progress on otherwise-empty incremental runs.
737        assert_eq!(collected.len(), 1);
738        assert!(collected[0].records.is_empty());
739        assert_eq!(collected[0].bookmark, Some(json!("v0")));
740    }
741
742    #[tokio::test]
743    async fn default_stream_pages_propagates_fetch_errors() {
744        let source = FailingSource;
745        let ctx = std::collections::HashMap::new();
746        let mut pages = source.stream_pages(&ctx, DEFAULT_BATCH_SIZE);
747        let first = pages.next().await.unwrap();
748        assert!(matches!(first, Err(FaucetError::Auth(_))));
749    }
750
751    #[test]
752    fn source_default_connector_name_is_stripped_type_name() {
753        // MockSource lives at `faucet_core::traits::tests::MockSource`; the
754        // stripped type_name yields the trailing segment.
755        let source = MockSource { records: vec![] };
756        assert_eq!(source.connector_name(), "MockSource");
757    }
758
759    #[test]
760    fn sink_default_connector_name_is_stripped_type_name() {
761        let sink = MockSink::new();
762        assert_eq!(sink.connector_name(), "MockSink");
763    }
764
765    #[test]
766    fn source_default_dataset_uri_uses_connector_name() {
767        let source = MockSource { records: vec![] };
768        assert_eq!(source.dataset_uri(), "MockSource://unknown");
769    }
770
771    #[test]
772    fn sink_default_dataset_uri_uses_connector_name() {
773        let sink = MockSink::new();
774        assert_eq!(sink.dataset_uri(), "MockSink://unknown");
775    }
776
777    // ── write_batch_partial tests ───────────────────────────────────────────
778
779    #[tokio::test]
780    async fn default_write_batch_partial_success_returns_all_ok() {
781        let sink = MockSink::new();
782        let records = vec![json!({"id": 1}), json!({"id": 2}), json!({"id": 3})];
783        let outcomes = sink.write_batch_partial(&records).await.unwrap();
784        assert_eq!(outcomes.len(), 3);
785        assert!(outcomes.iter().all(|o| o.is_ok()));
786        assert_eq!(sink.written.lock().unwrap().len(), 3);
787    }
788
789    #[tokio::test]
790    async fn default_write_batch_partial_bubbles_outer_err() {
791        let sink = FailingSink;
792        let records = vec![json!({"id": 1}), json!({"id": 2})];
793        let result = sink.write_batch_partial(&records).await;
794        assert!(matches!(result, Err(FaucetError::Sink(_))));
795    }
796
797    #[tokio::test]
798    async fn default_write_batch_partial_empty_returns_empty_vec() {
799        let sink = MockSink::new();
800        let outcomes = sink.write_batch_partial(&[]).await.unwrap();
801        assert!(outcomes.is_empty());
802    }
803
804    #[tokio::test]
805    async fn default_write_batch_partial_callable_through_trait_object() {
806        let sink: Box<dyn Sink> = Box::new(MockSink::new());
807        let records = vec![json!({"id": 1}), json!({"id": 2})];
808        let outcomes = sink.write_batch_partial(&records).await.unwrap();
809        assert_eq!(outcomes.len(), 2);
810        assert!(outcomes.iter().all(|o| o.is_ok()));
811    }
812
813    // ── check() tests ─────────────────────────────────────────────────────────
814
815    #[tokio::test]
816    async fn source_default_check_pulls_first_page_and_passes() {
817        let source = MockSource {
818            records: vec![json!({"id": 1}), json!({"id": 2})],
819        };
820        let report = source
821            .check(&crate::check::CheckContext::default())
822            .await
823            .unwrap();
824        assert_eq!(report.failed_count(), 0);
825        assert!(
826            report
827                .probes
828                .iter()
829                .any(|p| p.name == "read" && matches!(p.status, crate::check::ProbeStatus::Pass))
830        );
831    }
832
833    #[tokio::test]
834    async fn source_default_check_passes_on_empty_source() {
835        let source = MockSource { records: vec![] };
836        let report = source
837            .check(&crate::check::CheckContext::default())
838            .await
839            .unwrap();
840        // Reachable but empty is still a healthy source.
841        assert_eq!(report.failed_count(), 0);
842    }
843
844    #[tokio::test]
845    async fn source_default_check_fails_when_fetch_errors() {
846        let source = FailingSource;
847        let report = source
848            .check(&crate::check::CheckContext::default())
849            .await
850            .unwrap();
851        assert_eq!(report.failed_count(), 1);
852        assert!(report.probes.iter().any(
853            |p| p.name == "read" && matches!(p.status, crate::check::ProbeStatus::Fail { .. })
854        ));
855    }
856
857    #[tokio::test]
858    async fn sink_default_check_is_not_implemented_skip() {
859        let sink = MockSink::new();
860        let report = sink
861            .check(&crate::check::CheckContext::default())
862            .await
863            .unwrap();
864        assert_eq!(report.probes.len(), 1);
865        assert!(matches!(
866            report.probes[0].status,
867            crate::check::ProbeStatus::Skip { .. }
868        ));
869    }
870
871    #[tokio::test]
872    async fn source_check_callable_through_trait_object() {
873        let source: Box<dyn Source> = Box::new(MockSource {
874            records: vec![json!({"id": 1})],
875        });
876        let report = source
877            .check(&crate::check::CheckContext::default())
878            .await
879            .unwrap();
880        assert_eq!(report.failed_count(), 0);
881    }
882
883    // ── idempotent-write / exactly-once capability tests ──────────────────────
884
885    #[tokio::test]
886    async fn sink_default_is_not_idempotent() {
887        let sink = MockSink::new();
888        assert!(!sink.supports_idempotent_writes());
889        // Default write_batch_idempotent ignores the token and delegates.
890        let n = sink
891            .write_batch_idempotent(&[json!({"id": 1})], "scope::a", "00000000000000000001")
892            .await
893            .unwrap();
894        assert_eq!(n, 1);
895        assert_eq!(sink.last_committed_token("scope::a").await.unwrap(), None);
896        assert_eq!(sink.written.lock().unwrap().len(), 1);
897    }
898
899    #[test]
900    fn source_default_does_not_support_exactly_once() {
901        let source = MockSource { records: vec![] };
902        assert!(!source.supports_exactly_once());
903    }
904
905    #[test]
906    fn sink_default_supported_write_modes_is_append_only() {
907        use crate::write_mode::WriteMode;
908        let sink = MockSink::new();
909        assert_eq!(sink.supported_write_modes(), &[WriteMode::Append]);
910    }
911
912    #[test]
913    fn supported_write_modes_callable_through_trait_object() {
914        use crate::write_mode::WriteMode;
915        let sink: Box<dyn Sink> = Box::new(MockSink::new());
916        assert!(sink.supported_write_modes().contains(&WriteMode::Append));
917    }
918
919    #[tokio::test]
920    async fn sink_default_current_schema_is_none() {
921        let sink = MockSink::new();
922        assert_eq!(sink.current_schema().await.unwrap(), None);
923    }
924
925    #[test]
926    fn sink_default_does_not_support_schema_evolution() {
927        let sink = MockSink::new();
928        assert!(!sink.supports_schema_evolution());
929    }
930
931    #[tokio::test]
932    async fn sink_default_evolve_schema_is_unsupported_error() {
933        let sink = MockSink::new();
934        let evo = crate::drift::SchemaEvolution::default();
935        let err = sink.evolve_schema(&evo).await.unwrap_err();
936        assert!(matches!(err, FaucetError::Sink(_)));
937        assert!(err.to_string().contains("schema evolution"));
938    }
939
940    #[tokio::test]
941    async fn source_default_capture_resume_position_is_none() {
942        let source = MockSource { records: vec![] };
943        assert_eq!(source.capture_resume_position().await.unwrap(), None);
944    }
945
946    #[tokio::test]
947    async fn capture_resume_position_callable_through_trait_object() {
948        let source: Box<dyn Source> = Box::new(MockSource { records: vec![] });
949        assert!(source.capture_resume_position().await.unwrap().is_none());
950    }
951
952    #[tokio::test]
953    async fn source_default_is_not_shardable() {
954        let source: Box<dyn Source> = Box::new(MockSource { records: vec![] });
955        assert!(!source.is_shardable());
956    }
957
958    #[tokio::test]
959    async fn source_default_enumerates_single_whole_shard() {
960        // A non-shardable source enumerates to exactly one whole-dataset shard,
961        // regardless of the requested target — preserving single-worker behavior.
962        let source: Box<dyn Source> = Box::new(MockSource { records: vec![] });
963        let shards = source.enumerate_shards(8).await.unwrap();
964        assert_eq!(shards.len(), 1);
965        assert!(shards[0].is_whole());
966    }
967
968    #[tokio::test]
969    async fn source_default_apply_shard_is_noop() {
970        let source: Box<dyn Source> = Box::new(MockSource { records: vec![] });
971        // Applying the whole shard is a no-op and must not error.
972        source
973            .apply_shard(&crate::shard::ShardSpec::whole())
974            .await
975            .unwrap();
976    }
977}