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    /// Wire formats this source can emit as raw bytes for the **native
154    /// byte-passthrough** fast path (#633), in preference order (first = best).
155    /// Default: `&[]` (no native fast path).
156    ///
157    /// The pipeline uses the path only when this returns a non-empty slice, a
158    /// sink advertises a matching [`NativeLoadCapability`](crate::NativeLoadCapability),
159    /// and every prerequisite holds (see [`plan_native_transfer`](crate::plan_native_transfer)).
160    /// A [`TransformingSource`](crate::TransformingSource) deliberately does not
161    /// override this, so any attached transform disables the fast path.
162    fn native_output_formats(&self) -> &'static [crate::native::NativeFormat] {
163        &[]
164    }
165
166    /// Stream the source natively as byte batches in `format` (#633).
167    ///
168    /// Only invoked after the pipeline negotiated `format` — one this source
169    /// advertised via [`native_output_formats`](Self::native_output_formats). The
170    /// default yields a single typed "unsupported" error so a source that
171    /// advertises support but forgets to override this fails loudly. Each batch's
172    /// `bookmark` carries the same checkpoint semantics as [`StreamPage`].
173    fn stream_native<'a>(
174        &'a self,
175        context: &'a std::collections::HashMap<String, Value>,
176        format: crate::native::NativeFormat,
177        batch_size: usize,
178    ) -> Pin<Box<dyn Stream<Item = Result<crate::native::NativeBatch, FaucetError>> + Send + 'a>>
179    {
180        let _ = (context, batch_size, format);
181        let name = self.connector_name();
182        let err: Result<crate::native::NativeBatch, FaucetError> = Err(FaucetError::Source(
183            format!("source '{name}' does not support native byte streaming (stream_native)"),
184        ));
185        Box::pin(futures::stream::once(async move { err }))
186    }
187
188    /// Return a JSON Schema describing the configuration this source accepts.
189    fn config_schema(&self) -> Value {
190        serde_json::json!({"type": "object", "properties": {}})
191    }
192
193    /// Stable key under which this source's incremental-replication bookmark
194    /// should be persisted in a [`StateStore`](crate::state::StateStore).
195    ///
196    /// Returning `Some(key)` opts this source into resumable runs: when the
197    /// pipeline is configured with a state store via
198    /// [`Pipeline::with_state_store`](crate::Pipeline::with_state_store), it
199    /// reads the bookmark at `key` before fetching and writes the new
200    /// bookmark back only after the sink confirms the batch was written.
201    ///
202    /// The default returns `None`, meaning the source is not persisted.
203    /// Keys must satisfy [`validate_state_key`](crate::state::validate_state_key).
204    fn state_key(&self) -> Option<String> {
205        None
206    }
207
208    /// Earlier renderings of this source's natural
209    /// [`state_key`](Self::state_key), newest first. When the current key holds
210    /// nothing, the pipeline moves a bookmark found under one of these to it
211    /// before reading ([`migrate_state_key`](crate::migrate_state_key)), so
212    /// changing how a source derives its key does not restart a resumable run.
213    /// Return nothing when the key was configured explicitly. Default: none.
214    fn legacy_state_keys(&self) -> Vec<String> {
215        Vec::new()
216    }
217
218    /// Apply a bookmark loaded from a [`StateStore`](crate::state::StateStore)
219    /// as this run's starting point.
220    ///
221    /// The default implementation ignores the value, which keeps existing
222    /// sources backwards-compatible. Sources that support incremental
223    /// replication override this — typically by storing the value behind
224    /// interior mutability and consulting it inside
225    /// `fetch_with_context_incremental`.
226    async fn apply_start_bookmark(&self, _bookmark: Value) -> Result<(), FaucetError> {
227        Ok(())
228    }
229
230    /// Capture the source's current replication position **without consuming
231    /// any changes**, ensuring any server-side resource (e.g. a logical
232    /// replication slot) needed to later resume from that position exists.
233    ///
234    /// Returns the position as a bookmark [`Value`] — the same shape
235    /// [`apply_start_bookmark`](Self::apply_start_bookmark) accepts — or `None`
236    /// if this source does not support position capture.
237    ///
238    /// Used by the snapshot→CDC replication orchestrator (`faucet replicate`)
239    /// to anchor the CDC stream at-or-before the bulk snapshot's read point so
240    /// the handoff has no gap. The default returns `None`.
241    async fn capture_resume_position(&self) -> Result<Option<Value>, FaucetError> {
242        Ok(None)
243    }
244
245    /// How far this source is behind its head (#733) — unread WAL bytes, binlog
246    /// distance, unconsumed messages, or the age of the oldest unread change.
247    ///
248    /// The pipeline polls it at page boundaries and exports
249    /// `faucet_source_lag_{bytes,events,seconds}`; `faucet status --probe`
250    /// asks for it on demand after applying the stored bookmark. It must be a
251    /// cheap, read-only query. An `Err` is logged once and reported as no lag;
252    /// it never fails a run. Default: `Ok(None)` (no notion of a head).
253    /// Decorators must forward this.
254    async fn lag(&self) -> Result<Option<crate::lag::SourceLag>, FaucetError> {
255        Ok(None)
256    }
257
258    /// The version of this source's bookmark shape (#736). Stored state carries
259    /// it; bump it whenever the shape changes and teach
260    /// [`migrate_state`](Self::migrate_state) the step. Default `0`. Decorators
261    /// must forward this.
262    fn state_schema(&self) -> u32 {
263        0
264    }
265
266    /// Bring a bookmark stored at shape version `from` up to
267    /// [`state_schema`](Self::state_schema) (#736). Must be pure — the
268    /// migrated value is only persisted by the next bookmark write, so a crash
269    /// in between re-runs it. The default knows only its own version. Decorators
270    /// must forward this.
271    fn migrate_state(&self, from: u32, data: Value) -> Result<Value, FaucetError> {
272        if from == self.state_schema() {
273            Ok(data)
274        } else {
275            Err(FaucetError::State(format!(
276                "{} has no migration from bookmark schema {from} to {}",
277                self.connector_name(),
278                self.state_schema()
279            )))
280        }
281    }
282
283    /// Whether this source **deterministically replays** the same page sequence
284    /// from a given bookmark — the requirement for the atomic-watermark
285    /// effectively-once path (a non-deterministic replay could cause the pipeline
286    /// to skip a page whose contents differ from the one already committed).
287    /// Default: `false`.
288    ///
289    /// Sources with a durable monotonic position and per-page bookmarks (CDC)
290    /// override this to return `true`. The pipeline rejects
291    /// `DeliveryMode::ExactlyOnce` against a source that returns `false`.
292    fn supports_exactly_once(&self) -> bool {
293        false
294    }
295
296    /// Whether reading from this source **removes data from its backend** —
297    /// a queue source that acks, deletes or settles messages as the pipeline
298    /// moves past each page (SQS, Pub/Sub, JetStream, RabbitMQ). Such a source
299    /// relies on the linear pipeline writing and flushing a page before it is
300    /// polled again; anything that reads without durably writing (a preview, a
301    /// dry run, a `--limit` run, a plan sample) or that polls ahead of its
302    /// sinks (a topology graph) would lose those messages for good, so callers
303    /// refuse it. Default `false`.
304    fn consumes_destructively(&self) -> bool {
305        false
306    }
307
308    /// The typed replay capability this source advertises — see
309    /// [`ReplayGuarantee`](crate::ReplayGuarantee).
310    ///
311    /// The default derives from [`supports_exactly_once`](Self::supports_exactly_once)
312    /// (the boolean stays the back-compat primitive: existing connectors that
313    /// override only the boolean automatically advertise `Deterministic`
314    /// here). Override this directly only to *diverge* from the boolean —
315    /// there is currently no reason to.
316    fn replay_guarantee(&self) -> crate::idempotency::ReplayGuarantee {
317        if self.supports_exactly_once() {
318            crate::idempotency::ReplayGuarantee::Deterministic
319        } else {
320            crate::idempotency::ReplayGuarantee::NonDeterministic
321        }
322    }
323
324    /// What this source can do (RFC 0013, #876). The default derives every
325    /// field from the per-capability methods above
326    /// ([`Capabilities::of_source`](crate::Capabilities::of_source)); override
327    /// it to declare a capability no method expresses (range digests, lag),
328    /// keeping the connector's [`ConnectorDescriptor`](crate::ConnectorDescriptor)
329    /// in agreement. Decorators must forward this.
330    fn capabilities(&self) -> crate::capabilities::Capabilities {
331        crate::capabilities::Capabilities::of_source(self)
332    }
333
334    /// Whether this source can split its work into independent shards for
335    /// clustered (Mode B) execution. Default: `false` (single whole-dataset
336    /// shard). Sources with a natural partition (object-store prefixes, table
337    /// primary-key ranges) override this to `true` and implement
338    /// [`enumerate_shards`](Self::enumerate_shards) +
339    /// [`apply_shard`](Self::apply_shard).
340    fn is_shardable(&self) -> bool {
341        false
342    }
343
344    /// Enumerate the shards this source splits into, aiming for roughly `target`
345    /// of them (a hint — the source may return fewer, e.g. when the dataset is
346    /// small, or one per natural partition regardless of `target`).
347    ///
348    /// Called **once per run** by the cluster coordinator; enumeration must be
349    /// deterministic enough that re-enumeration yields a compatible set (stable
350    /// shard ids), since it may run on more than one instance and is reconciled
351    /// by idempotent insert. May perform read-only I/O (a `LIST`, a `MIN/MAX`
352    /// query). The default returns a single whole-dataset shard
353    /// ([`ShardSpec::whole`](crate::ShardSpec::whole)), preserving today's
354    /// single-worker behavior.
355    async fn enumerate_shards(
356        &self,
357        _target: usize,
358    ) -> Result<Vec<crate::shard::ShardSpec>, FaucetError> {
359        Ok(vec![crate::shard::ShardSpec::whole()])
360    }
361
362    /// Narrow this source instance to a single shard before streaming.
363    ///
364    /// Called on the worker that claims `shard`, after construction and before
365    /// any `stream_pages` call. Like [`apply_start_bookmark`](Self::apply_start_bookmark)
366    /// this takes `&self` and is expected to record the shard behind interior
367    /// mutability (the source consults it when building its query / listing).
368    /// The default ignores the shard — a non-shardable source only ever receives
369    /// [`ShardSpec::whole`](crate::ShardSpec::whole), so ignoring it streams the
370    /// whole dataset. Implementations should accept the whole shard as a no-op.
371    async fn apply_shard(&self, _shard: &crate::shard::ShardSpec) -> Result<(), FaucetError> {
372        Ok(())
373    }
374
375    /// Compute a [`ServerDigest`](crate::diff::ServerDigest) of the rows in
376    /// `range` **inside the backend**, so a matching range ships no rows
377    /// (#701). `columns` are the compared columns; `key` the integer key the
378    /// range is over. Default `Ok(None)`: not supported, the verifier streams
379    /// the range instead. Two digests compare only when both sides report the
380    /// same `algorithm`.
381    async fn range_digest(
382        &self,
383        range: &crate::diff::KeyRange,
384        key: &str,
385        columns: &[String],
386    ) -> Result<Option<crate::diff::ServerDigest>, FaucetError> {
387        let _ = (range, key, columns);
388        Ok(None)
389    }
390
391    /// Whether this source can enumerate the datasets behind its connection
392    /// via [`discover`](Self::discover). Default: `false`. Sources backed by
393    /// an introspectable catalog (database `information_schema`, MongoDB
394    /// collections, Elasticsearch indices, object-store prefixes) override
395    /// this to `true`.
396    fn supports_discover(&self) -> bool {
397        false
398    }
399
400    /// Enumerate the datasets living behind this source's connection — one
401    /// [`DatasetDescriptor`](crate::discover::DatasetDescriptor) per table /
402    /// collection / index / prefix, each carrying a partial config override
403    /// that selects it (used by `faucet discover` to scaffold one matrix row
404    /// per dataset).
405    ///
406    /// Must be **read-only and cheap**: catalog metadata queries and listings
407    /// only, never a data scan. Descriptors must never embed credentials.
408    /// The default returns a typed "unsupported" error; override it (and
409    /// return `true` from [`supports_discover`](Self::supports_discover))
410    /// only for sources with a real catalog to introspect.
411    async fn discover(&self) -> Result<Vec<crate::discover::DatasetDescriptor>, FaucetError> {
412        Err(FaucetError::Source(format!(
413            "source '{}' does not support dataset discovery",
414            self.connector_name()
415        )))
416    }
417
418    /// The columns this stream emits, with their exact types (#955), so a sink
419    /// that creates its destination types it from the source rather than from
420    /// the first page's JSON values. See [`crate::declared`].
421    ///
422    /// Called once by [`Pipeline::run`](crate::Pipeline::run), before the first
423    /// page, with the same `context` as [`stream_pages`](Self::stream_pages).
424    /// A SQL source answers it from the describe of the statement it is about
425    /// to run. Must be read-only; `Ok(None)` (the default) declares nothing and
426    /// leaves the sink inferring types. A source that overrides it declares
427    /// `declared_columns` in its capabilities. A wrapper that reshapes records
428    /// returns the inner declaration [`reshaped`](crate::DeclaredColumns::reshaped)
429    /// or `None`.
430    async fn declared_columns(
431        &self,
432        _context: &std::collections::HashMap<String, Value>,
433    ) -> Result<Option<crate::declared::DeclaredColumns>, FaucetError> {
434        Ok(None)
435    }
436
437    /// The dataset (table / collection) a change record belongs to, for a
438    /// change stream that carries several tables (#731). A multi-table
439    /// `faucet mirror` runs one stream and routes each record to its table's
440    /// pipeline by this name, which must match the name the paired bulk
441    /// source's [`discover`](Self::discover) reports (e.g. `public.orders`).
442    /// `None` means the record belongs to no table (a DDL / control event) —
443    /// and is the default, so a source that does not override this cannot be
444    /// used for a multi-table mirror.
445    fn record_table(&self, _record: &Value) -> Option<String> {
446        None
447    }
448
449    /// Order two of this source's bookmarks (#731): `Some(true)` when stream
450    /// position `a` is at or before `b` (every change up to `a` is also covered
451    /// by `b`), `Some(false)` when it is not, and `None` when the source cannot
452    /// tell (the default, or two positions it cannot relate). A multi-table
453    /// mirror uses it to resume one shared stream from the earliest table and
454    /// skip, per table, pages that table has already committed.
455    fn position_le(&self, _a: &Value, _b: &Value) -> Option<bool> {
456        None
457    }
458
459    /// Whether the stream had already delivered `record`'s change at stream
460    /// position `position` (#854): `Some(true)` when a run resumed from
461    /// `position` would not deliver it again, `Some(false)` when it would, and
462    /// `None` when the source cannot tell (the default). A multi-table mirror
463    /// resumes one shared stream from the earliest table, so a replayed page
464    /// can straddle another table's committed position; this per-record test
465    /// drops the records that table already applied, which a per-page
466    /// [`position_le`](Self::position_le) check cannot when the replay cuts its
467    /// pages at different points than the run that committed them.
468    fn record_covered(&self, _record: &Value, _position: &Value) -> Option<bool> {
469        None
470    }
471
472    /// The signal through which this source reports, during a run, that it is
473    /// reading its **whole dataset** (see [`crate::cleanup`]). After such a
474    /// run the pipeline may delete destination rows the run did not write.
475    /// Default `None`: the source never makes that claim.
476    fn read_completeness(&self) -> Option<crate::cleanup::ReadCompleteness> {
477        None
478    }
479
480    /// The earliest stream position every one of `positions` can resume from
481    /// (#731): the shared change stream restarts there and each table skips
482    /// what it has already applied. The default picks the position that
483    /// [`position_le`](Self::position_le) orders at or before all the others,
484    /// and `None` when there is none (an empty slice, or positions this source
485    /// cannot order). Sources whose positions are only partially ordered (one
486    /// cursor per capture instance, say) override it with a component-wise
487    /// minimum.
488    fn position_min(&self, positions: &[Value]) -> Option<Value> {
489        let first = positions.first()?;
490        if positions.iter().all(|p| p == first) {
491            return Some(first.clone());
492        }
493        positions
494            .iter()
495            .find(|cand| {
496                positions
497                    .iter()
498                    .all(|other| self.position_le(cand, other) == Some(true))
499            })
500            .cloned()
501    }
502
503    /// Stable identifier used as the `connector` label on metrics and the
504    /// `connector` attribute on spans. Defaults to the final segment of
505    /// `std::any::type_name::<Self>()`, e.g. `"RestSource"`. Built-in
506    /// connectors override with a short, friendly snake_case name (e.g.
507    /// `"rest"`). Must return a non-empty string; observability decorators
508    /// fall back to `"unknown"` in release builds if it is empty (and
509    /// `debug_assert!` in debug builds).
510    fn connector_name(&self) -> &'static str {
511        crate::observability::strip_type_name(std::any::type_name::<Self>())
512    }
513
514    /// Receive a pre-labelled handle for counting **upstream round trips**
515    /// (#638) — calls this connector makes to its own backend.
516    ///
517    /// The pipeline calls this once before streaming, because it is the only
518    /// place that knows the `pipeline` / `row` / `connector` labels every other
519    /// metric carries. A connector opts in by storing the handle behind
520    /// interior mutability (the pattern the REST source already uses for
521    /// `runtime_start`) and calling `recorder.record("<op>")` at each real
522    /// backend call — including retries, which are real round trips.
523    ///
524    /// Defaulted to a no-op: an uninstrumented connector emits nothing, so
525    /// instrumentation lands connector by connector with no behaviour change
526    /// in between, and a third-party connector is unaffected.
527    fn set_roundtrip_recorder(
528        &self,
529        _recorder: std::sync::Arc<crate::observability::RoundtripRecorder>,
530    ) {
531    }
532
533    /// Receive the run clock (#769): the instant `${now.*}` renders from —
534    /// `faucet run --clock`, a schedule tick, a backfill unit's start — so a
535    /// source that bounds reads by "now" (REST window slicing) reproduces the
536    /// run as of that instant rather than the wall clock.
537    ///
538    /// Called once before streaming. Defaulted to a no-op; a connector opts in
539    /// by storing the instant behind interior mutability.
540    fn set_run_clock(&self, _now: chrono::DateTime<chrono::Utc>) {}
541
542    /// Receive the run's [`CallContext`](crate::io::CallContext) (#880): the
543    /// cancel token, the round-trip recorder and any deadline every backend
544    /// call should honour. Called once before streaming.
545    ///
546    /// A connector stores it in a [`CallSlot`](crate::io::CallSlot) and runs
547    /// its backend calls through [`CallSlot::call`](crate::io::CallSlot::call),
548    /// which layers the connector's own timeout and retry settings over it.
549    /// Defaulted to a no-op; a wrapping source forwards it.
550    fn set_call_context(&self, _ctx: crate::io::CallContext) {}
551
552    /// Receive the run's cancellation token ([`Pipeline::with_cancel`](crate::Pipeline::with_cancel)),
553    /// installed once before streaming.
554    ///
555    /// Return `true` when the source handles the cancel itself: it stops
556    /// reading, yields the records it has buffered and ends its stream
557    /// promptly. The pipeline then keeps polling the stream to its end instead
558    /// of stopping at the next page boundary, so that buffered tail is
559    /// written. A source that buffers records it cannot re-read (a live feed)
560    /// opts in; the default ignores the token and returns `false`. A wrapping
561    /// source forwards the call and returns the inner source's answer.
562    fn set_cancel(&self, _cancel: tokio_util::sync::CancellationToken) -> bool {
563        false
564    }
565
566    /// Logical dataset identity for lineage emission, following OpenLineage
567    /// naming conventions (<https://openlineage.io/docs/spec/naming>).
568    ///
569    /// The default returns `"<connector_name>://unknown"`. Built-in connectors
570    /// override with a credential-free URI derived from their config. Strip any
571    /// credentials with [`redact_uri_credentials`](crate::redact_uri_credentials).
572    /// Informational metadata only — never used for I/O.
573    fn dataset_uri(&self) -> String {
574        format!("{}://unknown", self.connector_name())
575    }
576
577    /// Run a fast, non-mutating preflight probe (used by `faucet doctor`).
578    ///
579    /// The default pulls a **single page** via
580    /// [`stream_pages`](Self::stream_pages) and reports success/failure — it
581    /// exercises the real read path (DNS, TLS, auth, the first request, the
582    /// first-record decode) and never repeats. The page stream is dropped
583    /// immediately after the first page, so a source that streams stops after
584    /// one page; a source that keeps the default `stream_pages` buffers its
585    /// whole fetch before that page exists, so it should override `check` when
586    /// its dataset is large.
587    ///
588    /// Sources whose first page *blocks* waiting for inbound data (webhook,
589    /// websocket) or has *side effects* (CDC consuming WAL) override this with a
590    /// cheaper, side-effect-free probe. Probe-level failures are returned as a
591    /// [`ProbeStatus::Fail`](crate::check::ProbeStatus) inside `Ok(report)`.
592    async fn check(
593        &self,
594        ctx: &crate::check::CheckContext,
595    ) -> Result<crate::check::CheckReport, FaucetError> {
596        use crate::check::{CheckReport, Probe};
597        use futures::StreamExt;
598
599        let empty = std::collections::HashMap::new();
600        let start = std::time::Instant::now();
601        let mut pages = self.stream_pages(&empty, 1);
602        let probe = match tokio::time::timeout(ctx.timeout, pages.next()).await {
603            Err(_) => Probe::fail("read", start.elapsed(), "timed out fetching first page"),
604            Ok(None) | Ok(Some(Ok(_))) => Probe::pass("read", start.elapsed()),
605            Ok(Some(Err(e))) => Probe::fail("read", start.elapsed(), e.to_string()),
606        };
607        Ok(CheckReport::single(probe))
608    }
609}
610
611/// Per-row outcome from [`Sink::write_batch_partial`].
612///
613/// `Ok(())` — the row was durably written to the sink.
614/// `Err(_)` — the row failed; the pipeline will route it to the DLQ when
615/// one is configured.
616pub type RowOutcome = Result<(), FaucetError>;
617
618/// A sink writes records to an external system.
619#[async_trait]
620pub trait Sink: Send + Sync {
621    /// Write a batch of records to the destination.
622    ///
623    /// Returns the number of records successfully written.
624    async fn write_batch(&self, records: &[Value]) -> Result<usize, FaucetError>;
625
626    /// Flush any buffered data to the destination.
627    ///
628    /// The default implementation is a no-op (suitable for sinks that
629    /// write immediately in `write_batch`).
630    async fn flush(&self) -> Result<(), FaucetError> {
631        Ok(())
632    }
633
634    /// Write a batch and report per-row outcomes.
635    ///
636    /// Sinks whose underlying API exposes per-row results (BigQuery
637    /// `insertAll`, Elasticsearch `_bulk`) override this. The default
638    /// implementation delegates to [`Self::write_batch`] and maps a single success
639    /// onto a uniform all-`Ok(())` vector. An outer failure is bubbled up
640    /// unchanged so the pipeline's DLQ router can apply its `on_batch_error`
641    /// policy at a single decision point.
642    ///
643    /// The default checks the write's [`WriteReport`](crate::WriteReport)
644    /// (#879): rows it neither wrote nor reported as coalesced fail the batch
645    /// with [`FaucetError::RowsUnaccounted`] instead of being reported `Ok`.
646    async fn write_batch_partial(&self, records: &[Value]) -> Result<Vec<RowOutcome>, FaucetError> {
647        self.write_batch_reported(records)
648            .await?
649            .check(records.len())?;
650        Ok(records.iter().map(|_| Ok(())).collect())
651    }
652
653    /// Write a batch and report what happened to every row offered (#879).
654    ///
655    /// The pipeline calls this instead of [`Self::write_batch`] and checks that
656    /// `accepted + coalesced + skipped` covers the page; a shortfall fails the
657    /// page with [`FaucetError::RowsUnaccounted`]. The default wraps
658    /// `write_batch`'s count; for a sink configured to merge rows by key
659    /// ([`Self::dedups_by_key`] — keyed upsert / delete, last write wins) it
660    /// counts the rows the count falls short by as
661    /// [`coalesced`](crate::WriteReport::coalesced). A sink that declines rows
662    /// by design of its configuration overrides this and reports them as
663    /// [`skipped`](crate::WriteReport::skipped).
664    async fn write_batch_reported(
665        &self,
666        records: &[Value],
667    ) -> Result<crate::accounting::WriteReport, FaucetError> {
668        let n = self.write_batch(records).await?;
669        Ok(if self.dedups_by_key() {
670            crate::accounting::WriteReport::keyed(records.len(), n)
671        } else {
672            crate::accounting::WriteReport::accepted(n)
673        })
674    }
675
676    /// Write a page the engine governed (RFC 0014): it went through masking,
677    /// quality, contract and schema drift, so the records are exactly what
678    /// may reach the destination. The engine's plain write path calls this.
679    ///
680    /// The default delegates to [`write_batch_reported`](Self::write_batch_reported)
681    /// with the page's records, so existing sinks behave as before. A
682    /// [`GovernedPage`](crate::governance::GovernedPage) cannot be built
683    /// outside [`governance`](crate::governance), so a sink that overrides
684    /// this receives only governed data. Decorators forward it.
685    async fn write_governed(
686        &self,
687        page: &crate::governance::GovernedPage<'_>,
688    ) -> Result<crate::accounting::WriteReport, FaucetError> {
689        self.write_batch_reported(page.records()).await
690    }
691
692    /// What a failed batch write leaves behind (#737): nothing
693    /// ([`Atomic`](crate::BatchAtomicity::Atomic)), per-row outcomes with
694    /// nothing committed on an outer `Err`
695    /// ([`PerRow`](crate::BatchAtomicity::PerRow)), or possibly some rows
696    /// ([`BestEffort`](crate::BatchAtomicity::BestEffort), the default).
697    ///
698    /// `on_batch_error: dlq_all` is refused on a best-effort sink unless it
699    /// writes by key or the user opts in to the duplicates. Decorators must
700    /// forward this.
701    fn batch_atomicity(&self) -> crate::dlq::BatchAtomicity {
702        crate::dlq::BatchAtomicity::BestEffort
703    }
704
705    /// Called with a whole page before the pipeline splits it into adaptive
706    /// sub-batches. A sink enforcing a ceiling that must hold for the page as
707    /// a unit (a run budget) refuses here, so no sub-batch of a refused page
708    /// lands. Default: accept. Decorators must forward this.
709    async fn admit_page(&self, records: &[Value]) -> Result<(), FaucetError> {
710        let _ = records;
711        Ok(())
712    }
713
714    /// Whether this sink can consume **columnar** (`arrow::RecordBatch`) writes
715    /// via [`write_batch_columnar`](Self::write_batch_columnar) without first
716    /// converting to `Value`. Default: `false`.
717    ///
718    /// The pipeline takes the columnar fast path only when *both* the source and
719    /// sink return `true` (RFC 0002 / #375).
720    #[cfg(feature = "arrow")]
721    fn supports_columnar(&self) -> bool {
722        false
723    }
724
725    /// Write a columnar `RecordBatch` to the destination, returning the number of
726    /// rows written.
727    ///
728    /// The default converts the batch to `Value` rows via the
729    /// [`columnar`](crate::columnar) shim and delegates to
730    /// [`write_batch`](Self::write_batch), so every sink participates correctly
731    /// even without a native columnar path. Sinks that write Arrow/Parquet
732    /// directly override this — and return `true` from
733    /// [`supports_columnar`](Self::supports_columnar) — to skip the conversion.
734    #[cfg(feature = "arrow")]
735    async fn write_batch_columnar(
736        &self,
737        batch: &arrow::array::RecordBatch,
738    ) -> Result<usize, FaucetError> {
739        let rows = crate::columnar::record_batch_to_values(batch)?;
740        self.write_batch(&rows).await
741    }
742
743    /// Native byte-passthrough load mechanisms this sink offers, each naming
744    /// the wire format and the write modes it can honor (#633). Default:
745    /// `vec![]` (no fast path; use [`write_batch`](Self::write_batch)).
746    ///
747    /// The pipeline takes the native path only when a source advertises a
748    /// format this returns and the run passes the pipeline-owned gates — no
749    /// transforms, no quality/contract/masking pass, no DLQ, at-least-once
750    /// delivery, no upsert/delete `write_mode`, and (for overwrite) an
751    /// all-or-nothing load session finalized by a single terminal `flush` —
752    /// see [`plan_native_transfer`](crate::plan_native_transfer). A capability
753    /// cannot waive those gates.
754    fn native_load_capabilities(&self) -> Vec<crate::native::NativeLoadCapability> {
755        Vec::new()
756    }
757
758    /// Bulk-load one native-format byte batch directly (#633), returning the
759    /// number of rows written.
760    ///
761    /// Only invoked when the pipeline negotiated one of this sink's
762    /// [`native_load_capabilities`](Self::native_load_capabilities) and the
763    /// pipeline-owned gates passed. `ctx.first_batch` lets an overwrite sink
764    /// truncate on the first load and append thereafter. The default returns a
765    /// typed "unsupported" error so a sink that advertises a capability but
766    /// forgets to override this fails loudly.
767    ///
768    /// **Contract:** an **empty payload must be a successful no-op** returning
769    /// `Ok(0)` — sources emit a trailing empty batch to carry the final
770    /// bookmark, and an error here would break bookmark persistence. Under
771    /// `Overwrite`, nothing may become visible in the destination until the
772    /// terminal [`flush`](Self::flush) — the pipeline flushes exactly once, on
773    /// success only, so a mechanism that commits per batch must omit
774    /// `Overwrite` from its capability's `write_modes`.
775    async fn load_native(
776        &self,
777        batch: crate::native::NativeBatch,
778        scope: &str,
779        ctx: crate::native::NativeLoadContext,
780    ) -> Result<usize, FaucetError> {
781        let _ = (batch, scope, ctx);
782        Err(FaucetError::Sink(format!(
783            "sink '{}' does not support native byte loading (load_native)",
784            self.connector_name()
785        )))
786    }
787
788    /// Whether this sink can durably commit a page's rows **and** a commit token
789    /// in a single atomic transaction. Default: `false` (at-least-once only).
790    ///
791    /// Only return `true` from a sink that genuinely commits both atomically —
792    /// see [`write_batch_idempotent`](Self::write_batch_idempotent). The pipeline
793    /// rejects `DeliveryMode::ExactlyOnce` against a sink that returns `false`.
794    fn supports_idempotent_writes(&self) -> bool {
795        false
796    }
797
798    /// The strongest delivery guarantee this sink can uphold — see
799    /// [`SinkGuarantee`](crate::SinkGuarantee).
800    ///
801    /// The default derives from the two back-compat primitives:
802    /// [`supports_idempotent_writes`](Self::supports_idempotent_writes) →
803    /// `AtomicWatermark`, else an upsert-capable
804    /// [`supported_write_modes`](Self::supported_write_modes) → `KeyedUpsert`,
805    /// else `AtLeastOnce`. Existing connectors that override only the
806    /// primitives automatically advertise the right capability here.
807    fn sink_guarantee(&self) -> crate::idempotency::SinkGuarantee {
808        if self.supports_idempotent_writes() {
809            crate::idempotency::SinkGuarantee::AtomicWatermark
810        } else if self
811            .supported_write_modes()
812            .contains(&crate::write_mode::WriteMode::Upsert)
813        {
814            crate::idempotency::SinkGuarantee::KeyedUpsert
815        } else {
816            crate::idempotency::SinkGuarantee::AtLeastOnce
817        }
818    }
819
820    /// What this sink can do (RFC 0013, #876). The default derives every field
821    /// from the per-capability methods
822    /// ([`Capabilities::of_sink`](crate::Capabilities::of_sink)); override it
823    /// to declare a capability no method expresses (how unknown fields are
824    /// treated, scoped overwrite), keeping the connector's
825    /// [`ConnectorDescriptor`](crate::ConnectorDescriptor) in agreement.
826    /// Decorators must forward this.
827    fn capabilities(&self) -> crate::capabilities::Capabilities {
828        crate::capabilities::Capabilities::of_sink(self)
829    }
830
831    /// Whether a **plain [`write_batch`](Self::write_batch) is safe to replay**
832    /// — i.e. re-sending the same page after an ambiguous failure converges
833    /// instead of duplicating rows. Default: `false`.
834    ///
835    /// This is the property the pipeline needs before it may retry a
836    /// non-idempotent write, and it is **not** the same as
837    /// [`supports_idempotent_writes`](Self::supports_idempotent_writes), which
838    /// promises only that rows *and a commit token* can be committed in one
839    /// transaction (the [`write_batch_idempotent`](Self::write_batch_idempotent)
840    /// path). A sink can offer that token protocol and still have a plain
841    /// `write_batch` that is a bare multi-row `INSERT` — replaying it after the
842    /// server committed but the response was lost duplicates every row, which
843    /// is this repo's worst bug class (F29/F32).
844    ///
845    /// The default implementation is therefore conservative and derives from
846    /// the live config: a keyed upsert/delete converges on replay, anything
847    /// else does not. Override only for a sink whose plain write is genuinely
848    /// replay-safe by construction.
849    fn write_batch_is_replay_safe(&self) -> bool {
850        self.dedups_by_key()
851    }
852
853    /// Whether this sink instance is **configured** to dedup by key — i.e.
854    /// `write_mode: upsert` (or `delete`) with a non-empty `key`, so
855    /// re-applying a record with the same key converges instead of
856    /// duplicating. Default: `false`.
857    ///
858    /// Distinct from [`sink_guarantee`](Self::sink_guarantee) (capability):
859    /// this reflects the *live config*. Sinks that flatten a
860    /// [`WriteSpec`](crate::write_mode::WriteSpec) into their config override
861    /// it as `self.config.write.dedups_by_key()`. The pipeline consults it to
862    /// derive the keyed-upsert effectively-once mechanism at run time.
863    fn dedups_by_key(&self) -> bool {
864        false
865    }
866
867    /// Write modes this sink can apply. Default: append-only. Sinks that
868    /// implement key-based merge override this to include
869    /// [`WriteMode::Upsert`](crate::write_mode::WriteMode) /
870    /// [`WriteMode::Delete`](crate::write_mode::WriteMode). The CLI rejects a
871    /// configured mode that is not in this set at config-load time.
872    fn supported_write_modes(&self) -> &'static [crate::write_mode::WriteMode] {
873        &[crate::write_mode::WriteMode::Append]
874    }
875
876    /// The sink's live destination schema as an `infer_schema`-shaped object
877    /// (`{"type":"object","properties":{ <col>: <type-fragment>, … }}`), or
878    /// `None` for a schemaless sink or a target that does not exist yet.
879    ///
880    /// Used by the schema-drift policy to diff each page's shape against the
881    /// real destination. Default: `Ok(None)` (drift handling is inert).
882    async fn current_schema(&self) -> Result<Option<Value>, FaucetError> {
883        Ok(None)
884    }
885
886    /// Whether this sink can apply additive/widening DDL via
887    /// [`evolve_schema`](Self::evolve_schema). Default: `false`. The CLI rejects
888    /// `on_drift: evolve` against a sink that returns `false` at config-load.
889    fn supports_schema_evolution(&self) -> bool {
890        false
891    }
892
893    /// Apply an additive schema evolution (new columns, lossless widenings,
894    /// nullability relaxations) to the destination. MUST be idempotent
895    /// (`ADD COLUMN IF NOT EXISTS` semantics) so concurrent runs converge.
896    ///
897    /// Default: a typed "unsupported" error. Override only when the backend
898    /// supports in-place additive DDL (and return `true` from
899    /// `supports_schema_evolution`).
900    async fn evolve_schema(
901        &self,
902        evolution: &crate::drift::SchemaEvolution,
903    ) -> Result<(), FaucetError> {
904        let _ = evolution;
905        Err(FaucetError::Sink(format!(
906            "sink '{}' does not support schema evolution",
907            self.connector_name()
908        )))
909    }
910
911    /// Whether this sink can delete a scoped set of rows for scoped cleanup
912    /// (#478). Default `false`; the upsert-capable sinks override it.
913    fn supports_cleanup(&self) -> bool {
914        false
915    }
916
917    /// Whether this sink can bulk-load via an object-store stage — write the
918    /// page to S3/GCS/Azure, then pull it with the warehouse's native load
919    /// command (`COPY` / `COPY INTO` / `s3()` table function) (#528). Default
920    /// `false`; warehouse sinks that honour a [`StagingSpec`](crate::StagingSpec)
921    /// override it. Object-safe (no args, no generics) so `Box<dyn Sink>` is
922    /// unaffected.
923    fn supports_staged_load(&self) -> bool {
924        false
925    }
926
927    /// Delete rows matching `scope` whose key is **not** in `seen`.
928    ///
929    /// Called at most once per invocation, only after the run completed
930    /// successfully and uncancelled — see [`crate::cleanup`] for why the timing
931    /// is load-bearing. `scope` is a set of equality predicates in destination
932    /// column terms, AND-ed together; `seen` holds the key tuples this run wrote.
933    /// An **empty** `scope` is the whole destination: a complete-read cleanup
934    /// ([`CleanupPolicy::for_complete_read`](crate::CleanupPolicy::for_complete_read)).
935    ///
936    /// Returns the number of rows deleted. Implementations **must** be
937    /// all-or-nothing where the backend allows it: a partial delete would remove
938    /// rows the run actually wrote.
939    ///
940    /// The default is a typed "unsupported" error, so no existing or third-party
941    /// connector breaks.
942    #[deprecated(
943        since = "1.14.0",
944        note = "implement `faucet_core::driver::CleanupDriver` on a family driver; see RFC 0014"
945    )]
946    async fn cleanup_scope(
947        &self,
948        scope: &std::collections::BTreeMap<String, Value>,
949        seen: &crate::cleanup::SeenKeys,
950    ) -> Result<u64, FaucetError> {
951        let _ = (scope, seen);
952        Err(FaucetError::Sink(format!(
953            "sink '{}' does not support scoped cleanup",
954            self.connector_name()
955        )))
956    }
957
958    /// Write `records` AND durably record `token` for `scope`, atomically.
959    ///
960    /// `scope` namespaces the watermark (the pipeline passes the per-row state
961    /// key, e.g. `"{name}::{row_id}"`). `token` is a monotonic, fixed-width
962    /// string (see [`format_token`](crate::format_token)).
963    ///
964    /// The default is **not** idempotent: it ignores the token and delegates to
965    /// [`write_batch`](Self::write_batch). Override only when the commit is
966    /// genuinely atomic (and return `true` from `supports_idempotent_writes`).
967    #[deprecated(
968        since = "1.14.0",
969        note = "implement `faucet_core::driver::CommitTokenDriver` on a family driver; see RFC 0014"
970    )]
971    async fn write_batch_idempotent(
972        &self,
973        records: &[Value],
974        scope: &str,
975        token: &str,
976    ) -> Result<usize, FaucetError> {
977        let _ = (scope, token);
978        self.write_batch(records).await
979    }
980
981    /// [`write_batch_idempotent`](Self::write_batch_idempotent) with a
982    /// [`WriteReport`](crate::accounting::WriteReport): the exactly-once path
983    /// calls this, and a page whose report does not account for every row it
984    /// offered fails with [`FaucetError::RowsUnaccounted`] before its bookmark
985    /// is persisted (#879). Override it when the sink declines rows by design
986    /// inside the atomic commit (a record without a key under a skip policy)
987    /// and report them as `skipped`.
988    ///
989    /// The default credits the rows `write_batch_idempotent` returns — as
990    /// coalesced-by-key when [`dedups_by_key`](Self::dedups_by_key) — so a
991    /// sink that silently writes fewer rows than it was offered fails the page.
992    #[deprecated(
993        since = "1.14.0",
994        note = "implement `faucet_core::driver::CommitTokenDriver` on a family driver; see RFC 0014"
995    )]
996    #[allow(deprecated)] // drives the 1.x lifecycle hook until faucet-core 2.0 (RFC 0014)
997    async fn write_batch_idempotent_reported(
998        &self,
999        records: &[Value],
1000        scope: &str,
1001        token: &str,
1002    ) -> Result<crate::accounting::WriteReport, FaucetError> {
1003        let n = self.write_batch_idempotent(records, scope, token).await?;
1004        Ok(if self.dedups_by_key() {
1005            crate::accounting::WriteReport::keyed(records.len(), n)
1006        } else {
1007            crate::accounting::WriteReport::accepted(n)
1008        })
1009    }
1010
1011    /// The last token durably committed for `scope`, or `None` if this sink has
1012    /// never committed under that scope. Default: `None`.
1013    #[deprecated(
1014        since = "1.14.0",
1015        note = "implement `faucet_core::driver::CommitTokenDriver` on a family driver; see RFC 0014"
1016    )]
1017    async fn last_committed_token(&self, scope: &str) -> Result<Option<String>, FaucetError> {
1018        let _ = scope;
1019        Ok(None)
1020    }
1021
1022    /// Whether this sink instance is configured for full-destination
1023    /// replacement ([`WriteMode::Overwrite`](crate::write_mode::WriteMode)).
1024    ///
1025    /// The pipeline consults this to drive the overwrite lifecycle:
1026    /// [`begin_overwrite`](Self::begin_overwrite) before the first page, then
1027    /// [`commit_overwrite`](Self::commit_overwrite) once the run finishes
1028    /// successfully, or [`abort_overwrite`](Self::abort_overwrite) on
1029    /// failure/cancel. Sinks that flatten a [`WriteSpec`](crate::write_mode::WriteSpec)
1030    /// into their config return `self.config.write.is_overwrite()`. Default `false`.
1031    fn is_overwrite(&self) -> bool {
1032        false
1033    }
1034
1035    /// Prepare a staging target for an overwrite run.
1036    ///
1037    /// Called once, before the first [`write_batch`](Self::write_batch), only
1038    /// when [`is_overwrite`](Self::is_overwrite) is true. The sink stages this
1039    /// run's writes (a temp table / new index / temp prefix) so the existing
1040    /// destination is untouched until the run succeeds. Subsequent
1041    /// `write_batch` calls for this sink must land in the staging target.
1042    ///
1043    /// Default: a typed "unsupported" error, so a sink that advertises
1044    /// `WriteMode::Overwrite` but forgets to implement the lifecycle fails
1045    /// loudly rather than silently appending. Never called for sinks whose
1046    /// `is_overwrite()` is false.
1047    #[deprecated(
1048        since = "1.14.0",
1049        note = "implement `faucet_core::driver::StagingDriver` on a family driver; see RFC 0014"
1050    )]
1051    async fn begin_overwrite(&self) -> Result<(), FaucetError> {
1052        Err(FaucetError::Sink(format!(
1053            "sink '{}' does not support write_mode: overwrite",
1054            self.connector_name()
1055        )))
1056    }
1057
1058    /// Atomically replace the destination with the staged data.
1059    ///
1060    /// Called **once, only after the run completed successfully and
1061    /// uncancelled**. Implementations MUST swap staging → destination
1062    /// atomically (or as close as the backend allows) so a reader never sees a
1063    /// half-replaced dataset, and MUST NOT have destroyed the prior contents
1064    /// before this point — a failed run leaves the old data in place.
1065    ///
1066    /// Default: a typed "unsupported" error (unreachable for a correct sink
1067    /// whose `is_overwrite()` is false).
1068    #[deprecated(
1069        since = "1.14.0",
1070        note = "implement `faucet_core::driver::StagingDriver` on a family driver; see RFC 0014"
1071    )]
1072    async fn commit_overwrite(&self) -> Result<(), FaucetError> {
1073        Err(FaucetError::Sink(format!(
1074            "sink '{}' does not support write_mode: overwrite",
1075            self.connector_name()
1076        )))
1077    }
1078
1079    /// Whether this sink can undo a run it wrote (#706) through
1080    /// [`rollback_run`](Self::rollback_run). Default `false`.
1081    fn supports_rollback(&self) -> bool {
1082        false
1083    }
1084
1085    /// Undo everything `run_id` wrote to this destination, per
1086    /// `opts.mode`: delete the run's rows (append), restore journaled
1087    /// before-images (upsert / delete), or swap the kept previous table back
1088    /// (overwrite). Must be all-or-nothing per destination where the backend
1089    /// allows it, and must leave the destination untouched when `opts.dry_run`
1090    /// is set or when conflicts block it (see
1091    /// [`RollbackOutcome`](crate::rollback::RollbackOutcome)).
1092    ///
1093    /// Default: a typed "unsupported" error.
1094    async fn rollback_run(
1095        &self,
1096        run_id: &str,
1097        opts: &crate::rollback::RollbackOptions,
1098    ) -> Result<crate::rollback::RollbackOutcome, FaucetError> {
1099        let _ = (run_id, opts);
1100        Err(FaucetError::Sink(format!(
1101            "sink '{}' does not support rollback",
1102            self.connector_name()
1103        )))
1104    }
1105
1106    /// Drop whatever this sink kept to make `run_id` undoable (journal rows, a
1107    /// previous table) once the run is past the retention window. Default:
1108    /// no-op.
1109    async fn forget_run(&self, run_id: &str) -> Result<(), FaucetError> {
1110        let _ = run_id;
1111        Ok(())
1112    }
1113
1114    /// Rewind the exactly-once watermark for `scope` to `token` (`None` clears
1115    /// it), so a rolled-back run's pages are not skipped on the next run.
1116    /// Default: a typed "unsupported" error.
1117    async fn rewind_commit_token(
1118        &self,
1119        scope: &str,
1120        token: Option<&str>,
1121    ) -> Result<(), FaucetError> {
1122        let _ = (scope, token);
1123        Err(FaucetError::Sink(format!(
1124            "sink '{}' does not support rewinding its commit token",
1125            self.connector_name()
1126        )))
1127    }
1128
1129    /// A source `(kind, config)` that reads this destination back — what
1130    /// `faucet verify` (#701) compares the pipeline's source against. Default
1131    /// `None`: the user names the destination reader in `verify.destination`.
1132    /// The config must not need a credential the sink config lacks.
1133    fn readback_source(&self) -> Option<(String, Value)> {
1134        None
1135    }
1136
1137    /// Discard the staging target after a failed or cancelled overwrite run.
1138    ///
1139    /// Called (best-effort) when an overwrite run does not reach
1140    /// [`commit_overwrite`](Self::commit_overwrite). The destination must be
1141    /// left exactly as it was before the run. Default: no-op — a leftover
1142    /// staging object is untidy but never data loss, so a sink may skip it.
1143    #[deprecated(
1144        since = "1.14.0",
1145        note = "implement `faucet_core::driver::StagingDriver` on a family driver; see RFC 0014"
1146    )]
1147    async fn abort_overwrite(&self) -> Result<(), FaucetError> {
1148        Ok(())
1149    }
1150
1151    /// Finalize this run's output after [`Pipeline::run`](crate::Pipeline::run)
1152    /// finished **successfully and uncancelled**, on every transfer path
1153    /// (`Value`, columnar, native), after the terminal flush.
1154    ///
1155    /// A sink whose destination must reflect *this* run even when it wrote
1156    /// nothing uses it: the file sinks with `append: false` truncate (or, for
1157    /// a fixed-path Parquet file, remove) the previous run's output here when
1158    /// no record arrived, so a source that became empty never leaves stale
1159    /// rows presented as current (#753). Never called after a failed or
1160    /// cancelled run, so the previous good output survives those. Decorators
1161    /// must forward it. Default: no-op.
1162    async fn complete_run(&self) -> Result<(), FaucetError> {
1163        Ok(())
1164    }
1165
1166    /// Whether an overwrite staging object (the `…__faucet_ovw` table or
1167    /// collection a [`begin_overwrite`](Self::begin_overwrite) creates) exists
1168    /// right now — a read-only probe used by `faucet status --probe` to report
1169    /// staging a crashed or aborted overwrite left behind.
1170    ///
1171    /// `Ok(None)` means the sink cannot tell (the default); overwrite-capable
1172    /// sinks return `Ok(Some(_))`.
1173    #[deprecated(
1174        since = "1.14.0",
1175        note = "implement `faucet_core::driver::StagingDriver` on a family driver; see RFC 0014"
1176    )]
1177    async fn overwrite_staging_exists(&self) -> Result<Option<bool>, FaucetError> {
1178        Ok(None)
1179    }
1180
1181    /// Return a JSON Schema describing the configuration this sink accepts.
1182    ///
1183    /// The schema is auto-generated from the config struct using `schemars`.
1184    /// Callers can inspect it to discover required fields, types, defaults,
1185    /// and descriptions before constructing the sink.
1186    ///
1187    /// The default returns an empty object schema.
1188    fn config_schema(&self) -> Value {
1189        serde_json::json!({"type": "object", "properties": {}})
1190    }
1191
1192    /// Stable identifier used as the `connector` label on metrics and the
1193    /// `connector` attribute on spans. See `Source::connector_name`.
1194    fn connector_name(&self) -> &'static str {
1195        crate::observability::strip_type_name(std::any::type_name::<Self>())
1196    }
1197
1198    /// Receive a pre-labelled handle for counting **upstream round trips**
1199    /// (#638). See [`Source::set_roundtrip_recorder`]; defaulted to a no-op.
1200    fn set_roundtrip_recorder(
1201        &self,
1202        _recorder: std::sync::Arc<crate::observability::RoundtripRecorder>,
1203    ) {
1204    }
1205
1206    /// Receive the columns the run's source declared (#955), before the first
1207    /// write. A sink that creates its destination types the created columns
1208    /// from them (see [`crate::declared`]) and, when the declaration is
1209    /// complete and the run wrote nothing, creates the empty typed destination
1210    /// in [`complete_run`](Self::complete_run). An existing destination is
1211    /// never retyped. Such a sink declares `declared_types` in its
1212    /// capabilities. Defaulted to a no-op; a wrapping sink forwards it (one
1213    /// that adds columns declares them).
1214    fn set_declared_columns(&self, _columns: crate::declared::DeclaredColumns) {}
1215
1216    /// Receive the run's [`CallContext`](crate::io::CallContext) (#880). See
1217    /// [`Source::set_call_context`]. A sink's context never carries the run's
1218    /// cancel token — a cancelled run still flushes its sinks — nor a retry
1219    /// policy: writes are retried page by page under ADR 0007. Defaulted to a
1220    /// no-op; a wrapping sink forwards it.
1221    fn set_call_context(&self, _ctx: crate::io::CallContext) {}
1222
1223    /// Logical dataset identity for lineage emission, following OpenLineage
1224    /// naming conventions (<https://openlineage.io/docs/spec/naming>).
1225    ///
1226    /// The default returns `"<connector_name>://unknown"`. Built-in connectors
1227    /// override with a credential-free URI derived from their config. Strip any
1228    /// credentials with [`redact_uri_credentials`](crate::redact_uri_credentials).
1229    /// Informational metadata only — never used for I/O.
1230    fn dataset_uri(&self) -> String {
1231        format!("{}://unknown", self.connector_name())
1232    }
1233
1234    /// The concrete **local files** this sink instance opened during the run.
1235    ///
1236    /// The provenance record faucet's local-output retention GC (#587) deletes
1237    /// from: it may only remove files faucet recorded here, never a glob or a
1238    /// directory. A sink that writes local files (jsonl, csv, parquet to a local
1239    /// destination) accumulates a
1240    /// [`LocalOutputLog`](crate::local_outputs::LocalOutputLog) as it opens them
1241    /// and returns its snapshot; every other sink keeps the empty default, which
1242    /// simply means "nothing local to collect".
1243    ///
1244    /// Two properties the GC depends on, so an implementation must preserve them:
1245    ///
1246    /// 1. **Every file, individually named.** A rolling parquet sink returns one
1247    ///    entry per UUID-named part — the directory itself is not an output.
1248    /// 2. **[`LocalOutput::pre_existing`](crate::local_outputs::LocalOutput) is
1249    ///    honest**, captured before the first open. A file faucet appended to but
1250    ///    did not create is never collected.
1251    ///
1252    /// Called after the run (and by `faucet cleanup`), never on the data path, so
1253    /// an implementation may take a lock. Decorating sinks **must forward** this
1254    /// to their inner sink, exactly as they do
1255    /// [`dataset_uri`](Self::dataset_uri) — a decorator that returns the default
1256    /// hides its inner sink's files from the GC and they are never reclaimed.
1257    async fn local_outputs(&self) -> Vec<crate::local_outputs::LocalOutput> {
1258        Vec::new()
1259    }
1260
1261    /// Run a fast, non-mutating preflight probe (used by `faucet doctor`).
1262    ///
1263    /// Unlike sources, a sink has no non-mutating "first page" equivalent
1264    /// (`write_batch` mutates the destination), so the default returns
1265    /// [`CheckReport::not_implemented`](crate::check::CheckReport::not_implemented).
1266    /// Built-in sinks override this with a connect / auth / metadata probe.
1267    ///
1268    /// The probe **MUST be idempotent and side-effect-free** — no inserts, no
1269    /// residual rows or objects — and must never put credentials or connection
1270    /// strings in a probe `reason`/`hint`.
1271    async fn check(
1272        &self,
1273        _ctx: &crate::check::CheckContext,
1274    ) -> Result<crate::check::CheckReport, FaucetError> {
1275        Ok(crate::check::CheckReport::not_implemented())
1276    }
1277}
1278
1279#[allow(deprecated)] // the tests drive the 1.x lifecycle hooks directly (RFC 0014)
1280#[cfg(test)]
1281mod tests {
1282    use super::*;
1283    use serde_json::json;
1284
1285    // ── Mock Source ──────────────────────────────────────────────────────────
1286
1287    struct MockSource {
1288        records: Vec<Value>,
1289    }
1290
1291    #[async_trait]
1292    impl Source for MockSource {
1293        async fn fetch_with_context(
1294            &self,
1295            _context: &std::collections::HashMap<String, Value>,
1296        ) -> Result<Vec<Value>, FaucetError> {
1297            Ok(self.records.clone())
1298        }
1299    }
1300
1301    #[test]
1302    fn default_migrate_state_accepts_only_its_own_schema() {
1303        let src = MockSource { records: vec![] };
1304        assert_eq!(src.state_schema(), 0);
1305        assert_eq!(
1306            src.migrate_state(0, json!({"id": 1})).unwrap(),
1307            json!({"id": 1})
1308        );
1309        let err = src.migrate_state(3, json!({})).unwrap_err().to_string();
1310        assert!(
1311            err.contains("no migration from bookmark schema 3 to 0"),
1312            "{err}"
1313        );
1314    }
1315
1316    #[test]
1317    fn default_multi_table_hooks_route_nothing_and_order_nothing() {
1318        let src = MockSource { records: vec![] };
1319        assert_eq!(src.record_table(&json!({"table": "t"})), None);
1320        assert_eq!(src.position_le(&json!(1), &json!(2)), None);
1321        assert_eq!(src.record_covered(&json!({}), &json!(1)), None);
1322        assert_eq!(src.position_min(&[]), None);
1323        assert_eq!(src.position_min(&[json!(3), json!(3)]), Some(json!(3)));
1324        assert_eq!(src.position_min(&[json!(1), json!(2)]), None);
1325    }
1326
1327    #[tokio::test]
1328    async fn ordered_double_fetches_nothing() {
1329        use crate::Source as _;
1330        assert!(
1331            OrderedSource
1332                .fetch_with_context(&Default::default())
1333                .await
1334                .unwrap()
1335                .is_empty()
1336        );
1337    }
1338
1339    struct OrderedSource;
1340
1341    #[async_trait]
1342    impl Source for OrderedSource {
1343        async fn fetch_with_context(
1344            &self,
1345            _context: &std::collections::HashMap<String, Value>,
1346        ) -> Result<Vec<Value>, FaucetError> {
1347            Ok(vec![])
1348        }
1349
1350        fn position_le(&self, a: &Value, b: &Value) -> Option<bool> {
1351            Some(a.as_u64()? <= b.as_u64()?)
1352        }
1353    }
1354
1355    #[test]
1356    fn default_position_min_uses_position_le() {
1357        let src = OrderedSource;
1358        assert_eq!(
1359            src.position_min(&[json!(5), json!(2), json!(9)]),
1360            Some(json!(2))
1361        );
1362        assert_eq!(src.position_min(&[json!(5), json!("x")]), None);
1363    }
1364
1365    struct IncrementalSource {
1366        records: Vec<Value>,
1367        bookmark: Value,
1368    }
1369
1370    #[async_trait]
1371    impl Source for IncrementalSource {
1372        async fn fetch_with_context(
1373            &self,
1374            _context: &std::collections::HashMap<String, Value>,
1375        ) -> Result<Vec<Value>, FaucetError> {
1376            Ok(self.records.clone())
1377        }
1378
1379        async fn fetch_with_context_incremental(
1380            &self,
1381            _context: &std::collections::HashMap<String, Value>,
1382        ) -> Result<(Vec<Value>, Option<Value>), FaucetError> {
1383            Ok((self.records.clone(), Some(self.bookmark.clone())))
1384        }
1385    }
1386
1387    struct FailingSource;
1388
1389    #[async_trait]
1390    impl Source for FailingSource {
1391        async fn fetch_with_context(
1392            &self,
1393            _context: &std::collections::HashMap<String, Value>,
1394        ) -> Result<Vec<Value>, FaucetError> {
1395            Err(FaucetError::Auth("no credentials".into()))
1396        }
1397    }
1398
1399    // ── Mock Sink ───────────────────────────────────────────────────────────
1400
1401    struct MockSink {
1402        written: std::sync::Mutex<Vec<Value>>,
1403    }
1404
1405    impl MockSink {
1406        fn new() -> Self {
1407            Self {
1408                written: std::sync::Mutex::new(Vec::new()),
1409            }
1410        }
1411    }
1412
1413    #[async_trait]
1414    impl Sink for MockSink {
1415        async fn write_batch(&self, records: &[Value]) -> Result<usize, FaucetError> {
1416            let mut w = self.written.lock().unwrap();
1417            w.extend(records.iter().cloned());
1418            Ok(records.len())
1419        }
1420    }
1421
1422    struct FailingSink;
1423
1424    #[async_trait]
1425    impl Sink for FailingSink {
1426        async fn write_batch(&self, _records: &[Value]) -> Result<usize, FaucetError> {
1427            Err(FaucetError::Sink("write failed".into()))
1428        }
1429    }
1430
1431    // ── Native byte-passthrough defaults (#633) ──────────────────────────────
1432
1433    /// A source/sink pair that advertises nothing native, so the defaulted
1434    /// trait methods are what answer.
1435    struct PlainSink;
1436
1437    #[async_trait]
1438    impl Sink for PlainSink {
1439        async fn write_batch(&self, records: &[Value]) -> Result<usize, FaucetError> {
1440            Ok(records.len())
1441        }
1442    }
1443
1444    #[tokio::test]
1445    async fn native_defaults_advertise_nothing_and_fail_loudly() {
1446        use futures::StreamExt as _;
1447
1448        // A source that never overrides the native methods advertises no
1449        // formats, so the pipeline can never negotiate the byte path…
1450        let src = MockSource { records: vec![] };
1451        assert!(src.native_output_formats().is_empty());
1452        // …and calling it anyway yields one typed error rather than silently
1453        // producing an empty stream (which would look like "no rows").
1454        let ctx = std::collections::HashMap::new();
1455        let mut batches = src.stream_native(&ctx, crate::native::NativeFormat::NdJson, 10);
1456        let err = batches
1457            .next()
1458            .await
1459            .expect("one item")
1460            .expect_err("default must error");
1461        assert!(
1462            err.to_string()
1463                .contains("does not support native byte streaming"),
1464            "{err}"
1465        );
1466        assert!(batches.next().await.is_none(), "exactly one item");
1467
1468        // Same on the sink side: no capabilities, and `load_native` is a typed
1469        // error so a sink that advertises but forgets to implement is obvious.
1470        let sink = PlainSink;
1471        assert!(sink.native_load_capabilities().is_empty());
1472        let err = sink
1473            .load_native(
1474                crate::native::NativeBatch::bytes(
1475                    crate::native::NativeFormat::NdJson,
1476                    b"{}\n".to_vec(),
1477                ),
1478                "scope",
1479                crate::native::NativeLoadContext {
1480                    write_mode: crate::write_mode::WriteMode::Append,
1481                    first_batch: true,
1482                },
1483            )
1484            .await
1485            .expect_err("default must error");
1486        assert!(
1487            err.to_string()
1488                .contains("does not support native byte loading"),
1489            "{err}"
1490        );
1491    }
1492
1493    #[tokio::test]
1494    async fn default_rollback_and_readback_hooks() {
1495        let sink = MockSink::new();
1496        assert!(!sink.supports_rollback());
1497        let opts = crate::rollback::RollbackOptions {
1498            run_id_column: "_faucet_run_id".into(),
1499            mode: crate::rollback::RollbackMode::Append,
1500            force: false,
1501            dry_run: false,
1502            later_runs: false,
1503        };
1504        let err = sink.rollback_run("r1", &opts).await.unwrap_err();
1505        assert!(
1506            err.to_string().contains("does not support rollback"),
1507            "{err}"
1508        );
1509        assert!(sink.forget_run("r1").await.is_ok());
1510        let err = sink.rewind_commit_token("s", None).await.unwrap_err();
1511        assert!(err.to_string().contains("commit token"), "{err}");
1512        assert!(sink.readback_source().is_none());
1513        let source = MockSource { records: vec![] };
1514        let digest = source
1515            .range_digest(&crate::diff::KeyRange::ALL, "id", &["v".into()])
1516            .await
1517            .unwrap();
1518        assert!(digest.is_none(), "not supported by default");
1519    }
1520
1521    #[tokio::test]
1522    async fn default_overwrite_methods_reject_or_noop() {
1523        // A sink that does not opt into overwrite (MockSink uses the defaults):
1524        // `is_overwrite` is false, begin/commit return the typed "unsupported"
1525        // error, and abort is a no-op success.
1526        let sink = MockSink::new();
1527        assert!(!sink.is_overwrite());
1528        assert!(sink.begin_overwrite().await.is_err());
1529        assert!(sink.commit_overwrite().await.is_err());
1530        assert!(sink.abort_overwrite().await.is_ok());
1531        assert_eq!(sink.overwrite_staging_exists().await.unwrap(), None);
1532    }
1533
1534    // ── Source tests ────────────────────────────────────────────────────────
1535
1536    #[tokio::test]
1537    async fn source_fetch_all_returns_records() {
1538        let source = MockSource {
1539            records: vec![json!({"id": 1}), json!({"id": 2})],
1540        };
1541        let records = source.fetch_all().await.unwrap();
1542        assert_eq!(records.len(), 2);
1543        assert_eq!(records[0]["id"], 1);
1544    }
1545
1546    #[tokio::test]
1547    async fn source_fetch_all_empty() {
1548        let source = MockSource { records: vec![] };
1549        let records = source.fetch_all().await.unwrap();
1550        assert!(records.is_empty());
1551    }
1552
1553    #[tokio::test]
1554    async fn source_default_incremental_returns_none_bookmark() {
1555        let source = MockSource {
1556            records: vec![json!({"id": 1})],
1557        };
1558        let (records, bookmark) = source.fetch_all_incremental().await.unwrap();
1559        assert_eq!(records.len(), 1);
1560        assert!(bookmark.is_none());
1561    }
1562
1563    #[tokio::test]
1564    async fn source_custom_incremental_returns_bookmark() {
1565        let source = IncrementalSource {
1566            records: vec![json!({"id": 1})],
1567            bookmark: json!("2024-12-01"),
1568        };
1569        let (records, bookmark) = source.fetch_all_incremental().await.unwrap();
1570        assert_eq!(records.len(), 1);
1571        assert_eq!(bookmark, Some(json!("2024-12-01")));
1572    }
1573
1574    #[tokio::test]
1575    async fn source_error_propagates() {
1576        let source = FailingSource;
1577        let result = source.fetch_all().await;
1578        assert!(result.is_err());
1579        assert!(matches!(result, Err(FaucetError::Auth(_))));
1580    }
1581
1582    #[tokio::test]
1583    async fn source_as_trait_object() {
1584        let source: Box<dyn Source> = Box::new(MockSource {
1585            records: vec![json!({"id": 42})],
1586        });
1587        let records = source.fetch_all().await.unwrap();
1588        assert_eq!(records[0]["id"], 42);
1589    }
1590
1591    // ── Sink tests ──────────────────────────────────────────────────────────
1592
1593    #[tokio::test]
1594    async fn sink_write_batch_returns_count() {
1595        let sink = MockSink::new();
1596        let records = vec![json!({"id": 1}), json!({"id": 2}), json!({"id": 3})];
1597        let count = sink.write_batch(&records).await.unwrap();
1598        assert_eq!(count, 3);
1599    }
1600
1601    #[tokio::test]
1602    async fn sink_write_batch_empty() {
1603        let sink = MockSink::new();
1604        let count = sink.write_batch(&[]).await.unwrap();
1605        assert_eq!(count, 0);
1606    }
1607
1608    #[tokio::test]
1609    async fn sink_accumulates_records() {
1610        let sink = MockSink::new();
1611        sink.write_batch(&[json!({"a": 1})]).await.unwrap();
1612        sink.write_batch(&[json!({"b": 2})]).await.unwrap();
1613        let written = sink.written.lock().unwrap();
1614        assert_eq!(written.len(), 2);
1615    }
1616
1617    #[tokio::test]
1618    async fn sink_default_flush_is_noop() {
1619        let sink = MockSink::new();
1620        assert!(sink.flush().await.is_ok());
1621    }
1622
1623    #[tokio::test]
1624    async fn sink_error_propagates() {
1625        let sink = FailingSink;
1626        let result = sink.write_batch(&[json!({"id": 1})]).await;
1627        assert!(result.is_err());
1628        assert!(matches!(result, Err(FaucetError::Sink(_))));
1629    }
1630
1631    #[tokio::test]
1632    async fn sink_as_trait_object() {
1633        let sink: Box<dyn Sink> = Box::new(MockSink::new());
1634        let count = sink.write_batch(&[json!({"id": 1})]).await.unwrap();
1635        assert_eq!(count, 1);
1636    }
1637
1638    // ── stream_pages tests ──────────────────────────────────────────────────
1639
1640    use crate::pipeline::DEFAULT_BATCH_SIZE;
1641    use futures::StreamExt;
1642
1643    #[tokio::test]
1644    async fn default_stream_pages_chunks_records() {
1645        let source = MockSource {
1646            records: (0..5).map(|i| json!({"i": i})).collect(),
1647        };
1648        let ctx = std::collections::HashMap::new();
1649        let mut pages = source.stream_pages(&ctx, 2);
1650        let mut all = Vec::new();
1651        while let Some(page) = pages.next().await {
1652            all.push(page.unwrap());
1653        }
1654        // 5 records, batch_size=2 → pages of [2, 2, 1]
1655        assert_eq!(all.len(), 3);
1656        assert_eq!(all[0].records.len(), 2);
1657        assert_eq!(all[1].records.len(), 2);
1658        assert_eq!(all[2].records.len(), 1);
1659    }
1660
1661    #[tokio::test]
1662    async fn default_stream_pages_attaches_bookmark_to_final_page_only() {
1663        let source = IncrementalSource {
1664            records: (0..5).map(|i| json!({"i": i})).collect(),
1665            bookmark: json!("v1"),
1666        };
1667        let ctx = std::collections::HashMap::new();
1668        let mut pages = source.stream_pages(&ctx, 2);
1669        let mut collected = Vec::new();
1670        while let Some(page) = pages.next().await {
1671            collected.push(page.unwrap());
1672        }
1673        assert_eq!(collected.len(), 3);
1674        assert!(collected[0].bookmark.is_none());
1675        assert!(collected[1].bookmark.is_none());
1676        assert_eq!(collected[2].bookmark, Some(json!("v1")));
1677    }
1678
1679    #[tokio::test]
1680    async fn default_stream_pages_single_page_when_batch_size_exceeds_total() {
1681        let source = MockSource {
1682            records: vec![json!({"id": 1}), json!({"id": 2})],
1683        };
1684        let ctx = std::collections::HashMap::new();
1685        let mut pages = source.stream_pages(&ctx, 100);
1686        let mut collected = Vec::new();
1687        while let Some(page) = pages.next().await {
1688            collected.push(page.unwrap());
1689        }
1690        assert_eq!(collected.len(), 1);
1691        assert_eq!(collected[0].records.len(), 2);
1692    }
1693
1694    #[tokio::test]
1695    async fn default_stream_pages_batch_size_zero_emits_single_page() {
1696        // batch_size = 0 is the "no batching" sentinel — yields every record
1697        // in one page regardless of total count.
1698        let source = MockSource {
1699            records: (0..50_000).map(|i| json!({"i": i})).collect(),
1700        };
1701        let ctx = std::collections::HashMap::new();
1702        let mut pages = source.stream_pages(&ctx, 0);
1703        let mut collected = Vec::new();
1704        while let Some(page) = pages.next().await {
1705            collected.push(page.unwrap());
1706        }
1707        assert_eq!(
1708            collected.len(),
1709            1,
1710            "batch_size=0 must emit exactly one page"
1711        );
1712        assert_eq!(collected[0].records.len(), 50_000);
1713    }
1714
1715    #[tokio::test]
1716    async fn default_stream_pages_batch_size_zero_attaches_bookmark_to_sole_page() {
1717        let source = IncrementalSource {
1718            records: (0..3).map(|i| json!({"i": i})).collect(),
1719            bookmark: json!("v1"),
1720        };
1721        let ctx = std::collections::HashMap::new();
1722        let mut pages = source.stream_pages(&ctx, 0);
1723        let page = pages.next().await.unwrap().unwrap();
1724        assert_eq!(page.records.len(), 3);
1725        assert_eq!(page.bookmark, Some(json!("v1")));
1726        assert!(pages.next().await.is_none());
1727    }
1728
1729    #[tokio::test]
1730    async fn default_stream_pages_empty_source_yields_no_pages() {
1731        let source = MockSource { records: vec![] };
1732        let ctx = std::collections::HashMap::new();
1733        let mut pages = source.stream_pages(&ctx, DEFAULT_BATCH_SIZE);
1734        assert!(pages.next().await.is_none());
1735    }
1736
1737    #[tokio::test]
1738    async fn default_stream_pages_empty_source_with_bookmark_yields_single_empty_page() {
1739        let source = IncrementalSource {
1740            records: vec![],
1741            bookmark: json!("v0"),
1742        };
1743        let ctx = std::collections::HashMap::new();
1744        let mut pages = source.stream_pages(&ctx, DEFAULT_BATCH_SIZE);
1745        let mut collected = Vec::new();
1746        while let Some(page) = pages.next().await {
1747            collected.push(page.unwrap());
1748        }
1749        // One empty-records page that carries the bookmark, so the pipeline
1750        // still persists progress on otherwise-empty incremental runs.
1751        assert_eq!(collected.len(), 1);
1752        assert!(collected[0].records.is_empty());
1753        assert_eq!(collected[0].bookmark, Some(json!("v0")));
1754    }
1755
1756    #[tokio::test]
1757    async fn default_stream_pages_propagates_fetch_errors() {
1758        let source = FailingSource;
1759        let ctx = std::collections::HashMap::new();
1760        let mut pages = source.stream_pages(&ctx, DEFAULT_BATCH_SIZE);
1761        let first = pages.next().await.unwrap();
1762        assert!(matches!(first, Err(FaucetError::Auth(_))));
1763    }
1764
1765    #[test]
1766    fn source_default_connector_name_is_stripped_type_name() {
1767        // MockSource lives at `faucet_core::traits::tests::MockSource`; the
1768        // stripped type_name yields the trailing segment.
1769        let source = MockSource { records: vec![] };
1770        assert_eq!(source.connector_name(), "MockSource");
1771    }
1772
1773    #[test]
1774    fn sink_default_connector_name_is_stripped_type_name() {
1775        let sink = MockSink::new();
1776        assert_eq!(sink.connector_name(), "MockSink");
1777    }
1778
1779    #[test]
1780    fn source_default_dataset_uri_uses_connector_name() {
1781        let source = MockSource { records: vec![] };
1782        assert_eq!(source.dataset_uri(), "MockSource://unknown");
1783    }
1784
1785    #[test]
1786    fn sink_default_dataset_uri_uses_connector_name() {
1787        let sink = MockSink::new();
1788        assert_eq!(sink.dataset_uri(), "MockSink://unknown");
1789    }
1790
1791    // ── write_batch_partial tests ───────────────────────────────────────────
1792
1793    #[tokio::test]
1794    async fn default_write_batch_partial_success_returns_all_ok() {
1795        let sink = MockSink::new();
1796        let records = vec![json!({"id": 1}), json!({"id": 2}), json!({"id": 3})];
1797        let outcomes = sink.write_batch_partial(&records).await.unwrap();
1798        assert_eq!(outcomes.len(), 3);
1799        assert!(outcomes.iter().all(|o| o.is_ok()));
1800        assert_eq!(sink.written.lock().unwrap().len(), 3);
1801    }
1802
1803    #[tokio::test]
1804    async fn default_write_batch_partial_bubbles_outer_err() {
1805        let sink = FailingSink;
1806        let records = vec![json!({"id": 1}), json!({"id": 2})];
1807        let result = sink.write_batch_partial(&records).await;
1808        assert!(matches!(result, Err(FaucetError::Sink(_))));
1809    }
1810
1811    #[tokio::test]
1812    async fn default_write_batch_partial_empty_returns_empty_vec() {
1813        let sink = MockSink::new();
1814        let outcomes = sink.write_batch_partial(&[]).await.unwrap();
1815        assert!(outcomes.is_empty());
1816    }
1817
1818    #[tokio::test]
1819    async fn default_write_batch_partial_callable_through_trait_object() {
1820        let sink: Box<dyn Sink> = Box::new(MockSink::new());
1821        let records = vec![json!({"id": 1}), json!({"id": 2})];
1822        let outcomes = sink.write_batch_partial(&records).await.unwrap();
1823        assert_eq!(outcomes.len(), 2);
1824        assert!(outcomes.iter().all(|o| o.is_ok()));
1825    }
1826
1827    // ── check() tests ─────────────────────────────────────────────────────────
1828
1829    #[tokio::test]
1830    async fn source_default_check_pulls_first_page_and_passes() {
1831        let source = MockSource {
1832            records: vec![json!({"id": 1}), json!({"id": 2})],
1833        };
1834        let report = source
1835            .check(&crate::check::CheckContext::default())
1836            .await
1837            .unwrap();
1838        assert_eq!(report.failed_count(), 0);
1839        assert!(
1840            report
1841                .probes
1842                .iter()
1843                .any(|p| p.name == "read" && matches!(p.status, crate::check::ProbeStatus::Pass))
1844        );
1845    }
1846
1847    #[tokio::test]
1848    async fn source_default_check_passes_on_empty_source() {
1849        let source = MockSource { records: vec![] };
1850        let report = source
1851            .check(&crate::check::CheckContext::default())
1852            .await
1853            .unwrap();
1854        // Reachable but empty is still a healthy source.
1855        assert_eq!(report.failed_count(), 0);
1856    }
1857
1858    #[tokio::test]
1859    async fn source_default_check_fails_when_fetch_errors() {
1860        let source = FailingSource;
1861        let report = source
1862            .check(&crate::check::CheckContext::default())
1863            .await
1864            .unwrap();
1865        assert_eq!(report.failed_count(), 1);
1866        assert!(report.probes.iter().any(
1867            |p| p.name == "read" && matches!(p.status, crate::check::ProbeStatus::Fail { .. })
1868        ));
1869    }
1870
1871    #[tokio::test]
1872    async fn sink_default_check_is_not_implemented_skip() {
1873        let sink = MockSink::new();
1874        let report = sink
1875            .check(&crate::check::CheckContext::default())
1876            .await
1877            .unwrap();
1878        assert_eq!(report.probes.len(), 1);
1879        assert!(matches!(
1880            report.probes[0].status,
1881            crate::check::ProbeStatus::Skip { .. }
1882        ));
1883    }
1884
1885    #[tokio::test]
1886    async fn source_check_callable_through_trait_object() {
1887        let source: Box<dyn Source> = Box::new(MockSource {
1888            records: vec![json!({"id": 1})],
1889        });
1890        let report = source
1891            .check(&crate::check::CheckContext::default())
1892            .await
1893            .unwrap();
1894        assert_eq!(report.failed_count(), 0);
1895    }
1896
1897    // ── idempotent-write / exactly-once capability tests ──────────────────────
1898
1899    #[tokio::test]
1900    async fn sink_default_is_not_idempotent() {
1901        let sink = MockSink::new();
1902        assert!(!sink.supports_idempotent_writes());
1903        // Default write_batch_idempotent ignores the token and delegates.
1904        let n = sink
1905            .write_batch_idempotent(&[json!({"id": 1})], "scope::a", "00000000000000000001")
1906            .await
1907            .unwrap();
1908        assert_eq!(n, 1);
1909        assert_eq!(sink.last_committed_token("scope::a").await.unwrap(), None);
1910        assert_eq!(sink.written.lock().unwrap().len(), 1);
1911    }
1912
1913    #[test]
1914    fn source_default_does_not_support_exactly_once() {
1915        let source = MockSource { records: vec![] };
1916        assert!(!source.supports_exactly_once());
1917    }
1918
1919    #[test]
1920    fn sink_default_supported_write_modes_is_append_only() {
1921        use crate::write_mode::WriteMode;
1922        let sink = MockSink::new();
1923        assert_eq!(sink.supported_write_modes(), &[WriteMode::Append]);
1924    }
1925
1926    #[test]
1927    fn supported_write_modes_callable_through_trait_object() {
1928        use crate::write_mode::WriteMode;
1929        let sink: Box<dyn Sink> = Box::new(MockSink::new());
1930        assert!(sink.supported_write_modes().contains(&WriteMode::Append));
1931    }
1932
1933    #[tokio::test]
1934    async fn sink_default_current_schema_is_none() {
1935        let sink = MockSink::new();
1936        assert_eq!(sink.current_schema().await.unwrap(), None);
1937    }
1938
1939    #[test]
1940    fn sink_default_does_not_support_schema_evolution() {
1941        let sink = MockSink::new();
1942        assert!(!sink.supports_schema_evolution());
1943    }
1944
1945    #[tokio::test]
1946    async fn sink_default_evolve_schema_is_unsupported_error() {
1947        let sink = MockSink::new();
1948        let evo = crate::drift::SchemaEvolution::default();
1949        let err = sink.evolve_schema(&evo).await.unwrap_err();
1950        assert!(matches!(err, FaucetError::Sink(_)));
1951        assert!(err.to_string().contains("schema evolution"));
1952    }
1953
1954    #[tokio::test]
1955    async fn source_default_capture_resume_position_is_none() {
1956        let source = MockSource { records: vec![] };
1957        assert_eq!(source.capture_resume_position().await.unwrap(), None);
1958    }
1959
1960    #[tokio::test]
1961    async fn capture_resume_position_callable_through_trait_object() {
1962        let source: Box<dyn Source> = Box::new(MockSource { records: vec![] });
1963        assert!(source.capture_resume_position().await.unwrap().is_none());
1964    }
1965
1966    #[tokio::test]
1967    async fn source_default_does_not_support_discover() {
1968        let source: Box<dyn Source> = Box::new(MockSource { records: vec![] });
1969        assert!(!source.supports_discover());
1970        let err = source.discover().await.unwrap_err();
1971        assert!(matches!(err, FaucetError::Source(_)));
1972        assert!(
1973            err.to_string().contains("dataset discovery"),
1974            "typed unsupported error: {err}"
1975        );
1976    }
1977
1978    #[tokio::test]
1979    async fn source_default_is_not_shardable() {
1980        let source: Box<dyn Source> = Box::new(MockSource { records: vec![] });
1981        assert!(!source.is_shardable());
1982    }
1983
1984    #[tokio::test]
1985    async fn source_default_enumerates_single_whole_shard() {
1986        // A non-shardable source enumerates to exactly one whole-dataset shard,
1987        // regardless of the requested target — preserving single-worker behavior.
1988        let source: Box<dyn Source> = Box::new(MockSource { records: vec![] });
1989        let shards = source.enumerate_shards(8).await.unwrap();
1990        assert_eq!(shards.len(), 1);
1991        assert!(shards[0].is_whole());
1992    }
1993
1994    #[tokio::test]
1995    async fn source_default_apply_shard_is_noop() {
1996        let source: Box<dyn Source> = Box::new(MockSource { records: vec![] });
1997        // Applying the whole shard is a no-op and must not error.
1998        source
1999            .apply_shard(&crate::shard::ShardSpec::whole())
2000            .await
2001            .unwrap();
2002    }
2003}