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 the atomic-watermark
167    /// effectively-once path (a non-deterministic replay could cause the pipeline
168    /// to skip a page whose contents differ from the one already committed).
169    /// Default: `false`.
170    ///
171    /// Sources with a durable monotonic position and per-page bookmarks (CDC)
172    /// override this to return `true`. The pipeline rejects
173    /// `DeliveryMode::ExactlyOnce` against a source that returns `false`.
174    fn supports_exactly_once(&self) -> bool {
175        false
176    }
177
178    /// The typed replay capability this source advertises — see
179    /// [`ReplayGuarantee`](crate::ReplayGuarantee).
180    ///
181    /// The default derives from [`supports_exactly_once`](Self::supports_exactly_once)
182    /// (the boolean stays the back-compat primitive: existing connectors that
183    /// override only the boolean automatically advertise `Deterministic`
184    /// here). Override this directly only to *diverge* from the boolean —
185    /// there is currently no reason to.
186    fn replay_guarantee(&self) -> crate::idempotency::ReplayGuarantee {
187        if self.supports_exactly_once() {
188            crate::idempotency::ReplayGuarantee::Deterministic
189        } else {
190            crate::idempotency::ReplayGuarantee::NonDeterministic
191        }
192    }
193
194    /// Whether this source can split its work into independent shards for
195    /// clustered (Mode B) execution. Default: `false` (single whole-dataset
196    /// shard). Sources with a natural partition (object-store prefixes, table
197    /// primary-key ranges) override this to `true` and implement
198    /// [`enumerate_shards`](Self::enumerate_shards) +
199    /// [`apply_shard`](Self::apply_shard).
200    fn is_shardable(&self) -> bool {
201        false
202    }
203
204    /// Enumerate the shards this source splits into, aiming for roughly `target`
205    /// of them (a hint — the source may return fewer, e.g. when the dataset is
206    /// small, or one per natural partition regardless of `target`).
207    ///
208    /// Called **once per run** by the cluster coordinator; enumeration must be
209    /// deterministic enough that re-enumeration yields a compatible set (stable
210    /// shard ids), since it may run on more than one instance and is reconciled
211    /// by idempotent insert. May perform read-only I/O (a `LIST`, a `MIN/MAX`
212    /// query). The default returns a single whole-dataset shard
213    /// ([`ShardSpec::whole`](crate::ShardSpec::whole)), preserving today's
214    /// single-worker behavior.
215    async fn enumerate_shards(
216        &self,
217        _target: usize,
218    ) -> Result<Vec<crate::shard::ShardSpec>, FaucetError> {
219        Ok(vec![crate::shard::ShardSpec::whole()])
220    }
221
222    /// Narrow this source instance to a single shard before streaming.
223    ///
224    /// Called on the worker that claims `shard`, after construction and before
225    /// any `stream_pages` call. Like [`apply_start_bookmark`](Self::apply_start_bookmark)
226    /// this takes `&self` and is expected to record the shard behind interior
227    /// mutability (the source consults it when building its query / listing).
228    /// The default ignores the shard — a non-shardable source only ever receives
229    /// [`ShardSpec::whole`](crate::ShardSpec::whole), so ignoring it streams the
230    /// whole dataset. Implementations should accept the whole shard as a no-op.
231    async fn apply_shard(&self, _shard: &crate::shard::ShardSpec) -> Result<(), FaucetError> {
232        Ok(())
233    }
234
235    /// Whether this source can enumerate the datasets behind its connection
236    /// via [`discover`](Self::discover). Default: `false`. Sources backed by
237    /// an introspectable catalog (database `information_schema`, MongoDB
238    /// collections, Elasticsearch indices, object-store prefixes) override
239    /// this to `true`.
240    fn supports_discover(&self) -> bool {
241        false
242    }
243
244    /// Enumerate the datasets living behind this source's connection — one
245    /// [`DatasetDescriptor`](crate::discover::DatasetDescriptor) per table /
246    /// collection / index / prefix, each carrying a partial config override
247    /// that selects it (used by `faucet discover` to scaffold one matrix row
248    /// per dataset).
249    ///
250    /// Must be **read-only and cheap**: catalog metadata queries and listings
251    /// only, never a data scan. Descriptors must never embed credentials.
252    /// The default returns a typed "unsupported" error; override it (and
253    /// return `true` from [`supports_discover`](Self::supports_discover))
254    /// only for sources with a real catalog to introspect.
255    async fn discover(&self) -> Result<Vec<crate::discover::DatasetDescriptor>, FaucetError> {
256        Err(FaucetError::Source(format!(
257            "source '{}' does not support dataset discovery",
258            self.connector_name()
259        )))
260    }
261
262    /// Stable identifier used as the `connector` label on metrics and the
263    /// `connector` attribute on spans. Defaults to the final segment of
264    /// `std::any::type_name::<Self>()`, e.g. `"RestSource"`. Built-in
265    /// connectors override with a short, friendly snake_case name (e.g.
266    /// `"rest"`). Must return a non-empty string; observability decorators
267    /// fall back to `"unknown"` in release builds if it is empty (and
268    /// `debug_assert!` in debug builds).
269    fn connector_name(&self) -> &'static str {
270        crate::observability::strip_type_name(std::any::type_name::<Self>())
271    }
272
273    /// Logical dataset identity for lineage emission, following OpenLineage
274    /// naming conventions (<https://openlineage.io/docs/spec/naming>).
275    ///
276    /// The default returns `"<connector_name>://unknown"`. Built-in connectors
277    /// override with a credential-free URI derived from their config. Strip any
278    /// credentials with [`redact_uri_credentials`](crate::redact_uri_credentials).
279    /// Informational metadata only — never used for I/O.
280    fn dataset_uri(&self) -> String {
281        format!("{}://unknown", self.connector_name())
282    }
283
284    /// Run a fast, non-mutating preflight probe (used by `faucet doctor`).
285    ///
286    /// The default pulls a **single page** via
287    /// [`stream_pages`](Self::stream_pages) and reports success/failure — it
288    /// exercises the real read path (DNS, TLS, auth, the first request, the
289    /// first-record decode) but never paginates the full dataset and never
290    /// repeats. The page stream is dropped immediately after the first page.
291    ///
292    /// Sources whose first page *blocks* waiting for inbound data (webhook,
293    /// websocket) or has *side effects* (CDC consuming WAL) override this with a
294    /// cheaper, side-effect-free probe. Probe-level failures are returned as a
295    /// [`ProbeStatus::Fail`](crate::check::ProbeStatus) inside `Ok(report)`.
296    async fn check(
297        &self,
298        ctx: &crate::check::CheckContext,
299    ) -> Result<crate::check::CheckReport, FaucetError> {
300        use crate::check::{CheckReport, Probe};
301        use futures::StreamExt;
302
303        let empty = std::collections::HashMap::new();
304        let start = std::time::Instant::now();
305        let mut pages = self.stream_pages(&empty, 1);
306        let probe = match tokio::time::timeout(ctx.timeout, pages.next()).await {
307            Err(_) => Probe::fail("read", start.elapsed(), "timed out fetching first page"),
308            Ok(None) | Ok(Some(Ok(_))) => Probe::pass("read", start.elapsed()),
309            Ok(Some(Err(e))) => Probe::fail("read", start.elapsed(), e.to_string()),
310        };
311        Ok(CheckReport::single(probe))
312    }
313}
314
315/// Per-row outcome from [`Sink::write_batch_partial`].
316///
317/// `Ok(())` — the row was durably written to the sink.
318/// `Err(_)` — the row failed; the pipeline will route it to the DLQ when
319/// one is configured.
320pub type RowOutcome = Result<(), FaucetError>;
321
322/// A sink writes records to an external system.
323#[async_trait]
324pub trait Sink: Send + Sync {
325    /// Write a batch of records to the destination.
326    ///
327    /// Returns the number of records successfully written.
328    async fn write_batch(&self, records: &[Value]) -> Result<usize, FaucetError>;
329
330    /// Flush any buffered data to the destination.
331    ///
332    /// The default implementation is a no-op (suitable for sinks that
333    /// write immediately in `write_batch`).
334    async fn flush(&self) -> Result<(), FaucetError> {
335        Ok(())
336    }
337
338    /// Write a batch and report per-row outcomes.
339    ///
340    /// Sinks whose underlying API exposes per-row results (BigQuery
341    /// `insertAll`, Elasticsearch `_bulk`) override this. The default
342    /// implementation delegates to [`Self::write_batch`] and maps a single success
343    /// onto a uniform all-`Ok(())` vector. An outer failure is bubbled up
344    /// unchanged so the pipeline's DLQ router can apply its `on_batch_error`
345    /// policy at a single decision point.
346    async fn write_batch_partial(&self, records: &[Value]) -> Result<Vec<RowOutcome>, FaucetError> {
347        self.write_batch(records).await?;
348        Ok(records.iter().map(|_| Ok(())).collect())
349    }
350
351    /// Whether this sink can durably commit a page's rows **and** a commit token
352    /// in a single atomic transaction. Default: `false` (at-least-once only).
353    ///
354    /// Only return `true` from a sink that genuinely commits both atomically —
355    /// see [`write_batch_idempotent`](Self::write_batch_idempotent). The pipeline
356    /// rejects `DeliveryMode::ExactlyOnce` against a sink that returns `false`.
357    fn supports_idempotent_writes(&self) -> bool {
358        false
359    }
360
361    /// The strongest delivery guarantee this sink can uphold — see
362    /// [`SinkGuarantee`](crate::SinkGuarantee).
363    ///
364    /// The default derives from the two back-compat primitives:
365    /// [`supports_idempotent_writes`](Self::supports_idempotent_writes) →
366    /// `AtomicWatermark`, else an upsert-capable
367    /// [`supported_write_modes`](Self::supported_write_modes) → `KeyedUpsert`,
368    /// else `AtLeastOnce`. Existing connectors that override only the
369    /// primitives automatically advertise the right capability here.
370    fn sink_guarantee(&self) -> crate::idempotency::SinkGuarantee {
371        if self.supports_idempotent_writes() {
372            crate::idempotency::SinkGuarantee::AtomicWatermark
373        } else if self
374            .supported_write_modes()
375            .contains(&crate::write_mode::WriteMode::Upsert)
376        {
377            crate::idempotency::SinkGuarantee::KeyedUpsert
378        } else {
379            crate::idempotency::SinkGuarantee::AtLeastOnce
380        }
381    }
382
383    /// Whether this sink instance is **configured** to dedup by key — i.e.
384    /// `write_mode: upsert` (or `delete`) with a non-empty `key`, so
385    /// re-applying a record with the same key converges instead of
386    /// duplicating. Default: `false`.
387    ///
388    /// Distinct from [`sink_guarantee`](Self::sink_guarantee) (capability):
389    /// this reflects the *live config*. Sinks that flatten a
390    /// [`WriteSpec`](crate::write_mode::WriteSpec) into their config override
391    /// it as `self.config.write.dedups_by_key()`. The pipeline consults it to
392    /// derive the keyed-upsert effectively-once mechanism at run time.
393    fn dedups_by_key(&self) -> bool {
394        false
395    }
396
397    /// Write modes this sink can apply. Default: append-only. Sinks that
398    /// implement key-based merge override this to include
399    /// [`WriteMode::Upsert`](crate::write_mode::WriteMode) /
400    /// [`WriteMode::Delete`](crate::write_mode::WriteMode). The CLI rejects a
401    /// configured mode that is not in this set at config-load time.
402    fn supported_write_modes(&self) -> &'static [crate::write_mode::WriteMode] {
403        &[crate::write_mode::WriteMode::Append]
404    }
405
406    /// The sink's live destination schema as an `infer_schema`-shaped object
407    /// (`{"type":"object","properties":{ <col>: <type-fragment>, … }}`), or
408    /// `None` for a schemaless sink or a target that does not exist yet.
409    ///
410    /// Used by the schema-drift policy to diff each page's shape against the
411    /// real destination. Default: `Ok(None)` (drift handling is inert).
412    async fn current_schema(&self) -> Result<Option<Value>, FaucetError> {
413        Ok(None)
414    }
415
416    /// Whether this sink can apply additive/widening DDL via
417    /// [`evolve_schema`](Self::evolve_schema). Default: `false`. The CLI rejects
418    /// `on_drift: evolve` against a sink that returns `false` at config-load.
419    fn supports_schema_evolution(&self) -> bool {
420        false
421    }
422
423    /// Apply an additive schema evolution (new columns, lossless widenings,
424    /// nullability relaxations) to the destination. MUST be idempotent
425    /// (`ADD COLUMN IF NOT EXISTS` semantics) so concurrent runs converge.
426    ///
427    /// Default: a typed "unsupported" error. Override only when the backend
428    /// supports in-place additive DDL (and return `true` from
429    /// `supports_schema_evolution`).
430    async fn evolve_schema(
431        &self,
432        evolution: &crate::drift::SchemaEvolution,
433    ) -> Result<(), FaucetError> {
434        let _ = evolution;
435        Err(FaucetError::Sink(format!(
436            "sink '{}' does not support schema evolution",
437            self.connector_name()
438        )))
439    }
440
441    /// Write `records` AND durably record `token` for `scope`, atomically.
442    ///
443    /// `scope` namespaces the watermark (the pipeline passes the per-row state
444    /// key, e.g. `"{name}::{row_id}"`). `token` is a monotonic, fixed-width
445    /// string (see [`format_token`](crate::format_token)).
446    ///
447    /// The default is **not** idempotent: it ignores the token and delegates to
448    /// [`write_batch`](Self::write_batch). Override only when the commit is
449    /// genuinely atomic (and return `true` from `supports_idempotent_writes`).
450    async fn write_batch_idempotent(
451        &self,
452        records: &[Value],
453        scope: &str,
454        token: &str,
455    ) -> Result<usize, FaucetError> {
456        let _ = (scope, token);
457        self.write_batch(records).await
458    }
459
460    /// The last token durably committed for `scope`, or `None` if this sink has
461    /// never committed under that scope. Default: `None`.
462    async fn last_committed_token(&self, scope: &str) -> Result<Option<String>, FaucetError> {
463        let _ = scope;
464        Ok(None)
465    }
466
467    /// Return a JSON Schema describing the configuration this sink accepts.
468    ///
469    /// The schema is auto-generated from the config struct using `schemars`.
470    /// Callers can inspect it to discover required fields, types, defaults,
471    /// and descriptions before constructing the sink.
472    ///
473    /// The default returns an empty object schema.
474    fn config_schema(&self) -> Value {
475        serde_json::json!({"type": "object", "properties": {}})
476    }
477
478    /// Stable identifier used as the `connector` label on metrics and the
479    /// `connector` attribute on spans. See `Source::connector_name`.
480    fn connector_name(&self) -> &'static str {
481        crate::observability::strip_type_name(std::any::type_name::<Self>())
482    }
483
484    /// Logical dataset identity for lineage emission, following OpenLineage
485    /// naming conventions (<https://openlineage.io/docs/spec/naming>).
486    ///
487    /// The default returns `"<connector_name>://unknown"`. Built-in connectors
488    /// override with a credential-free URI derived from their config. Strip any
489    /// credentials with [`redact_uri_credentials`](crate::redact_uri_credentials).
490    /// Informational metadata only — never used for I/O.
491    fn dataset_uri(&self) -> String {
492        format!("{}://unknown", self.connector_name())
493    }
494
495    /// Run a fast, non-mutating preflight probe (used by `faucet doctor`).
496    ///
497    /// Unlike sources, a sink has no non-mutating "first page" equivalent
498    /// (`write_batch` mutates the destination), so the default returns
499    /// [`CheckReport::not_implemented`](crate::check::CheckReport::not_implemented).
500    /// Built-in sinks override this with a connect / auth / metadata probe.
501    ///
502    /// The probe **MUST be idempotent and side-effect-free** — no inserts, no
503    /// residual rows or objects — and must never put credentials or connection
504    /// strings in a probe `reason`/`hint`.
505    async fn check(
506        &self,
507        _ctx: &crate::check::CheckContext,
508    ) -> Result<crate::check::CheckReport, FaucetError> {
509        Ok(crate::check::CheckReport::not_implemented())
510    }
511}
512
513#[cfg(test)]
514mod tests {
515    use super::*;
516    use serde_json::json;
517
518    // ── Mock Source ──────────────────────────────────────────────────────────
519
520    struct MockSource {
521        records: Vec<Value>,
522    }
523
524    #[async_trait]
525    impl Source for MockSource {
526        async fn fetch_with_context(
527            &self,
528            _context: &std::collections::HashMap<String, Value>,
529        ) -> Result<Vec<Value>, FaucetError> {
530            Ok(self.records.clone())
531        }
532    }
533
534    struct IncrementalSource {
535        records: Vec<Value>,
536        bookmark: Value,
537    }
538
539    #[async_trait]
540    impl Source for IncrementalSource {
541        async fn fetch_with_context(
542            &self,
543            _context: &std::collections::HashMap<String, Value>,
544        ) -> Result<Vec<Value>, FaucetError> {
545            Ok(self.records.clone())
546        }
547
548        async fn fetch_with_context_incremental(
549            &self,
550            _context: &std::collections::HashMap<String, Value>,
551        ) -> Result<(Vec<Value>, Option<Value>), FaucetError> {
552            Ok((self.records.clone(), Some(self.bookmark.clone())))
553        }
554    }
555
556    struct FailingSource;
557
558    #[async_trait]
559    impl Source for FailingSource {
560        async fn fetch_with_context(
561            &self,
562            _context: &std::collections::HashMap<String, Value>,
563        ) -> Result<Vec<Value>, FaucetError> {
564            Err(FaucetError::Auth("no credentials".into()))
565        }
566    }
567
568    // ── Mock Sink ───────────────────────────────────────────────────────────
569
570    struct MockSink {
571        written: std::sync::Mutex<Vec<Value>>,
572    }
573
574    impl MockSink {
575        fn new() -> Self {
576            Self {
577                written: std::sync::Mutex::new(Vec::new()),
578            }
579        }
580    }
581
582    #[async_trait]
583    impl Sink for MockSink {
584        async fn write_batch(&self, records: &[Value]) -> Result<usize, FaucetError> {
585            let mut w = self.written.lock().unwrap();
586            w.extend(records.iter().cloned());
587            Ok(records.len())
588        }
589    }
590
591    struct FailingSink;
592
593    #[async_trait]
594    impl Sink for FailingSink {
595        async fn write_batch(&self, _records: &[Value]) -> Result<usize, FaucetError> {
596            Err(FaucetError::Sink("write failed".into()))
597        }
598    }
599
600    // ── Source tests ────────────────────────────────────────────────────────
601
602    #[tokio::test]
603    async fn source_fetch_all_returns_records() {
604        let source = MockSource {
605            records: vec![json!({"id": 1}), json!({"id": 2})],
606        };
607        let records = source.fetch_all().await.unwrap();
608        assert_eq!(records.len(), 2);
609        assert_eq!(records[0]["id"], 1);
610    }
611
612    #[tokio::test]
613    async fn source_fetch_all_empty() {
614        let source = MockSource { records: vec![] };
615        let records = source.fetch_all().await.unwrap();
616        assert!(records.is_empty());
617    }
618
619    #[tokio::test]
620    async fn source_default_incremental_returns_none_bookmark() {
621        let source = MockSource {
622            records: vec![json!({"id": 1})],
623        };
624        let (records, bookmark) = source.fetch_all_incremental().await.unwrap();
625        assert_eq!(records.len(), 1);
626        assert!(bookmark.is_none());
627    }
628
629    #[tokio::test]
630    async fn source_custom_incremental_returns_bookmark() {
631        let source = IncrementalSource {
632            records: vec![json!({"id": 1})],
633            bookmark: json!("2024-12-01"),
634        };
635        let (records, bookmark) = source.fetch_all_incremental().await.unwrap();
636        assert_eq!(records.len(), 1);
637        assert_eq!(bookmark, Some(json!("2024-12-01")));
638    }
639
640    #[tokio::test]
641    async fn source_error_propagates() {
642        let source = FailingSource;
643        let result = source.fetch_all().await;
644        assert!(result.is_err());
645        assert!(matches!(result, Err(FaucetError::Auth(_))));
646    }
647
648    #[tokio::test]
649    async fn source_as_trait_object() {
650        let source: Box<dyn Source> = Box::new(MockSource {
651            records: vec![json!({"id": 42})],
652        });
653        let records = source.fetch_all().await.unwrap();
654        assert_eq!(records[0]["id"], 42);
655    }
656
657    // ── Sink tests ──────────────────────────────────────────────────────────
658
659    #[tokio::test]
660    async fn sink_write_batch_returns_count() {
661        let sink = MockSink::new();
662        let records = vec![json!({"id": 1}), json!({"id": 2}), json!({"id": 3})];
663        let count = sink.write_batch(&records).await.unwrap();
664        assert_eq!(count, 3);
665    }
666
667    #[tokio::test]
668    async fn sink_write_batch_empty() {
669        let sink = MockSink::new();
670        let count = sink.write_batch(&[]).await.unwrap();
671        assert_eq!(count, 0);
672    }
673
674    #[tokio::test]
675    async fn sink_accumulates_records() {
676        let sink = MockSink::new();
677        sink.write_batch(&[json!({"a": 1})]).await.unwrap();
678        sink.write_batch(&[json!({"b": 2})]).await.unwrap();
679        let written = sink.written.lock().unwrap();
680        assert_eq!(written.len(), 2);
681    }
682
683    #[tokio::test]
684    async fn sink_default_flush_is_noop() {
685        let sink = MockSink::new();
686        assert!(sink.flush().await.is_ok());
687    }
688
689    #[tokio::test]
690    async fn sink_error_propagates() {
691        let sink = FailingSink;
692        let result = sink.write_batch(&[json!({"id": 1})]).await;
693        assert!(result.is_err());
694        assert!(matches!(result, Err(FaucetError::Sink(_))));
695    }
696
697    #[tokio::test]
698    async fn sink_as_trait_object() {
699        let sink: Box<dyn Sink> = Box::new(MockSink::new());
700        let count = sink.write_batch(&[json!({"id": 1})]).await.unwrap();
701        assert_eq!(count, 1);
702    }
703
704    // ── stream_pages tests ──────────────────────────────────────────────────
705
706    use crate::pipeline::DEFAULT_BATCH_SIZE;
707    use futures::StreamExt;
708
709    #[tokio::test]
710    async fn default_stream_pages_chunks_records() {
711        let source = MockSource {
712            records: (0..5).map(|i| json!({"i": i})).collect(),
713        };
714        let ctx = std::collections::HashMap::new();
715        let mut pages = source.stream_pages(&ctx, 2);
716        let mut all = Vec::new();
717        while let Some(page) = pages.next().await {
718            all.push(page.unwrap());
719        }
720        // 5 records, batch_size=2 → pages of [2, 2, 1]
721        assert_eq!(all.len(), 3);
722        assert_eq!(all[0].records.len(), 2);
723        assert_eq!(all[1].records.len(), 2);
724        assert_eq!(all[2].records.len(), 1);
725    }
726
727    #[tokio::test]
728    async fn default_stream_pages_attaches_bookmark_to_final_page_only() {
729        let source = IncrementalSource {
730            records: (0..5).map(|i| json!({"i": i})).collect(),
731            bookmark: json!("v1"),
732        };
733        let ctx = std::collections::HashMap::new();
734        let mut pages = source.stream_pages(&ctx, 2);
735        let mut collected = Vec::new();
736        while let Some(page) = pages.next().await {
737            collected.push(page.unwrap());
738        }
739        assert_eq!(collected.len(), 3);
740        assert!(collected[0].bookmark.is_none());
741        assert!(collected[1].bookmark.is_none());
742        assert_eq!(collected[2].bookmark, Some(json!("v1")));
743    }
744
745    #[tokio::test]
746    async fn default_stream_pages_single_page_when_batch_size_exceeds_total() {
747        let source = MockSource {
748            records: vec![json!({"id": 1}), json!({"id": 2})],
749        };
750        let ctx = std::collections::HashMap::new();
751        let mut pages = source.stream_pages(&ctx, 100);
752        let mut collected = Vec::new();
753        while let Some(page) = pages.next().await {
754            collected.push(page.unwrap());
755        }
756        assert_eq!(collected.len(), 1);
757        assert_eq!(collected[0].records.len(), 2);
758    }
759
760    #[tokio::test]
761    async fn default_stream_pages_batch_size_zero_emits_single_page() {
762        // batch_size = 0 is the "no batching" sentinel — yields every record
763        // in one page regardless of total count.
764        let source = MockSource {
765            records: (0..50_000).map(|i| json!({"i": i})).collect(),
766        };
767        let ctx = std::collections::HashMap::new();
768        let mut pages = source.stream_pages(&ctx, 0);
769        let mut collected = Vec::new();
770        while let Some(page) = pages.next().await {
771            collected.push(page.unwrap());
772        }
773        assert_eq!(
774            collected.len(),
775            1,
776            "batch_size=0 must emit exactly one page"
777        );
778        assert_eq!(collected[0].records.len(), 50_000);
779    }
780
781    #[tokio::test]
782    async fn default_stream_pages_batch_size_zero_attaches_bookmark_to_sole_page() {
783        let source = IncrementalSource {
784            records: (0..3).map(|i| json!({"i": i})).collect(),
785            bookmark: json!("v1"),
786        };
787        let ctx = std::collections::HashMap::new();
788        let mut pages = source.stream_pages(&ctx, 0);
789        let page = pages.next().await.unwrap().unwrap();
790        assert_eq!(page.records.len(), 3);
791        assert_eq!(page.bookmark, Some(json!("v1")));
792        assert!(pages.next().await.is_none());
793    }
794
795    #[tokio::test]
796    async fn default_stream_pages_empty_source_yields_no_pages() {
797        let source = MockSource { records: vec![] };
798        let ctx = std::collections::HashMap::new();
799        let mut pages = source.stream_pages(&ctx, DEFAULT_BATCH_SIZE);
800        assert!(pages.next().await.is_none());
801    }
802
803    #[tokio::test]
804    async fn default_stream_pages_empty_source_with_bookmark_yields_single_empty_page() {
805        let source = IncrementalSource {
806            records: vec![],
807            bookmark: json!("v0"),
808        };
809        let ctx = std::collections::HashMap::new();
810        let mut pages = source.stream_pages(&ctx, DEFAULT_BATCH_SIZE);
811        let mut collected = Vec::new();
812        while let Some(page) = pages.next().await {
813            collected.push(page.unwrap());
814        }
815        // One empty-records page that carries the bookmark, so the pipeline
816        // still persists progress on otherwise-empty incremental runs.
817        assert_eq!(collected.len(), 1);
818        assert!(collected[0].records.is_empty());
819        assert_eq!(collected[0].bookmark, Some(json!("v0")));
820    }
821
822    #[tokio::test]
823    async fn default_stream_pages_propagates_fetch_errors() {
824        let source = FailingSource;
825        let ctx = std::collections::HashMap::new();
826        let mut pages = source.stream_pages(&ctx, DEFAULT_BATCH_SIZE);
827        let first = pages.next().await.unwrap();
828        assert!(matches!(first, Err(FaucetError::Auth(_))));
829    }
830
831    #[test]
832    fn source_default_connector_name_is_stripped_type_name() {
833        // MockSource lives at `faucet_core::traits::tests::MockSource`; the
834        // stripped type_name yields the trailing segment.
835        let source = MockSource { records: vec![] };
836        assert_eq!(source.connector_name(), "MockSource");
837    }
838
839    #[test]
840    fn sink_default_connector_name_is_stripped_type_name() {
841        let sink = MockSink::new();
842        assert_eq!(sink.connector_name(), "MockSink");
843    }
844
845    #[test]
846    fn source_default_dataset_uri_uses_connector_name() {
847        let source = MockSource { records: vec![] };
848        assert_eq!(source.dataset_uri(), "MockSource://unknown");
849    }
850
851    #[test]
852    fn sink_default_dataset_uri_uses_connector_name() {
853        let sink = MockSink::new();
854        assert_eq!(sink.dataset_uri(), "MockSink://unknown");
855    }
856
857    // ── write_batch_partial tests ───────────────────────────────────────────
858
859    #[tokio::test]
860    async fn default_write_batch_partial_success_returns_all_ok() {
861        let sink = MockSink::new();
862        let records = vec![json!({"id": 1}), json!({"id": 2}), json!({"id": 3})];
863        let outcomes = sink.write_batch_partial(&records).await.unwrap();
864        assert_eq!(outcomes.len(), 3);
865        assert!(outcomes.iter().all(|o| o.is_ok()));
866        assert_eq!(sink.written.lock().unwrap().len(), 3);
867    }
868
869    #[tokio::test]
870    async fn default_write_batch_partial_bubbles_outer_err() {
871        let sink = FailingSink;
872        let records = vec![json!({"id": 1}), json!({"id": 2})];
873        let result = sink.write_batch_partial(&records).await;
874        assert!(matches!(result, Err(FaucetError::Sink(_))));
875    }
876
877    #[tokio::test]
878    async fn default_write_batch_partial_empty_returns_empty_vec() {
879        let sink = MockSink::new();
880        let outcomes = sink.write_batch_partial(&[]).await.unwrap();
881        assert!(outcomes.is_empty());
882    }
883
884    #[tokio::test]
885    async fn default_write_batch_partial_callable_through_trait_object() {
886        let sink: Box<dyn Sink> = Box::new(MockSink::new());
887        let records = vec![json!({"id": 1}), json!({"id": 2})];
888        let outcomes = sink.write_batch_partial(&records).await.unwrap();
889        assert_eq!(outcomes.len(), 2);
890        assert!(outcomes.iter().all(|o| o.is_ok()));
891    }
892
893    // ── check() tests ─────────────────────────────────────────────────────────
894
895    #[tokio::test]
896    async fn source_default_check_pulls_first_page_and_passes() {
897        let source = MockSource {
898            records: vec![json!({"id": 1}), json!({"id": 2})],
899        };
900        let report = source
901            .check(&crate::check::CheckContext::default())
902            .await
903            .unwrap();
904        assert_eq!(report.failed_count(), 0);
905        assert!(
906            report
907                .probes
908                .iter()
909                .any(|p| p.name == "read" && matches!(p.status, crate::check::ProbeStatus::Pass))
910        );
911    }
912
913    #[tokio::test]
914    async fn source_default_check_passes_on_empty_source() {
915        let source = MockSource { records: vec![] };
916        let report = source
917            .check(&crate::check::CheckContext::default())
918            .await
919            .unwrap();
920        // Reachable but empty is still a healthy source.
921        assert_eq!(report.failed_count(), 0);
922    }
923
924    #[tokio::test]
925    async fn source_default_check_fails_when_fetch_errors() {
926        let source = FailingSource;
927        let report = source
928            .check(&crate::check::CheckContext::default())
929            .await
930            .unwrap();
931        assert_eq!(report.failed_count(), 1);
932        assert!(report.probes.iter().any(
933            |p| p.name == "read" && matches!(p.status, crate::check::ProbeStatus::Fail { .. })
934        ));
935    }
936
937    #[tokio::test]
938    async fn sink_default_check_is_not_implemented_skip() {
939        let sink = MockSink::new();
940        let report = sink
941            .check(&crate::check::CheckContext::default())
942            .await
943            .unwrap();
944        assert_eq!(report.probes.len(), 1);
945        assert!(matches!(
946            report.probes[0].status,
947            crate::check::ProbeStatus::Skip { .. }
948        ));
949    }
950
951    #[tokio::test]
952    async fn source_check_callable_through_trait_object() {
953        let source: Box<dyn Source> = Box::new(MockSource {
954            records: vec![json!({"id": 1})],
955        });
956        let report = source
957            .check(&crate::check::CheckContext::default())
958            .await
959            .unwrap();
960        assert_eq!(report.failed_count(), 0);
961    }
962
963    // ── idempotent-write / exactly-once capability tests ──────────────────────
964
965    #[tokio::test]
966    async fn sink_default_is_not_idempotent() {
967        let sink = MockSink::new();
968        assert!(!sink.supports_idempotent_writes());
969        // Default write_batch_idempotent ignores the token and delegates.
970        let n = sink
971            .write_batch_idempotent(&[json!({"id": 1})], "scope::a", "00000000000000000001")
972            .await
973            .unwrap();
974        assert_eq!(n, 1);
975        assert_eq!(sink.last_committed_token("scope::a").await.unwrap(), None);
976        assert_eq!(sink.written.lock().unwrap().len(), 1);
977    }
978
979    #[test]
980    fn source_default_does_not_support_exactly_once() {
981        let source = MockSource { records: vec![] };
982        assert!(!source.supports_exactly_once());
983    }
984
985    #[test]
986    fn sink_default_supported_write_modes_is_append_only() {
987        use crate::write_mode::WriteMode;
988        let sink = MockSink::new();
989        assert_eq!(sink.supported_write_modes(), &[WriteMode::Append]);
990    }
991
992    #[test]
993    fn supported_write_modes_callable_through_trait_object() {
994        use crate::write_mode::WriteMode;
995        let sink: Box<dyn Sink> = Box::new(MockSink::new());
996        assert!(sink.supported_write_modes().contains(&WriteMode::Append));
997    }
998
999    #[tokio::test]
1000    async fn sink_default_current_schema_is_none() {
1001        let sink = MockSink::new();
1002        assert_eq!(sink.current_schema().await.unwrap(), None);
1003    }
1004
1005    #[test]
1006    fn sink_default_does_not_support_schema_evolution() {
1007        let sink = MockSink::new();
1008        assert!(!sink.supports_schema_evolution());
1009    }
1010
1011    #[tokio::test]
1012    async fn sink_default_evolve_schema_is_unsupported_error() {
1013        let sink = MockSink::new();
1014        let evo = crate::drift::SchemaEvolution::default();
1015        let err = sink.evolve_schema(&evo).await.unwrap_err();
1016        assert!(matches!(err, FaucetError::Sink(_)));
1017        assert!(err.to_string().contains("schema evolution"));
1018    }
1019
1020    #[tokio::test]
1021    async fn source_default_capture_resume_position_is_none() {
1022        let source = MockSource { records: vec![] };
1023        assert_eq!(source.capture_resume_position().await.unwrap(), None);
1024    }
1025
1026    #[tokio::test]
1027    async fn capture_resume_position_callable_through_trait_object() {
1028        let source: Box<dyn Source> = Box::new(MockSource { records: vec![] });
1029        assert!(source.capture_resume_position().await.unwrap().is_none());
1030    }
1031
1032    #[tokio::test]
1033    async fn source_default_does_not_support_discover() {
1034        let source: Box<dyn Source> = Box::new(MockSource { records: vec![] });
1035        assert!(!source.supports_discover());
1036        let err = source.discover().await.unwrap_err();
1037        assert!(matches!(err, FaucetError::Source(_)));
1038        assert!(
1039            err.to_string().contains("dataset discovery"),
1040            "typed unsupported error: {err}"
1041        );
1042    }
1043
1044    #[tokio::test]
1045    async fn source_default_is_not_shardable() {
1046        let source: Box<dyn Source> = Box::new(MockSource { records: vec![] });
1047        assert!(!source.is_shardable());
1048    }
1049
1050    #[tokio::test]
1051    async fn source_default_enumerates_single_whole_shard() {
1052        // A non-shardable source enumerates to exactly one whole-dataset shard,
1053        // regardless of the requested target — preserving single-worker behavior.
1054        let source: Box<dyn Source> = Box::new(MockSource { records: vec![] });
1055        let shards = source.enumerate_shards(8).await.unwrap();
1056        assert_eq!(shards.len(), 1);
1057        assert!(shards[0].is_whole());
1058    }
1059
1060    #[tokio::test]
1061    async fn source_default_apply_shard_is_noop() {
1062        let source: Box<dyn Source> = Box::new(MockSource { records: vec![] });
1063        // Applying the whole shard is a no-op and must not error.
1064        source
1065            .apply_shard(&crate::shard::ShardSpec::whole())
1066            .await
1067            .unwrap();
1068    }
1069}