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