Skip to main content

rivet/source/mongo/
mod.rs

1//! **Layer: Execution** — MongoDB source engine (document store).
2//!
3//! Unlike the three SQL engines (PostgreSQL / MySQL / SQL Server), MongoDB has
4//! no SQL, no fixed per-collection schema, and no `information_schema`. So the
5//! SQL-shaped read seam — chunked/keyset planning, incremental predicate
6//! building, catalog introspection — does **not** apply. This adapter is the
7//! **OSS JSON-blob model**: every document exports as exactly two columns —
8//! `_id` (`Utf8`, the document key stringified: ObjectId → hex, etc.) and
9//! `document` (`Utf8` + the `arrow.json` extension type, the whole BSON
10//! document rendered as relaxed extended JSON).
11//!
12//! Per-field typing is punted downstream (`PARSE_JSON` in the warehouse). Schema
13//! inference / auto-discovery is deliberately **out of scope for OSS** (it is the
14//! paid-tier `discover` mechanism); the user gets a lossless blob, not a guessed
15//! columnar schema. A future `columns:` projection can carve typed columns out of
16//! the blob without changing this default.
17//!
18//! ## Sync bridge (ADR-0011)
19//!
20//! The `mongodb` driver is async (tokio); the [`Source`] trait is sync
21//! `&mut self`. Like the MSSQL/tiberius engine, each [`MongoSource`] owns a tokio
22//! runtime and `block_on`s every driver call — no async leaks into the runner.
23//!
24//! ## Scope
25//!
26//! `mode: full` only (incremental / chunked / time-window are SQL-runner concepts
27//! and are refused with an actionable error). Within full mode, `source.mongo.
28//! page_size` opts into **keyset (seek) paging** on `_id` — bounded query time,
29//! per-page parts, optional cross-run **resume** (`resume: true`, typed BSON
30//! checkpoint), and `parallel: N` **`_id`-range fan-out** (any BSON `_id`). CDC
31//! (change streams) rides the canonical `ChangeStream` seam and is added
32//! separately.
33
34use std::sync::Arc;
35
36use arrow::array::{ArrayRef, StringBuilder};
37use arrow::datatypes::{Schema, SchemaRef};
38use arrow::record_batch::RecordBatch;
39use futures_util::TryStreamExt;
40use mongodb::Client;
41use mongodb::bson::oid::ObjectId;
42use mongodb::bson::{Bson, Document, doc};
43use mongodb::options::{ClientOptions, CollectionOptions, ReadConcern};
44use tokio::runtime::Runtime;
45
46use crate::config::{MongoConfig, MongoJsonMode, MongoReadConcern, TlsConfig};
47use crate::error::Result;
48use crate::source::{BatchSink, ExportRequest, Source};
49use crate::types::{ColumnOverrides, RivetType, SourceColumn, TypeMapping};
50
51pub(crate) mod cdc;
52
53/// The byte budget at which a document batch flushes. The `document` column is an
54/// Arrow `Utf8` (i32 offsets): a batch accumulating > 2 GiB panics ("byte array
55/// offset overflow"). Large documents (up to 16 MB each) hit that far before the
56/// row cap (~65 K rows), so a row-only limit is unsafe for a document store —
57/// `None` (the default in every tuning profile) MUST NOT mean "unbounded".
58/// Default 256 MiB: bounds RSS (the correct blob knob) and keeps every batch well
59/// under the i32 ceiling (bug-hunt: 2300×1 MB docs panicked on the default).
60fn mongo_batch_byte_cap(max_batch_memory_mb: Option<usize>) -> usize {
61    const DEFAULT: usize = 256 * 1024 * 1024;
62    max_batch_memory_mb
63        .map(|mb| mb.saturating_mul(1024 * 1024))
64        .filter(|&b| b > 0)
65        .unwrap_or(DEFAULT)
66}
67
68/// Map rivet's [`TlsConfig`] onto the mongodb driver's `Tls`, mirroring the SQL
69/// engines' posture: `Disable` = plaintext; `Require` = TLS but accept any cert
70/// (sniff-proof, not MITM-proof); `VerifyCa` = verify chain, skip hostname;
71/// `VerifyFull` = verify both. The per-field `accept_invalid_*` flags widen (but
72/// never narrow) the mode. Without this the driver ignored the block entirely.
73fn tls_to_mongo(cfg: &TlsConfig) -> mongodb::options::Tls {
74    use crate::config::TlsMode;
75    use mongodb::options::{Tls, TlsOptions};
76    if matches!(cfg.mode, TlsMode::Disable) {
77        return Tls::Disabled;
78    }
79    // Only `Require` waives certificate verification; `VerifyCa`/`VerifyFull`
80    // both verify the chain. (This driver exposes no hostname-only toggle, so
81    // `VerifyCa` verifies the hostname too — stricter than the SQL engines, never
82    // weaker.) The per-field `accept_invalid_certs` flag widens, never narrows.
83    let mode_invalid_cert = matches!(cfg.mode, TlsMode::Require);
84    let mut o = TlsOptions::default();
85    o.ca_file_path = cfg.ca_file.as_ref().map(std::path::PathBuf::from);
86    o.allow_invalid_certificates = Some(mode_invalid_cert || cfg.accept_invalid_certs);
87    Tls::Enabled(o)
88}
89
90/// A connected MongoDB session: the async client plus the tokio runtime that
91/// drives it, so the sync `Source` trait can `block_on` every driver call
92/// (ADR-0011, mirrors MSSQL/tiberius). This is the **one** place that owns the
93/// async→sync bridge — the SDAM-needs-a-worker-thread runtime, the connect +
94/// ping handshake, the borrow dance — reused by the source, the harm / count
95/// probes, and `rivet init`.
96pub struct MongoSession {
97    rt: Runtime,
98    client: Client,
99    /// Database resolved from the connection URL path (`mongodb://…/<db>`).
100    db: String,
101}
102
103impl MongoSession {
104    /// Connect + `ping`. `gate` applies the shared remote-plaintext TLS refusal
105    /// (`require_tls_or_loopback`, CWE-319); `rivet init` passes `false` (dev
106    /// convenience, like the SQL init helpers), every other caller `true`.
107    pub fn connect(url: &str, tls: Option<&TlsConfig>, gate: bool) -> Result<Self> {
108        if gate {
109            crate::source::require_tls_or_loopback(url, tls)?;
110        }
111        // Small multi-thread runtime (not current-thread): the driver spawns
112        // background SDAM/heartbeat tasks that must make progress independently
113        // of our `block_on` calls, or connection monitoring starves.
114        let rt = tokio::runtime::Builder::new_multi_thread()
115            .worker_threads(2)
116            .enable_all()
117            .build()?;
118        let (client, db) = rt.block_on(async {
119            let mut opts = ClientOptions::parse(url).await?;
120            // Honor the `tls:` block. Without this the driver used only the URL's
121            // `?tls=` — so `tls: { mode: verify-full }` on a URL that didn't opt
122            // in connected in PLAINTEXT, the exact posture the operator asked to
123            // forbid (bug-hunt find). `None` ⇒ leave the URL's own tls setting.
124            if let Some(cfg) = tls {
125                opts.tls = Some(tls_to_mongo(cfg));
126            }
127            let db = opts.default_database.clone().ok_or_else(|| {
128                anyhow::anyhow!(
129                    "mongodb url must include a database: mongodb://user:pass@host:port/<db>"
130                )
131            })?;
132            let client = Client::with_options(opts)?;
133            // Round-trip so a bad host/auth fails at connect, not first read.
134            client.database(&db).run_command(doc! { "ping": 1 }).await?;
135            Ok::<_, anyhow::Error>((client, db))
136        })?;
137        Ok(Self { rt, client, db })
138    }
139
140    pub fn client(&self) -> &Client {
141        &self.client
142    }
143    pub fn db(&self) -> &str {
144        &self.db
145    }
146    /// Drive a future to completion on the owned runtime.
147    pub fn block_on<T>(&self, fut: impl std::future::Future<Output = T>) -> T {
148        self.rt.block_on(fut)
149    }
150}
151
152/// MongoDB source over a [`MongoSession`], carrying the resolved `source.mongo:`
153/// read options `export` applies.
154pub struct MongoSource {
155    session: MongoSession,
156    /// Render the `document` column as canonical (type-tagged) extended JSON.
157    canonical_json: bool,
158    /// Scan under `readConcern: snapshot` (point-in-time; 5.0+ replica set).
159    snapshot: bool,
160    /// Set `noCursorTimeout` on the scan cursor.
161    no_cursor_timeout: bool,
162    /// A disjoint `_id` slice `[lo, hi)` for ONE parallel worker. When `Some`,
163    /// every scan (keyset page or full) is bounded to `{_id: {$gte: lo, $lt:
164    /// hi}}` (composed with the keyset `$gt` cursor). Bounds are `Bson` — a slice
165    /// works for ANY `_id` type (ObjectId, integer, string), with `MinKey`/
166    /// `MaxKey` as the outer sentinels. Set per-worker by the parallel runner;
167    /// ranges tile the whole BSON key space so their union is the full collection
168    /// with no overlap. `_id` is immutable ⇒ no doc migrates between ranges.
169    /// `None` ⇒ the whole collection (the normal single reader).
170    id_range: Option<(Bson, Bson)>,
171    /// Collections already vetted by [`Self::ensure_uniform_id_type`] this run —
172    /// the guard runs on the FIRST keyset page of every run, including a
173    /// checkpointed resume (`after_id` present). Gating it on `after_id.is_none()`
174    /// let `resume: true` bypass it forever: an `_id` of a new BSON band inserted
175    /// after run 1 was silently unreachable on every later run (bug-hunt find).
176    id_guard_checked: std::collections::HashSet<String>,
177}
178
179impl MongoSource {
180    /// Connect, resolving the optional `source.mongo:` read options (absent ⇒
181    /// relaxed JSON, server read concern, cursor kept alive). Used by
182    /// `create_source` (with the config block) and the `doctor` / type-report
183    /// probes (with `None`).
184    pub fn connect(url: &str, tls: Option<&TlsConfig>, cfg: Option<&MongoConfig>) -> Result<Self> {
185        let session = MongoSession::connect(url, tls, true)?;
186        let (canonical_json, snapshot, no_cursor_timeout) = match cfg {
187            None => (false, false, true),
188            Some(c) => (
189                matches!(c.json, MongoJsonMode::Canonical),
190                matches!(c.read_concern, MongoReadConcern::Snapshot),
191                c.no_cursor_timeout,
192            ),
193        };
194        Ok(Self {
195            session,
196            canonical_json,
197            snapshot,
198            no_cursor_timeout,
199            id_range: None,
200            id_guard_checked: std::collections::HashSet::new(),
201        })
202    }
203
204    /// Bind this reader to one disjoint `_id` slice `[lo, hi)` (a parallel
205    /// worker). Consuming builder — each worker owns its own `MongoSource`.
206    pub fn with_id_range(mut self, lo: Bson, hi: Bson) -> Self {
207        self.id_range = Some((lo, hi));
208        self
209    }
210
211    /// The min and max `_id` of `collection` by BSON sort order (one index-bound
212    /// `find_one` each), or `None` if the collection is empty. Brackets are
213    /// *contiguous* in BSON sort order, so the endpoints span every bracket
214    /// present — comparing them detects any heterogeneous-`_id` mix. Shared by the
215    /// keyset *refusal* ([`Self::ensure_uniform_id_type`]) and the full-scan
216    /// *warning* ([`Self::warn_if_heterogeneous_id`]).
217    fn id_span(&self, collection: &str) -> Result<Option<(Document, Document)>> {
218        let coll = self
219            .session
220            .client()
221            .database(self.session.db())
222            .collection::<Document>(collection);
223        self.session.block_on(async {
224            let lo = coll.find_one(doc! {}).sort(doc! { "_id": 1 }).await?;
225            let hi = coll.find_one(doc! {}).sort(doc! { "_id": -1 }).await?;
226            Ok::<_, anyhow::Error>(lo.zip(hi))
227        })
228    }
229
230    /// Guard against silent loss on a heterogeneous-`_id` collection. Keyset
231    /// paging seeks with `$gt`/`$lt`, which MongoDB **type-brackets**: a numeric
232    /// cursor never matches a string `_id` even though strings sort after numbers,
233    /// so a collection mixing `_id` types would page only the first bracket and
234    /// silently drop the rest (verified: an int+string collection reads 50%). We
235    /// compare the BSON bracket of the min and max `_id` (a single bracket is a
236    /// contiguous band, so differing endpoints ⟺ a bracket jump exists) and refuse
237    /// keyset/parallel, pointing at the full scan — a single ordered cursor, which
238    /// DOES cross brackets. Numeric types share bracket 0, so a mixed
239    /// Int32/Int64/Double `_id` still keysets fine.
240    pub(crate) fn ensure_uniform_id_type(&self, collection: &str) -> Result<()> {
241        if let Some((lo, hi)) = self.id_span(collection)?
242            && let (Some(lo_id), Some(hi_id)) = (lo.get("_id"), hi.get("_id"))
243        {
244            // NaN never matches $gt/$gte/$lt against a non-NaN operand: a keyset
245            // page boundary landing ON the NaN row drops the whole remaining
246            // collection, and every parallel range excludes it. NaN sorts FIRST
247            // in the numeric band, so the min probe always sees it (the max
248            // probe covers the all-NaN corner).
249            for id in [lo_id, hi_id] {
250                if matches!(id, Bson::Double(f) if f.is_nan()) {
251                    anyhow::bail!(
252                        "collection '{collection}' has a NaN `_id`: MongoDB range \
253                         operators never match NaN, so keyset paging / parallel \
254                         ranges would silently skip documents. Remove `page_size` \
255                         and `parallel` to use a full ordered scan (a single \
256                         cursor reads every document), or fix the `_id`."
257                    );
258                }
259            }
260            // Unknown band is refused too (see `id_types_collide`): if we cannot
261            // place the type we cannot promise the seek crosses it — loud beats a
262            // silent gap.
263            if id_types_collide(lo_id, hi_id) {
264                anyhow::bail!(
265                    "collection '{collection}' has heterogeneous `_id` types \
266                     ({:?} … {:?}): MongoDB keyset paging seeks with `$gt`, which is \
267                     BSON-type-bracketed and would silently skip every `_id` type but \
268                     one. Remove `page_size` and `parallel` to use a full ordered scan \
269                     (a single cursor crosses BSON types), or normalise `_id` to one type.",
270                    lo_id.element_type(),
271                    hi_id.element_type(),
272                );
273            }
274        }
275        Ok(())
276    }
277
278    /// Advisory sibling of [`Self::ensure_uniform_id_type`] for the **full-scan**
279    /// path (no `page_size`, no `parallel`): a full ordered cursor DOES cross BSON
280    /// brackets, so a heterogeneous-`_id` collection is read completely — no loss.
281    /// But the flat `_id` **display column** stringifies distinct BSON types to
282    /// possibly-colliding text (int `1001` and string `"1001"` both → `"1001"`), so
283    /// a warehouse merge keyed on `_id` would conflate them. Warn once, up front,
284    /// and point at the typed `document._id` (see docs). Best-effort: a probe
285    /// failure is silent (advisory, never a gate).
286    pub(crate) fn warn_if_heterogeneous_id(&self, collection: &str) {
287        let Ok(Some((lo, hi))) = self.id_span(collection) else {
288            return;
289        };
290        if let (Some(lo_id), Some(hi_id)) = (lo.get("_id"), hi.get("_id"))
291            && id_types_collide(lo_id, hi_id)
292        {
293            log::warn!(
294                "collection '{collection}' has heterogeneous `_id` types ({:?} … {:?}): {}",
295                lo_id.element_type(),
296                hi_id.element_type(),
297                hetero_id_guidance()
298            );
299        }
300    }
301
302    /// Split the collection's `_id` space into `n` disjoint, size-balanced
303    /// ranges `[lo, hi)` whose union tiles the whole BSON key space (so the union
304    /// read is the complete collection, no overlap) — for ANY `_id` type, not
305    /// just ObjectId. Boundaries come from a `$sample` of the `_id`s (a
306    /// WiredTiger random cursor — O(sample), NOT a collection scan, unlike
307    /// `$bucketAuto` which examines every doc), then the N−1 quantiles of the
308    /// sorted sample. Outer bounds are `MinKey`/`MaxKey`, which sort before/after
309    /// every value, so the first/last range cannot miss the true extremes.
310    pub fn sample_id_ranges(&self, collection: &str, n: usize) -> Result<Vec<(Bson, Bson)>> {
311        self.ensure_uniform_id_type(collection)?;
312        let n = n.max(1);
313        let full_range = || vec![(Bson::MinKey, Bson::MaxKey)];
314        if n == 1 {
315            return Ok(full_range());
316        }
317        // ~250 samples per target range (min 2000), capped so a huge N stays well
318        // under the 5%-of-collection threshold that flips $sample to a full sort.
319        let sample_size = (n * 250).clamp(2000, 50_000);
320        let coll = self
321            .session
322            .client()
323            .database(self.session.db())
324            .collection::<Document>(collection);
325        let ids: Vec<Bson> = self.session.block_on(async move {
326            let mut cursor = coll
327                .aggregate(vec![
328                    doc! { "$sample": { "size": sample_size as i64 } },
329                    doc! { "$sort": { "_id": 1 } },
330                    doc! { "$project": { "_id": 1 } },
331                ])
332                .await?;
333            let mut ids = Vec::new();
334            while let Some(d) = cursor.try_next().await? {
335                // `$sort` yields the sampled `_id`s in BSON order; any type is fine
336                // (the range filter compares by the same order the server sorts by).
337                if let Some(id) = d.get("_id") {
338                    ids.push(id.clone());
339                }
340            }
341            Ok::<_, anyhow::Error>(ids)
342        })?;
343        if ids.len() < 2 {
344            // Too few docs to split meaningfully — one range over everything.
345            return Ok(full_range());
346        }
347        // N−1 interior quantile boundaries → N ranges; MinKey/MaxKey at the ends
348        // sort before/after every value, so the outer slices cover the extremes
349        // for ANY `_id` type.
350        let mut bounds = vec![Bson::MinKey];
351        for i in 1..n {
352            bounds.push(ids[i * ids.len() / n].clone());
353        }
354        bounds.push(Bson::MaxKey);
355        // Adjacent quantiles can land on the same value in a skewed sample; drop
356        // the resulting empty `[x, x)` so a worker never gets a no-op range (the
357        // dropped value is still covered by the next range's `$gte`).
358        Ok(bounds
359            .windows(2)
360            .filter(|w| w[0] != w[1])
361            .map(|w| (w[0].clone(), w[1].clone()))
362            .collect())
363    }
364}
365
366/// The two fixed columns of the JSON-blob model. Used by both the export schema
367/// and `type_mappings`, so `check --type-report` and the export agree.
368fn blob_mappings() -> Vec<TypeMapping> {
369    vec![
370        TypeMapping::from_source(
371            &SourceColumn::simple("_id", "objectid", false),
372            RivetType::String,
373        ),
374        TypeMapping::from_source(
375            // Nullable: a CDC DELETE with no pre-image (pre/post-images not
376            // enabled, or < 6.0) carries only `_id`, so `document` is NULL. A
377            // batch export never writes a null here — nullable is a safe superset.
378            &SourceColumn::simple("document", "document", true),
379            RivetType::Json,
380        ),
381    ]
382}
383
384/// Arrow schema for the JSON-blob model (`_id: Utf8`, `document: Utf8 + json`).
385fn blob_schema() -> SchemaRef {
386    let fields = blob_mappings()
387        .iter()
388        .map(|m| {
389            crate::types::build_arrow_field(m)
390                .expect("blob columns (String/Json) always have an Arrow mapping")
391        })
392        .collect::<Vec<_>>();
393    Arc::new(Schema::new(fields))
394}
395
396/// Stringify a document `_id` for the `_id` column. ObjectId → 24-char hex;
397/// string/number keys → their natural text; anything exotic (binary, compound)
398/// → its relaxed extended JSON, so the column is always populated and never
399/// silently null.
400fn id_to_string(id: Option<&Bson>) -> String {
401    match id {
402        Some(Bson::ObjectId(oid)) => oid.to_hex(),
403        Some(Bson::String(s)) => s.clone(),
404        // Ints, bools, etc. render as their bare relaxed-extjson value (`42`,
405        // `true`); ObjectId/String are special-cased above only to drop the
406        // `{"$oid":…}` wrapper / surrounding quotes.
407        Some(other) => other.clone().into_relaxed_extjson().to_string(),
408        // A document with no `_id` is not possible in MongoDB (the server
409        // always assigns one), but never panic on malformed input.
410        None => String::new(),
411    }
412}
413
414/// Encode a BSON `_id` as a lossless, engine-decodable keyset token: the raw
415/// BSON bytes of `{_id: <value>}`, hex-encoded. Unlike the display `_id` column
416/// (hex/text — type-ambiguous, e.g. integer `1001` vs string `"1001"`), this
417/// preserves the exact BSON type across the string-typed cursor seam, so keyset
418/// paging + resume work for ANY `_id`, not just ObjectId.
419/// The BSON comparison *band* an `_id` falls in for a `$gt`/`$lt` keyset seek.
420/// MongoDB only compares within a band; the server's sort order is
421/// `MinKey < Null < Numbers < String/Symbol < Object < Array < BinData <
422/// ObjectId < Boolean < Date < Timestamp < Regex < MaxKey`. The four numeric
423/// types share one band (a mixed Int32/Int64/Double `_id` keysets fine); every
424/// other band is its own bracket — a former catch-all arm lumped Null, Object,
425/// Array and Regex together, so a `null` + `{…}` collection slipped past the
426/// guard and silently lost a band (bug-hunt find). `None` = a type we cannot
427/// place (MinKey/MaxKey/JS/DbPointer/…) — the caller refuses keyset rather than
428/// guessing. Two `_id`s in different brackets ⟹ un-keyset-able.
429fn id_bracket(v: &Bson) -> Option<u8> {
430    Some(match v {
431        Bson::Double(_) | Bson::Int32(_) | Bson::Int64(_) | Bson::Decimal128(_) => 0,
432        Bson::Null => 1,
433        Bson::String(_) | Bson::Symbol(_) => 2,
434        Bson::Document(_) => 3,
435        Bson::Array(_) => 4,
436        Bson::Binary(_) => 5,
437        Bson::ObjectId(_) => 6,
438        Bson::Boolean(_) => 7,
439        Bson::DateTime(_) => 8,
440        Bson::Timestamp(_) => 9,
441        Bson::RegularExpression(_) => 10,
442        _ => return None,
443    })
444}
445
446/// Whether two `_id` values fall in DIFFERENT keyset brackets — i.e. the
447/// collection mixes BSON `_id` types. An unplaceable type (`id_bracket` = `None`)
448/// counts as colliding: we can neither seek across it (keyset) nor vouch its
449/// display text won't collide (full scan). Pure, so the keyset refusal and the
450/// full-scan warning share one notion of "heterogeneous `_id`".
451fn id_types_collide(lo: &Bson, hi: &Bson) -> bool {
452    match (id_bracket(lo), id_bracket(hi)) {
453        (Some(a), Some(b)) => a != b,
454        _ => true,
455    }
456}
457
458/// The shared downstream-guidance tail of the heterogeneous-`_id` warning (batch
459/// full scan and CDC both emit it). The flat `_id` column renders different BSON
460/// types to possibly-colliding text, so a warehouse merge keyed on `_id` conflates
461/// distinct documents — the typed value is always in `document._id`.
462pub(super) fn hetero_id_guidance() -> &'static str {
463    "the flat `_id` column renders different BSON types to possibly-colliding text \
464     (int 1001 and string \"1001\" both become \"1001\"), so a downstream merge keyed \
465     on `_id` conflates distinct documents — key on the typed `document._id` instead \
466     (see docs/reference/mongodb.md#consuming-in-the-warehouse)"
467}
468
469fn encode_id_cursor(id: &Bson) -> String {
470    let mut buf = Vec::new();
471    doc! { "_id": id.clone() }
472        .to_writer(&mut buf)
473        .expect("a one-field BSON document always serializes");
474    bytes_to_hex(&buf)
475}
476
477/// Lower-case hex of raw bytes — the string half of the BSON-token round-trip
478/// (paired with [`hex_to_bytes`]), used to carry a lossless BSON value across a
479/// string-typed seam (the keyset cursor and the CDC resume token).
480pub(super) fn bytes_to_hex(bytes: &[u8]) -> String {
481    bytes.iter().map(|b| format!("{b:02x}")).collect()
482}
483
484/// Inverse of [`encode_id_cursor`]. Falls back to a bare 24-char ObjectId hex so
485/// a cursor written before the typed token (or a value read from the display
486/// column) still resolves.
487fn decode_id_cursor(token: &str) -> Result<Bson> {
488    if let Ok(bytes) = hex_to_bytes(token)
489        && let Ok(doc) = Document::from_reader(&bytes[..])
490        && let Some(id) = doc.get("_id")
491    {
492        return Ok(id.clone());
493    }
494    ObjectId::parse_str(token).map(Bson::ObjectId).map_err(|_| {
495        anyhow::anyhow!("MongoDB keyset cursor '{token}' is neither a typed token nor ObjectId hex")
496    })
497}
498
499fn hex_to_bytes(s: &str) -> Result<Vec<u8>> {
500    // The 2-byte slices below index by BYTE; a multi-byte char boundary would
501    // panic. A corrupted / foreign token is an error, never a panic.
502    if !s.is_ascii() {
503        anyhow::bail!("invalid hex cursor token (non-ASCII)");
504    }
505    if !s.len().is_multiple_of(2) {
506        anyhow::bail!("odd-length hex cursor token");
507    }
508    (0..s.len())
509        .step_by(2)
510        .map(|i| u8::from_str_radix(&s[i..i + 2], 16).map_err(|e| anyhow::anyhow!(e)))
511        .collect()
512}
513
514/// Render a whole BSON document as an extended-JSON string. `canonical=false`
515/// (relaxed) keeps common scalars native (`42`, `"x"`, `true`, nested
516/// objects/arrays) while still wrapping exotic BSON types losslessly
517/// (`{"$oid":…}`, `{"$date":…}`, `{"$numberDecimal":…}`) — friendliest for a
518/// downstream `PARSE_JSON`. `canonical=true` type-tags every value
519/// (`{"$numberLong":…}`) so Int64/Double survive a double-based JSON-number
520/// parser that would otherwise clamp values beyond 2^53.
521fn document_to_json(doc: &Document, canonical: bool) -> Result<String> {
522    let bson = Bson::Document(doc.clone());
523    let value = if canonical {
524        bson.into_canonical_extjson()
525    } else {
526        bson.into_relaxed_extjson()
527    };
528    Ok(serde_json::to_string(&value)?)
529}
530
531/// Finish the accumulated builders into a `RecordBatch` and hand it to the sink.
532fn flush(
533    schema: &SchemaRef,
534    ids: &mut StringBuilder,
535    docs: &mut StringBuilder,
536    sink: &mut dyn BatchSink,
537) -> Result<()> {
538    let columns: Vec<ArrayRef> = vec![Arc::new(ids.finish()), Arc::new(docs.finish())];
539    let batch = RecordBatch::try_new(schema.clone(), columns)?;
540    sink.on_batch(&batch)?;
541    Ok(())
542}
543
544impl Source for MongoSource {
545    fn export(&mut self, request: &ExportRequest<'_>, sink: &mut dyn BatchSink) -> Result<()> {
546        // The structured read-intent: the bare collection behind the `table:`
547        // shortcut (ADR-0027). `None` ⇒ a hand-written `query:` or a
548        // filtered/wrapped form, which has no MongoDB equivalent.
549        let coll_name = request.base_relation.ok_or_else(|| {
550            anyhow::anyhow!(
551                "MongoDB source supports only `table: <collection>` with `mode: full` — a \
552                 hand-written `query:` or a filtered/wrapped form has no MongoDB equivalent. \
553                 Got query: {}",
554                request.query
555            )
556        })?;
557        let schema = blob_schema();
558        sink.on_schema(schema.clone())?;
559
560        // `readConcern: snapshot` must ride the collection handle; a plain scan
561        // uses the default handle. The scalar opts are copied into locals so the
562        // async scan closure doesn't borrow `self`.
563        let db = self.session.client().database(self.session.db());
564        let coll = if self.snapshot {
565            db.collection_with_options::<Document>(
566                coll_name,
567                CollectionOptions::builder()
568                    .read_concern(ReadConcern::snapshot())
569                    .build(),
570            )
571        } else {
572            db.collection::<Document>(coll_name)
573        };
574        let canonical = self.canonical_json;
575        let no_cursor_timeout = self.no_cursor_timeout;
576        let id_range = self.id_range.clone();
577        // Source-side batch = the RSS lever. Two caps, whichever trips first:
578        //   • `tuning.batch_size` — a row count (schema-based memory budget for a
579        //     document store is unreliable: the `document` column is variable
580        //     length, so a row estimate off the Arrow schema misfires);
581        //   • `tuning.max_batch_memory_mb` — a hard byte budget on the batch,
582        //     the *correct* RSS knob for a JSON blob (flush by accumulated bytes,
583        //     independent of how big each document is).
584        let batch_rows = request.tuning.effective_batch_size(Some(&schema)).max(1);
585        let batch_byte_cap = mongo_batch_byte_cap(request.tuning.max_batch_memory_mb);
586
587        // Keyset (seek) pagination: the keyset runner drives the outer loop and
588        // hands us one page's `page_limit` plus the previous page's max `_id`
589        // as a lossless typed token (`set_source_cursor` → `decode_id_cursor`).
590        // We page `find({_id:{$gt:after}}).sort({_id:1}).limit(n)` — a bounded,
591        // index-driven range scan. `page_limit` unset ⇒ one full scan. Because
592        // the token carries the exact BSON type (not a type-ambiguous hex/text
593        // rendering), keyset works for ANY `_id` — ObjectId, integer, string —
594        // and MongoDB's own `$gt`/`sort` provide the ordering.
595        let page_limit = request.page_limit;
596        let after_id: Option<Bson> =
597            match request.cursor.and_then(|c| c.last_cursor_value.as_deref()) {
598                Some(token) => Some(decode_id_cursor(token)?),
599                None => None,
600            };
601
602        // Single-worker keyset, first page of THIS run — including a checkpointed
603        // resume: refuse a heterogeneous-`_id` collection up front. `$gt` is
604        // type-bracketed and would silently drop every `_id` band but the
605        // cursor's; a resume whose cursor predates newly-inserted documents of a
606        // different band would otherwise skip them forever while reporting
607        // success (the guard must NOT be gated on `after_id.is_none()`).
608        // (Parallel checks this in `sample_id_ranges`; a full scan — `page_limit`
609        // unset — is exempt because its single cursor crosses brackets.)
610        if page_limit.is_some() && id_range.is_none() && !self.id_guard_checked.contains(coll_name)
611        {
612            self.ensure_uniform_id_type(coll_name)?;
613            self.id_guard_checked.insert(coll_name.to_string());
614        }
615
616        // Full scan (no page_size, not a parallel worker) is the ONE path that
617        // permits a heterogeneous-`_id` collection — its single cursor crosses BSON
618        // brackets, so the read is complete (keyset/parallel refuse it above). But
619        // the flat `_id` display column can collide across types downstream, so warn
620        // once, up front. Advisory only — never gates the run.
621        if page_limit.is_none() && id_range.is_none() {
622            self.warn_if_heterogeneous_id(coll_name);
623        }
624
625        let total = self.session.block_on(async {
626            // Lower bound: the keyset cursor (`$gt: after`) when resuming a page,
627            // else this worker's range floor (`$gte: lo`). Upper bound: the range
628            // ceiling (`$lt: hi`). A worker with no range and no cursor scans all.
629            let filter = {
630                let mut cond = Document::new();
631                match &after_id {
632                    Some(id) => {
633                        cond.insert("$gt", id.clone());
634                    }
635                    None => {
636                        if let Some((lo, _)) = &id_range {
637                            cond.insert("$gte", lo.clone());
638                        }
639                    }
640                }
641                if let Some((_, hi)) = &id_range {
642                    cond.insert("$lt", hi.clone());
643                }
644                if cond.is_empty() {
645                    doc! {}
646                } else {
647                    doc! { "_id": cond }
648                }
649            };
650            let mut find = coll.find(filter);
651            if let Some(page) = page_limit {
652                find = find.sort(doc! { "_id": 1 }).limit(page as i64);
653            }
654            if no_cursor_timeout {
655                find = find.no_cursor_timeout(true);
656            }
657            let mut cursor = find.await?;
658            let mut ids = StringBuilder::new();
659            let mut docs = StringBuilder::new();
660            let mut in_batch = 0usize;
661            let mut batch_bytes = 0usize;
662            let mut total = 0usize;
663            // The page's max `_id` (the last row, since keyset sorts `_id` asc).
664            // Reported to the sink as a typed token so the runner advances by the
665            // exact BSON value, not the type-ambiguous display string.
666            let mut max_id: Option<Bson> = None;
667            while let Some(d) = cursor.try_next().await? {
668                let id = id_to_string(d.get("_id"));
669                let json = document_to_json(&d, canonical)?;
670                batch_bytes += id.len() + json.len();
671                ids.append_value(&id);
672                docs.append_value(&json);
673                if page_limit.is_some() {
674                    max_id = d.get("_id").cloned();
675                }
676                in_batch += 1;
677                total += 1;
678                if in_batch >= batch_rows || batch_bytes >= batch_byte_cap {
679                    flush(&schema, &mut ids, &mut docs, sink)?;
680                    in_batch = 0;
681                    batch_bytes = 0;
682                }
683            }
684            if in_batch > 0 {
685                flush(&schema, &mut ids, &mut docs, sink)?;
686            }
687            // Keyset only: hand the runner the exact BSON high-water mark.
688            if let Some(id) = &max_id {
689                sink.set_source_cursor(encode_id_cursor(id));
690            }
691            Ok::<usize, anyhow::Error>(total)
692        })?;
693
694        log::info!("total: {total} documents (collection '{coll_name}')");
695        Ok(())
696    }
697
698    /// The JSON-blob model has a fixed two-column schema regardless of the
699    /// query, so `check --type-report` shows it without touching the server.
700    fn type_mappings(
701        &mut self,
702        _query: &str,
703        _column_overrides: &ColumnOverrides,
704    ) -> Result<Vec<TypeMapping>> {
705        Ok(blob_mappings())
706    }
707
708    /// The only scalar rivet asks a `mode: full` source is the reconcile row
709    /// count — `SELECT COUNT(*) FROM (SELECT * FROM <coll>) AS _rivet_reconcile`.
710    /// Recognize that shape and answer it with `countDocuments` so
711    /// `rivet run --reconcile` verifies Mongo exports like the SQL engines. Any
712    /// other SQL scalar (cursor MIN/MAX, etc.) has no Mongo meaning → `None`,
713    /// which callers treat as "unavailable" and skip.
714    fn query_scalar(&mut self, sql: &str) -> Result<Option<String>> {
715        if !sql.to_ascii_lowercase().contains("count(") {
716            return Ok(None);
717        }
718        let Some(coll_name) = last_from_identifier(sql) else {
719            return Ok(None);
720        };
721        let coll = self
722            .session
723            .client()
724            .database(self.session.db())
725            .collection::<Document>(coll_name);
726        let n = self
727            .session
728            .block_on(async move { coll.count_documents(doc! {}).await })?;
729        Ok(Some(n.to_string()))
730    }
731}
732
733/// Pull the collection name out of the innermost `… FROM <ident>` of a SQL
734/// string — the reconcile count wraps the base as
735/// `SELECT COUNT(*) FROM (SELECT * FROM <coll>) AS _rivet_reconcile`, so the
736/// *last* `FROM` names the collection. `None` if no bare identifier follows.
737///
738/// The batch export path no longer un-parses SQL — it reads the structured
739/// `ExportRequest::base_relation` (ADR-0027). This un-parser survives only
740/// because `query_scalar` receives a bare SQL string with no structured
741/// counterpart yet; giving the reconcile count a typed request would delete it
742/// too (the remaining tail of ADR-0027).
743fn last_from_identifier(sql: &str) -> Option<&str> {
744    const MARKER: &str = "from ";
745    let start = sql.to_ascii_lowercase().rfind(MARKER)? + MARKER.len();
746    let ident = sql[start..]
747        .trim_start()
748        .split(|c: char| !(c.is_ascii_alphanumeric() || c == '_' || c == '.'))
749        .next()?;
750    (!ident.is_empty()).then_some(ident)
751}
752
753/// Read a possibly-nested numeric field out of a `serverStatus` document,
754/// coercing Int32 / Int64 / Double to `i64`. `None` if any path segment is
755/// missing or the leaf is not numeric — so a storage engine that omits a
756/// section (e.g. in-memory has no `wiredTiger`) just drops that one metric.
757fn nested_i64(doc: &Document, path: &[&str]) -> Option<i64> {
758    let (leaf, parents) = path.split_last()?;
759    let mut cur = doc;
760    for key in parents {
761        cur = cur.get_document(key).ok()?;
762    }
763    match cur.get(leaf) {
764        Some(Bson::Int64(v)) => Some(*v),
765        Some(Bson::Int32(v)) => Some(*v as i64),
766        Some(Bson::Double(v)) => Some(*v as i64),
767        _ => None,
768    }
769}
770
771/// Pull the source-harm counters out of a `serverStatus` response. Only
772/// **cumulative monotonic** counters are emitted (never gauges), because the
773/// pipeline stores the before→after *delta* — the same contract as the SQL
774/// engines' `sample_harm_counters`.
775fn harm_from_server_status(status: &Document) -> Vec<(String, i64)> {
776    // (metric label, serverStatus path). Chosen as the Mongo analogues of the
777    // PG harm set: docs/keys *scanned* are the read-amplification signal
778    // (≈ `pg_tup_returned`), docs *returned* ≈ `pg_tup_fetched`, `getmore` is
779    // rivet's own streaming footprint (every cursor batch), and WiredTiger
780    // cache bytes-read is the I/O-vs-cache split (≈ `pg_blks_read`).
781    let probes: [(&str, &[&str]); 6] = [
782        (
783            "mongo_docs_scanned",
784            &["metrics", "queryExecutor", "scannedObjects"],
785        ),
786        (
787            "mongo_keys_scanned",
788            &["metrics", "queryExecutor", "scanned"],
789        ),
790        ("mongo_docs_returned", &["metrics", "document", "returned"]),
791        ("mongo_op_query", &["opcounters", "query"]),
792        ("mongo_op_getmore", &["opcounters", "getmore"]),
793        (
794            "mongo_wt_cache_bytes_read",
795            &["wiredTiger", "cache", "bytes read into cache"],
796        ),
797    ];
798    probes
799        .iter()
800        .filter_map(|(name, path)| nested_i64(status, path).map(|v| ((*name).to_string(), v)))
801        .collect()
802}
803
804/// Snapshot MongoDB's source-harm counters via `serverStatus` — the document-
805/// store analogue of the SQL engines' `sample_harm_counters`. Returns
806/// `(metric, cumulative_value)` pairs; the pipeline captures these before and
807/// after the export and stores the per-metric delta in `export_harm`.
808///
809/// These are **server-wide** cumulative counters, so concurrent activity
810/// inflates the delta; on a single-tenant pilot box it is the run's own read
811/// footprint. `serverStatus` needs the `clusterMonitor` role (or the
812/// `serverStatus` privilege) on authenticated deployments — `None` on any
813/// connect / permission / query failure, exactly like the SQL engines:
814/// harm metrics are observability, never a gate.
815pub(crate) fn sample_harm_counters(
816    url: &str,
817    tls: Option<&TlsConfig>,
818) -> Option<Vec<(String, i64)>> {
819    let session = MongoSession::connect(url, tls, true).ok()?;
820    session.block_on(async {
821        let status = session
822            .client()
823            .database(session.db())
824            .run_command(doc! { "serverStatus": 1 })
825            .await
826            .ok()?;
827        Some(harm_from_server_status(&status))
828    })
829}
830
831/// Scan-free document-count estimate for one collection — the preflight
832/// `row_estimate` (the Mongo analogue of PG `reltuples` / MySQL `TABLE_ROWS`).
833/// `estimatedDocumentCount` reads collection metadata, never scans the
834/// collection. `None` on any failure — the diagnostic then shows an unknown
835/// row count, exactly as MySQL does when its estimate is untrustworthy.
836pub(crate) fn estimated_count(url: &str, tls: Option<&TlsConfig>, collection: &str) -> Option<i64> {
837    let session = MongoSession::connect(url, tls, true).ok()?;
838    session.block_on(async {
839        let n = session
840            .client()
841            .database(session.db())
842            .collection::<Document>(collection)
843            .estimated_document_count()
844            .await
845            .ok()?;
846        i64::try_from(n).ok()
847    })
848}
849
850#[cfg(test)]
851mod tests {
852    use super::*;
853    use mongodb::bson::oid::ObjectId;
854
855    // W4: the byte-cap multiplications had no exact-value test — `mb * 1024 *
856    // 1024` mutating to `+` or `/` silently shrinks (or explodes) the flush
857    // budget that keeps document batches under the i32 offset ceiling.
858    #[test]
859    fn batch_byte_cap_is_exact_mib() {
860        assert_eq!(super::mongo_batch_byte_cap(Some(10)), 10 * 1024 * 1024);
861        assert_eq!(super::mongo_batch_byte_cap(None), 256 * 1024 * 1024);
862        // Zero is not a valid budget — falls back to the default.
863        assert_eq!(super::mongo_batch_byte_cap(Some(0)), 256 * 1024 * 1024);
864    }
865
866    // ── mutation-W4 gap closure ──────────────────────────────────────────────
867    // Deleting any `id_bracket` arm survived the suite: the type falls to the
868    // `None` catch-all and keyset is (conservatively) refused — a false refusal
869    // no test noticed. The same blindness would pass the DANGEROUS neighbour: a
870    // MERGE regression (two types sharing a band number) makes collide=false
871    // and keysets across a real server band — the silent bracket-loss class the
872    // heterogeneous-`_id` guard exists to prevent. Pin the WHOLE map.
873    #[test]
874    fn id_bracket_map_matches_the_server_sort_bands() {
875        use mongodb::bson::spec::BinarySubtype;
876        use mongodb::bson::{Binary, DateTime, Decimal128, Regex, Timestamp, doc};
877        let b = |v: &Bson| id_bracket(v);
878
879        // The four numeric types share band 0 — a mixed Int32/Int64/Double
880        // `_id` keysets fine by design.
881        assert_eq!(b(&Bson::Int32(1)), Some(0));
882        assert_eq!(b(&Bson::Int64(1)), Some(0));
883        assert_eq!(b(&Bson::Double(1.0)), Some(0));
884        assert_eq!(
885            b(&Bson::Decimal128("1".parse::<Decimal128>().unwrap())),
886            Some(0)
887        );
888        // Every other placeable type is its OWN band, in server sort order.
889        assert_eq!(b(&Bson::Null), Some(1));
890        assert_eq!(b(&Bson::String("s".into())), Some(2));
891        assert_eq!(b(&Bson::Document(doc! {"k": 1})), Some(3));
892        assert_eq!(b(&Bson::Array(vec![Bson::Int32(1)])), Some(4));
893        assert_eq!(
894            b(&Bson::Binary(Binary {
895                subtype: BinarySubtype::Generic,
896                bytes: vec![1],
897            })),
898            Some(5)
899        );
900        assert_eq!(b(&Bson::ObjectId(ObjectId::new())), Some(6));
901        assert_eq!(b(&Bson::Boolean(true)), Some(7));
902        assert_eq!(b(&Bson::DateTime(DateTime::from_millis(0))), Some(8));
903        assert_eq!(
904            b(&Bson::Timestamp(Timestamp {
905                time: 0,
906                increment: 0
907            })),
908            Some(9)
909        );
910        assert_eq!(
911            b(&Bson::RegularExpression(Regex {
912                pattern: "a".into(),
913                options: String::new(),
914            })),
915            Some(10)
916        );
917        // Unplaceable types refuse keyset rather than guess.
918        assert_eq!(b(&Bson::MaxKey), None);
919
920        // The collide relation derives from the map: same band never collides,
921        // different bands always do, unplaceable collides with everything.
922        assert!(!id_types_collide(&Bson::Int32(1), &Bson::Double(2.0)));
923        assert!(id_types_collide(
924            &Bson::Int64(2000),
925            &Bson::String("id-1".into())
926        ));
927        assert!(id_types_collide(&Bson::Null, &Bson::Document(doc! {})));
928        assert!(id_types_collide(&Bson::MaxKey, &Bson::MaxKey));
929    }
930
931    #[test]
932    fn id_string_covers_common_key_types() {
933        let oid = ObjectId::parse_str("64b8f0000000000000000000").unwrap();
934        assert_eq!(
935            id_to_string(Some(&Bson::ObjectId(oid))),
936            "64b8f0000000000000000000"
937        );
938        assert_eq!(id_to_string(Some(&Bson::String("abc".to_string()))), "abc");
939        assert_eq!(id_to_string(Some(&Bson::Int64(42))), "42");
940        assert_eq!(id_to_string(Some(&Bson::Int32(7))), "7");
941        // Never panic / never silently vanish on a missing id.
942        assert_eq!(id_to_string(None), "");
943    }
944
945    #[test]
946    fn heterogeneous_id_detection_is_bracket_based() {
947        let int1001 = Bson::Int32(1001);
948        let str1001 = Bson::String("1001".to_string());
949        let oid = Bson::ObjectId(ObjectId::parse_str("64b8f0000000000000000000").unwrap());
950        // The exact collision the warehouse MERGE guards against: int and string
951        // that stringify to the SAME `_id` text but are distinct documents.
952        assert!(id_types_collide(&int1001, &str1001));
953        // ObjectId vs a string (e.g. its own hex stored as a string _id): cross-bracket.
954        assert!(id_types_collide(&oid, &str1001));
955        // Uniform is NOT flagged — same type, and the four numeric types share
956        // bracket 0 (a mixed Int32/Int64 `_id` is not a display collision).
957        assert!(!id_types_collide(&int1001, &Bson::Int32(2002)));
958        assert!(!id_types_collide(&int1001, &Bson::Int64(9_000_000_000)));
959        assert!(!id_types_collide(&oid, &oid));
960        assert!(!id_types_collide(
961            &str1001,
962            &Bson::String("abc".to_string())
963        ));
964    }
965
966    #[test]
967    fn roast_tls_config_is_honored_not_ignored() {
968        use crate::config::{TlsConfig, TlsMode};
969        use mongodb::options::Tls;
970        let cfg = |mode, ca: Option<&str>| TlsConfig {
971            mode,
972            ca_file: ca.map(String::from),
973            accept_invalid_certs: false,
974            accept_invalid_hostnames: false,
975        };
976        // verify-full: TLS on, verify chain AND hostname, ca wired.
977        match tls_to_mongo(&cfg(TlsMode::VerifyFull, Some("/ca.pem"))) {
978            Tls::Enabled(o) => {
979                assert_eq!(
980                    o.ca_file_path.as_deref(),
981                    Some(std::path::Path::new("/ca.pem"))
982                );
983                assert_eq!(o.allow_invalid_certificates, Some(false));
984            }
985            Tls::Disabled => panic!("verify-full must ENABLE tls, not ignore it"),
986        }
987        // require: TLS on but cert unverified.
988        match tls_to_mongo(&cfg(TlsMode::Require, None)) {
989            Tls::Enabled(o) => assert_eq!(o.allow_invalid_certificates, Some(true)),
990            Tls::Disabled => panic!("require must enable tls"),
991        }
992        // disable: explicit plaintext opt-in.
993        assert!(matches!(
994            tls_to_mongo(&cfg(TlsMode::Disable, None)),
995            Tls::Disabled
996        ));
997    }
998
999    #[test]
1000    fn roast_default_batch_byte_cap_is_bounded_not_unlimited() {
1001        // The whole point of the fix: with no user budget the cap must be a
1002        // finite byte value (≤ the Arrow i32 ceiling), never "unbounded" — an
1003        // unbounded batch of large documents panics ("byte array offset
1004        // overflow"). Verified live at 2300×1 MB before this guard.
1005        let cap = mongo_batch_byte_cap(None);
1006        assert!(
1007            cap > 0 && cap < (i32::MAX as usize),
1008            "cap must be finite + sub-2GiB"
1009        );
1010        // A user budget is honored (MiB → bytes); 0 falls back to the default.
1011        assert_eq!(mongo_batch_byte_cap(Some(64)), 64 * 1024 * 1024);
1012        assert_eq!(mongo_batch_byte_cap(Some(0)), mongo_batch_byte_cap(None));
1013    }
1014
1015    #[test]
1016    fn roast_non_ascii_cursor_token_is_a_clean_error_not_a_panic() {
1017        // A corrupted / foreign checkpoint token must surface as Err — never a
1018        // byte-boundary panic. '€' is 3 bytes: "€€" is 6 bytes (even, so it
1019        // passes the odd-length check) and the 2-byte hex slice at [0..2] cuts
1020        // the char in half — which panicked before hex_to_bytes rejected
1021        // non-ASCII input up front.
1022        assert!(decode_id_cursor("€€").is_err());
1023    }
1024
1025    #[test]
1026    fn id_cursor_token_roundtrips_every_bson_type_losslessly() {
1027        // The whole point of the typed token (A′): the display column can't tell
1028        // integer `1001` from string `"1001"`, but the cursor MUST — otherwise a
1029        // keyset `$gt` on the wrong BSON type mis-pages. Prove the round-trip
1030        // preserves the exact type for each `_id` shape keyset supports.
1031        let oid = Bson::ObjectId(ObjectId::parse_str("64b8f0000000000000000000").unwrap());
1032        for id in [
1033            oid.clone(),
1034            Bson::Int32(1001),
1035            Bson::Int64(9_000_000_000),
1036            Bson::String("1001".to_string()), // same text as Int32(1001) — must NOT collide
1037            Bson::String("sku-00042".to_string()),
1038        ] {
1039            let token = encode_id_cursor(&id);
1040            assert_eq!(
1041                decode_id_cursor(&token).unwrap(),
1042                id,
1043                "typed cursor round-trip lost the type for {id:?}"
1044            );
1045        }
1046        // Int32(1001) and String("1001") encode to DIFFERENT tokens (no ambiguity).
1047        assert_ne!(
1048            encode_id_cursor(&Bson::Int32(1001)),
1049            encode_id_cursor(&Bson::String("1001".to_string()))
1050        );
1051        // Legacy / display fallback: a bare 24-char ObjectId hex still decodes.
1052        assert_eq!(decode_id_cursor("64b8f0000000000000000000").unwrap(), oid);
1053    }
1054
1055    #[test]
1056    fn document_renders_relaxed_and_canonical() {
1057        let d = doc! {
1058            "a": 1i32,
1059            "b": "x",
1060            "big": 9_223_372_036_854_775_807i64,
1061            "nested": doc! { "d": true },
1062        };
1063        // Relaxed: common scalars native, Int64 as a bare JSON number.
1064        let relaxed = document_to_json(&d, false).unwrap();
1065        assert!(relaxed.contains("\"a\":1"), "got: {relaxed}");
1066        assert!(relaxed.contains("\"b\":\"x\""), "got: {relaxed}");
1067        assert!(
1068            relaxed.contains("\"nested\":{\"d\":true}"),
1069            "got: {relaxed}"
1070        );
1071        assert!(
1072            relaxed.contains("\"big\":9223372036854775807"),
1073            "int64 is a bare number in relaxed: {relaxed}"
1074        );
1075        // Canonical: every number type-tagged so a double-based JSON parser can't
1076        // clamp an Int64 beyond 2^53.
1077        let canonical = document_to_json(&d, true).unwrap();
1078        assert!(
1079            canonical.contains("\"$numberLong\":\"9223372036854775807\""),
1080            "int64 is type-tagged in canonical: {canonical}"
1081        );
1082    }
1083
1084    #[test]
1085    fn blob_schema_is_two_columns_id_and_document_json() {
1086        let schema = blob_schema();
1087        let names: Vec<&str> = schema.fields().iter().map(|f| f.name().as_str()).collect();
1088        assert_eq!(names, vec!["_id", "document"]);
1089        // The document column carries the Arrow JSON canonical extension type so
1090        // parquet emits a native JSON column downstream, not a bare string.
1091        let doc_field = schema.field_with_name("document").unwrap();
1092        assert_eq!(
1093            doc_field.metadata().get("ARROW:extension:name"),
1094            Some(&"arrow.json".to_string()),
1095            "document column must carry the arrow.json extension type"
1096        );
1097    }
1098
1099    #[test]
1100    fn harm_counters_extracted_from_server_status() {
1101        let status = doc! {
1102            "metrics": {
1103                "queryExecutor": { "scanned": 10i64, "scannedObjects": 500i64 },
1104                "document": { "returned": 480i64 },
1105            },
1106            "opcounters": { "query": 7i32, "getmore": 12i32 },
1107            "wiredTiger": { "cache": { "bytes read into cache": 1_048_576i64 } },
1108        };
1109        let harm: std::collections::HashMap<String, i64> =
1110            harm_from_server_status(&status).into_iter().collect();
1111        assert_eq!(harm.get("mongo_docs_scanned"), Some(&500));
1112        assert_eq!(harm.get("mongo_keys_scanned"), Some(&10));
1113        assert_eq!(harm.get("mongo_docs_returned"), Some(&480));
1114        assert_eq!(harm.get("mongo_op_query"), Some(&7)); // Int32 coerced to i64
1115        assert_eq!(harm.get("mongo_op_getmore"), Some(&12));
1116        assert_eq!(harm.get("mongo_wt_cache_bytes_read"), Some(&1_048_576));
1117    }
1118
1119    #[test]
1120    fn reconcile_count_query_names_the_collection() {
1121        // The reconcile count wraps the base query; the LAST `FROM` names the
1122        // collection countDocuments runs against.
1123        assert_eq!(
1124            last_from_identifier("SELECT COUNT(*) FROM (SELECT * FROM orders) AS _rivet_reconcile"),
1125            Some("orders")
1126        );
1127        assert_eq!(
1128            last_from_identifier("select count(*) from (select * from shop_events) as _r"),
1129            Some("shop_events")
1130        );
1131        // No bare identifier after a FROM → no count.
1132        assert_eq!(last_from_identifier("SELECT 1"), None);
1133    }
1134
1135    #[test]
1136    fn harm_skips_missing_sections_gracefully() {
1137        // e.g. the in-memory storage engine has no `wiredTiger` section — a
1138        // missing section drops only its own metric, never errors.
1139        let status = doc! { "opcounters": { "query": 3i64 } };
1140        let harm: std::collections::HashMap<String, i64> =
1141            harm_from_server_status(&status).into_iter().collect();
1142        assert_eq!(harm.get("mongo_op_query"), Some(&3));
1143        assert!(!harm.contains_key("mongo_wt_cache_bytes_read"));
1144        assert!(!harm.contains_key("mongo_docs_scanned"));
1145    }
1146}