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