Skip to main content

faucet_core/
replication.rs

1//! Incremental replication support.
2
3use crate::error::FaucetError;
4use chrono::{DateTime, NaiveDate, NaiveDateTime, Utc};
5use schemars::JsonSchema;
6use serde::{Deserialize, Serialize};
7use serde_json::Value;
8use std::cmp::Ordering;
9
10pub mod cursor;
11
12/// Determines how records are replicated from the source.
13#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, JsonSchema)]
14#[serde(tag = "type")]
15pub enum ReplicationMethod {
16    /// All records are fetched on every run (default).
17    #[default]
18    FullTable,
19    /// Only records where the `replication_key` field is strictly greater than
20    /// the stored bookmark (`start_replication_value`) are kept.
21    Incremental,
22}
23
24/// What to do with a record whose replication key is missing or `null` (#747).
25#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)]
26#[serde(rename_all = "snake_case")]
27pub enum OnMissingKey {
28    /// Keep the record, count it, and warn (default): dropping it would be
29    /// silent data loss.
30    #[default]
31    Keep,
32    /// Drop the record (counted and warned, never silent).
33    Drop,
34    /// Fail the run.
35    Fail,
36}
37
38#[derive(Debug, Clone, PartialEq, Eq)]
39enum KeyForm {
40    TopLevel,
41    DotPath(Vec<String>),
42    Pointer,
43}
44
45/// A compiled replication key: a top-level field name, a dot path
46/// (`fields.updated`, numeric segments index arrays), or an RFC 6901 JSON
47/// Pointer (`/fields/updated`, for field names that contain a dot) (#747).
48///
49/// A dot path first tries the whole string as a literal top-level field, so a
50/// flat column literally named `Account.LastModifiedDate` (a CSV header) still
51/// resolves.
52#[derive(Debug, Clone, PartialEq, Eq)]
53pub struct ReplicationKey {
54    raw: String,
55    form: KeyForm,
56}
57
58impl ReplicationKey {
59    /// Parse a user-facing key (see the type docs for the accepted forms).
60    pub fn parse(raw: &str) -> Result<Self, FaucetError> {
61        if raw.trim().is_empty() {
62            return Err(FaucetError::Config(
63                "replication_key must not be empty".to_owned(),
64            ));
65        }
66        if raw.starts_with('/') {
67            return Ok(Self {
68                raw: raw.to_owned(),
69                form: KeyForm::Pointer,
70            });
71        }
72        if !raw.contains('.') {
73            return Ok(Self::top_level(raw));
74        }
75        let segments: Vec<String> = raw.split('.').map(str::to_owned).collect();
76        if segments.iter().any(String::is_empty) {
77            return Err(FaucetError::Config(format!(
78                "replication_key '{raw}': empty path segment (use the JSON Pointer form \
79                 `/a/b` for field names that contain dots)"
80            )));
81        }
82        Ok(Self {
83            raw: raw.to_owned(),
84            form: KeyForm::DotPath(segments),
85        })
86    }
87
88    /// A literal top-level field name, never interpreted as a path.
89    pub fn top_level(name: &str) -> Self {
90        Self {
91            raw: name.to_owned(),
92            form: KeyForm::TopLevel,
93        }
94    }
95
96    /// The key as configured.
97    pub fn as_str(&self) -> &str {
98        &self.raw
99    }
100
101    /// Whether the key is a JSON Pointer (`/a/b`).
102    pub fn is_pointer(&self) -> bool {
103        self.form == KeyForm::Pointer
104    }
105
106    /// Whether the key addresses a nested value (dot path or pointer).
107    pub fn is_nested(&self) -> bool {
108        self.form != KeyForm::TopLevel
109    }
110
111    /// Resolve the key against one record.
112    pub fn resolve<'a>(&self, record: &'a Value) -> Option<&'a Value> {
113        match &self.form {
114            KeyForm::TopLevel => record.get(&self.raw),
115            KeyForm::Pointer => record.pointer(&self.raw),
116            KeyForm::DotPath(segments) => {
117                if let Some(v) = record.get(&self.raw) {
118                    return Some(v);
119                }
120                segments.iter().try_fold(record, |cur, seg| match cur {
121                    Value::Object(m) => m.get(seg),
122                    Value::Array(a) => seg.parse::<usize>().ok().and_then(|i| a.get(i)),
123                    _ => None,
124                })
125            }
126        }
127    }
128
129    fn resolve_present<'a>(&self, record: &'a Value) -> Option<&'a Value> {
130        self.resolve(record).filter(|v| !v.is_null())
131    }
132}
133
134/// The result of [`filter_incremental_path`].
135#[derive(Debug, Clone, PartialEq, Default)]
136pub struct IncrementalFilter {
137    /// Records to write.
138    pub records: Vec<Value>,
139    /// Records whose key was missing or `null` (kept or dropped per policy).
140    pub missing: usize,
141}
142
143/// Filter `records` to those where `key > start`, using a compiled key.
144///
145/// A record whose key is missing or `null` is handled per `on_missing`
146/// (counted in [`IncrementalFilter::missing`] either way); a record whose key
147/// has a different JSON type than `start` is kept with a warning. Returns
148/// `Err` only for [`OnMissingKey::Fail`].
149pub fn filter_incremental_path(
150    records: Vec<Value>,
151    key: &ReplicationKey,
152    start: &Value,
153    on_missing: OnMissingKey,
154) -> Result<IncrementalFilter, FaucetError> {
155    filter_path(records, key, start, on_missing, false)
156}
157
158/// [`filter_incremental_path`] keeping `key >= start`: records sharing the
159/// bookmark value are re-delivered rather than lost when written after the
160/// previous run read that value (at-least-once at the boundary; a keyed upsert
161/// sink absorbs the repeat).
162pub fn filter_incremental_path_inclusive(
163    records: Vec<Value>,
164    key: &ReplicationKey,
165    start: &Value,
166    on_missing: OnMissingKey,
167) -> Result<IncrementalFilter, FaucetError> {
168    filter_path(records, key, start, on_missing, true)
169}
170
171fn filter_path(
172    records: Vec<Value>,
173    key: &ReplicationKey,
174    start: &Value,
175    on_missing: OnMissingKey,
176    inclusive: bool,
177) -> Result<IncrementalFilter, FaucetError> {
178    let mut missing = 0usize;
179    let mut kept = Vec::with_capacity(records.len());
180    for r in records {
181        let keep = match key.resolve_present(&r) {
182            None => {
183                missing += 1;
184                match on_missing {
185                    OnMissingKey::Keep => true,
186                    OnMissingKey::Drop => false,
187                    OnMissingKey::Fail => {
188                        return Err(FaucetError::Source(format!(
189                            "incremental replication: a record lacks replication_key '{}' \
190                             (on_missing_key: fail)",
191                            key.as_str()
192                        )));
193                    }
194                }
195            }
196            Some(v) if !comparable(v, start) => {
197                tracing::warn!(
198                    key = key.as_str(),
199                    "incremental replication: record key type does not match the bookmark \
200                     type; keeping the record to avoid silently dropping data"
201                );
202                true
203            }
204            Some(v) if inclusive => !json_gt(start, v),
205            Some(v) => json_gt(v, start),
206        };
207        if keep {
208            kept.push(r);
209        }
210    }
211    Ok(IncrementalFilter {
212        records: kept,
213        missing,
214    })
215}
216
217/// Filter `records` to only those where `record[key] > start`.
218///
219/// `key` is a literal top-level field. Values compare by what they hold (see
220/// [`json_gt`]): decimal strings numerically, timestamp strings as instants,
221/// integers exactly (no `f64` precision loss), floats as `f64`, other strings
222/// lexicographically.
223///
224/// Records missing the key (or holding `null`) are **kept** and a warning is
225/// logged (#747): dropping them silently is data loss. Likewise a record whose
226/// key value is a *different JSON type* than `start` is kept (#78/#27).
227pub fn filter_incremental(records: Vec<Value>, key: &str, start: &Value) -> Vec<Value> {
228    let out = filter_incremental_path(
229        records,
230        &ReplicationKey::top_level(key),
231        start,
232        OnMissingKey::Keep,
233    )
234    .unwrap_or_default();
235    if out.missing > 0 {
236        tracing::warn!(
237            key,
238            missing = out.missing,
239            "incremental replication: {} record(s) lacked replication_key '{key}'; kept to \
240             avoid silent data loss",
241            out.missing
242        );
243    }
244    out.records
245}
246
247/// Return the maximum non-null value of `key` across all records, if any.
248pub fn max_replication_value_path<'a>(
249    records: &'a [Value],
250    key: &ReplicationKey,
251) -> Option<&'a Value> {
252    records
253        .iter()
254        .filter_map(|r| key.resolve_present(r))
255        .max_by(|a, b| json_compare(a, b))
256}
257
258/// Return the maximum value of `record[key]` across all records, if any.
259pub fn max_replication_value<'a>(records: &'a [Value], key: &str) -> Option<&'a Value> {
260    records
261        .iter()
262        .filter_map(|r| r.get(key))
263        .max_by(|a, b| json_compare(a, b))
264}
265
266/// Return the larger of two replication values using the same ordering as
267/// [`max_replication_value`] (see [`json_gt`]; falling back to `a` on a tie).
268pub fn max_value(a: Value, b: Value) -> Value {
269    match json_compare(&a, &b) {
270        Ordering::Less => b,
271        _ => a,
272    }
273}
274
275/// Type-rank for a total ordering across JSON value kinds, so comparisons of
276/// differing types are deterministic instead of collapsing to `Equal`.
277fn type_rank(v: &Value) -> u8 {
278    match v {
279        Value::Null => 0,
280        Value::Bool(_) => 1,
281        Value::Number(_) => 2,
282        Value::String(_) => 3,
283        Value::Array(_) => 4,
284        Value::Object(_) => 5,
285    }
286}
287
288/// Exact integer view of a JSON number (`i64` or `u64`), widened to `i128` so
289/// both halves of the range compare without `f64` precision loss. `None` for
290/// non-integral (floating) numbers.
291fn number_as_i128(n: &serde_json::Number) -> Option<i128> {
292    n.as_i64()
293        .map(i128::from)
294        .or_else(|| n.as_u64().map(i128::from))
295}
296
297/// Whether two replication values can be ordered against each other: the same
298/// JSON type, or a number and a decimal string (sources such as Oracle emit a
299/// `NUMBER` as a number when it fits `f64` exactly and as a string otherwise).
300fn comparable(a: &Value, b: &Value) -> bool {
301    type_rank(a) == type_rank(b) || numeric_text(a).is_some() && numeric_text(b).is_some()
302}
303
304/// The decimal text of a number or a decimal string.
305fn numeric_text(v: &Value) -> Option<std::borrow::Cow<'_, str>> {
306    match v {
307        Value::Number(n) => Some(std::borrow::Cow::Owned(n.to_string())),
308        Value::String(s) if Decimal::parse(s).is_some() => Some(std::borrow::Cow::Borrowed(s)),
309        _ => None,
310    }
311}
312
313/// An exact decimal: `0.d₁d₂… × 10^exp` with no leading or trailing zero
314/// digits (`digits` empty means zero). Arbitrary length, so a DECIMAL(38)
315/// cursor orders exactly.
316#[derive(Debug, PartialEq, Eq)]
317struct Decimal {
318    negative: bool,
319    digits: Vec<u8>,
320    exp: i64,
321}
322
323impl Decimal {
324    fn parse(text: &str) -> Option<Self> {
325        let s = text.trim();
326        let (negative, s) = match s.as_bytes().first()? {
327            b'-' => (true, &s[1..]),
328            b'+' => (false, &s[1..]),
329            _ => (false, s),
330        };
331        let (mantissa, exponent) = match s.find(['e', 'E']) {
332            Some(i) => (&s[..i], s[i + 1..].parse::<i64>().ok()?),
333            None => (s, 0),
334        };
335        let (int, frac) = match mantissa.split_once('.') {
336            Some((i, f)) => (i, f),
337            None => (mantissa, ""),
338        };
339        if int.is_empty() && frac.is_empty()
340            || !int.bytes().chain(frac.bytes()).all(|b| b.is_ascii_digit())
341        {
342            return None;
343        }
344        let mut digits: Vec<u8> = int.bytes().chain(frac.bytes()).map(|b| b - b'0').collect();
345        let mut point = i64::try_from(int.len()).ok()?;
346        let leading = digits.iter().take_while(|d| **d == 0).count();
347        digits.drain(..leading);
348        point -= i64::try_from(leading).ok()?;
349        while digits.last() == Some(&0) {
350            digits.pop();
351        }
352        let exp = if digits.is_empty() {
353            0
354        } else {
355            point.checked_add(exponent)?
356        };
357        Some(Self {
358            negative: negative && !digits.is_empty(),
359            digits,
360            exp,
361        })
362    }
363
364    fn signum(&self) -> i8 {
365        match (self.digits.is_empty(), self.negative) {
366            (true, _) => 0,
367            (false, true) => -1,
368            (false, false) => 1,
369        }
370    }
371
372    fn cmp_magnitude(&self, other: &Self) -> Ordering {
373        self.exp
374            .cmp(&other.exp)
375            .then_with(|| self.digits.cmp(&other.digits))
376    }
377}
378
379impl Ord for Decimal {
380    fn cmp(&self, other: &Self) -> Ordering {
381        match self.signum().cmp(&other.signum()) {
382            Ordering::Equal => match self.signum() {
383                1 => self.cmp_magnitude(other),
384                -1 => other.cmp_magnitude(self),
385                _ => Ordering::Equal,
386            },
387            unequal => unequal,
388        }
389    }
390}
391
392impl PartialOrd for Decimal {
393    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
394        Some(self.cmp(other))
395    }
396}
397
398/// A timestamp string as an instant: RFC 3339 with an offset (a space may
399/// replace the `T`), compared in UTC; or the same without an offset, compared
400/// as a local date-time against another offset-less value.
401#[derive(Debug, PartialEq, Eq, PartialOrd, Ord)]
402enum Instant {
403    Zoned(DateTime<Utc>),
404    Naive(chrono::NaiveDateTime),
405}
406
407impl Instant {
408    fn parse(text: &str) -> Option<Self> {
409        let s = text.trim();
410        if s.len() < 19 || !s.as_bytes()[..4].iter().all(u8::is_ascii_digit) {
411            return None;
412        }
413        let normalized = if s.as_bytes()[10] == b' ' {
414            let mut t = s.to_string();
415            t.replace_range(10..11, "T");
416            std::borrow::Cow::Owned(t)
417        } else {
418            std::borrow::Cow::Borrowed(s)
419        };
420        if let Ok(dt) = DateTime::parse_from_rfc3339(&normalized) {
421            return Some(Self::Zoned(dt.with_timezone(&Utc)));
422        }
423        chrono::NaiveDateTime::parse_from_str(&normalized, "%Y-%m-%dT%H:%M:%S%.f")
424            .ok()
425            .map(Self::Naive)
426    }
427}
428
429/// Order two strings by what they hold: both decimals → numerically, both
430/// instants of the same kind → chronologically, otherwise lexicographically.
431fn compare_strings(x: &str, y: &str) -> Ordering {
432    if let (Some(a), Some(b)) = (Decimal::parse(x), Decimal::parse(y)) {
433        return a.cmp(&b);
434    }
435    match (Instant::parse(x), Instant::parse(y)) {
436        (Some(a @ Instant::Zoned(_)), Some(b @ Instant::Zoned(_)))
437        | (Some(a @ Instant::Naive(_)), Some(b @ Instant::Naive(_))) => a.cmp(&b),
438        _ => x.cmp(y),
439    }
440}
441
442/// Total ordering over JSON values used for replication bookmarks.
443///
444/// - Numbers: compared exactly as `i128` when both are integral (so cursors
445///   above 2^53 don't lose precision); otherwise as `f64`, with NaN ordered
446///   last.
447/// - Strings: decimal strings numerically and exactly (`"99" < "100"`),
448///   timestamps chronologically after normalizing offsets and fraction widths,
449///   anything else lexicographically.
450/// - A number against a decimal string: numerically.
451/// - Other same-type values: natural ordering (bools `false < true`, arrays
452///   element-wise, objects by serialized form).
453/// - Different types: ordered by [`type_rank`] so the result is always total.
454pub(crate) fn json_compare(a: &Value, b: &Value) -> Ordering {
455    match (a, b) {
456        (Value::Number(_), Value::String(_)) | (Value::String(_), Value::Number(_)) => {
457            match (numeric_text(a), numeric_text(b)) {
458                (Some(x), Some(y)) => match (Decimal::parse(&x), Decimal::parse(&y)) {
459                    (Some(x), Some(y)) => x.cmp(&y),
460                    _ => type_rank(a).cmp(&type_rank(b)),
461                },
462                _ => type_rank(a).cmp(&type_rank(b)),
463            }
464        }
465        (Value::Number(an), Value::Number(bn)) => {
466            match (number_as_i128(an), number_as_i128(bn)) {
467                (Some(ai), Some(bi)) => ai.cmp(&bi),
468                _ => {
469                    let af = an.as_f64().unwrap_or(f64::NAN);
470                    let bf = bn.as_f64().unwrap_or(f64::NAN);
471                    af.partial_cmp(&bf).unwrap_or_else(|| {
472                        // At least one NaN — order NaN last, deterministically.
473                        match (af.is_nan(), bf.is_nan()) {
474                            (false, true) => Ordering::Less,
475                            (true, false) => Ordering::Greater,
476                            _ => Ordering::Equal,
477                        }
478                    })
479                }
480            }
481        }
482        (Value::String(x), Value::String(y)) => compare_strings(x, y),
483        (Value::Bool(x), Value::Bool(y)) => x.cmp(y),
484        (Value::Null, Value::Null) => Ordering::Equal,
485        (Value::Array(x), Value::Array(y)) => {
486            for (xi, yi) in x.iter().zip(y.iter()) {
487                let c = json_compare(xi, yi);
488                if c != Ordering::Equal {
489                    return c;
490                }
491            }
492            x.len().cmp(&y.len())
493        }
494        // Objects have no natural order; use the serialized form for a stable
495        // total order (objects as replication keys are pathological).
496        (Value::Object(_), Value::Object(_)) => a.to_string().cmp(&b.to_string()),
497        // Different JSON types — order by type rank so comparison is total.
498        _ => type_rank(a).cmp(&type_rank(b)),
499    }
500}
501
502/// Total-order "greater than" over JSON values, using the same comparison
503/// [`filter_incremental`] applies to replication keys (numbers and decimal
504/// strings numerically, timestamp strings chronologically, other strings
505/// lexicographically). Public
506/// so callers bounding a replay window (e.g. `faucet backfill --to-bookmark`)
507/// compare exactly like the incremental filter does.
508pub fn json_gt(a: &Value, b: &Value) -> bool {
509    json_compare(a, b) == Ordering::Greater
510}
511
512// ── Server-side incremental push-down (#513) ─────────────────────────────────
513
514/// The placeholder replaced by the formatted bookmark inside a
515/// [`ReplicationBind::template`].
516pub const BIND_PLACEHOLDER: &str = "${bookmark}";
517
518fn default_bind_template() -> String {
519    BIND_PLACEHOLDER.to_owned()
520}
521
522/// Where a rendered bookmark is injected into the outgoing request.
523#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)]
524#[serde(rename_all = "snake_case")]
525pub enum BindTarget {
526    /// A query-string parameter (default) — e.g. `?updated_after=…`.
527    #[default]
528    Query,
529    /// A request header — e.g. `If-Modified-Since: …`.
530    Header,
531    /// A field of the JSON request body (POST-search APIs): a top-level field
532    /// named by `name`, or any existing location addressed by a JSON Pointer
533    /// `path` (#748).
534    Body,
535    /// A `{name}` placeholder in the request path.
536    Path,
537}
538
539/// How the bookmark value is formatted before it is substituted into the
540/// [`ReplicationBind::template`].
541///
542/// For every non-[`Raw`](BindFormat::Raw) format the bookmark is first parsed
543/// into an instant: a string is read as RFC 3339, a bare `YYYY-MM-DD` date
544/// (midnight UTC), or a naive `YYYY-MM-DDTHH:MM:SS` (assumed UTC); a JSON
545/// number is read as **epoch seconds**. It is then re-emitted in the target
546/// representation, so `epoch_ms` ← ISO string and `iso8601` ← epoch number both
547/// work. A JSON number under `epoch_s` / `epoch_ms` is taken to be in that
548/// unit already and emitted as its integer value.
549#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)]
550#[serde(rename_all = "snake_case")]
551pub enum BindFormat {
552    /// Emit the scalar verbatim (string as-is, number as its decimal form).
553    /// The default; no timestamp parsing.
554    #[default]
555    Raw,
556    /// RFC 3339 / ISO-8601 UTC timestamp, e.g. `2024-06-01T00:00:00Z`.
557    Iso8601,
558    /// Unix epoch **seconds** (integer).
559    EpochS,
560    /// Unix epoch **milliseconds** (integer).
561    EpochMs,
562    /// Calendar date `YYYY-MM-DD` (UTC).
563    Date,
564}
565
566/// The JSON type a body-target bind writes (#748).
567#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)]
568#[serde(rename_all = "snake_case")]
569pub enum BindValueType {
570    /// A JSON string (default).
571    #[default]
572    String,
573    /// A JSON number (e.g. an `epoch_ms` bookmark an API requires unquoted).
574    Number,
575}
576
577impl BindValueType {
578    /// Convert a rendered bind value into the JSON value to write.
579    pub fn to_value(self, rendered: &str) -> Result<Value, FaucetError> {
580        match self {
581            Self::String => Ok(Value::String(rendered.to_owned())),
582            Self::Number => {
583                let n: Option<serde_json::Number> = rendered
584                    .parse::<i64>()
585                    .map(serde_json::Number::from)
586                    .ok()
587                    .or_else(|| rendered.parse::<u64>().ok().map(serde_json::Number::from))
588                    .or_else(|| {
589                        rendered
590                            .parse::<f64>()
591                            .ok()
592                            .and_then(serde_json::Number::from_f64)
593                    });
594                n.map(Value::Number).ok_or_else(|| {
595                    FaucetError::Source(format!(
596                        "bind: rendered value '{rendered}' is not a number (value_type: number)"
597                    ))
598                })
599            }
600        }
601    }
602}
603
604fn unescape_pointer_token(token: &str) -> String {
605    token.replace("~1", "/").replace("~0", "~")
606}
607
608/// Write `value` into `body` at the RFC 6901 JSON Pointer `pointer` (#748).
609///
610/// The pointer must address an existing scalar (or `null`) — or a missing
611/// final key whose parent is an existing object. Intermediate objects and
612/// array elements are never created, and an object/array target is refused
613/// (a bind replaces a value; it does not merge).
614pub fn set_body_pointer(body: &mut Value, pointer: &str, value: Value) -> Result<(), FaucetError> {
615    if !pointer.starts_with('/') {
616        return Err(FaucetError::Config(format!(
617            "JSON Pointer '{pointer}' must start with '/'"
618        )));
619    }
620    if let Some(slot) = body.pointer_mut(pointer) {
621        if slot.is_object() || slot.is_array() {
622            return Err(FaucetError::Source(format!(
623                "request body location '{pointer}' holds an object or array; a bind replaces \
624                 a scalar value"
625            )));
626        }
627        *slot = value;
628        return Ok(());
629    }
630    let cut = pointer.rfind('/').unwrap_or(0);
631    let (parent, leaf) = (&pointer[..cut], &pointer[cut + 1..]);
632    let parent_value = if parent.is_empty() {
633        Some(body)
634    } else {
635        body.pointer_mut(parent)
636    };
637    match parent_value {
638        Some(Value::Object(map)) => {
639            map.insert(unescape_pointer_token(leaf), value);
640            Ok(())
641        }
642        _ => Err(FaucetError::Source(format!(
643            "request body has no location '{pointer}' (the pointer must resolve to an \
644             existing value, or to a new key of an existing object)"
645        ))),
646    }
647}
648
649/// Load-time check of a bind's placement: a body bind needs exactly one of
650/// `name` / `path`; every other target needs `name` and refuses `path`.
651pub(crate) fn validate_bind_placement(
652    what: &str,
653    into: BindTarget,
654    name: &str,
655    path: Option<&str>,
656) -> Result<(), FaucetError> {
657    let has_name = !name.trim().is_empty();
658    match (into, path) {
659        (BindTarget::Body, Some(p)) => {
660            if has_name {
661                return Err(FaucetError::Config(format!(
662                    "{what}: set either `name` (top-level body field) or `path` (JSON Pointer), \
663                     not both"
664                )));
665            }
666            if !p.starts_with('/') || p.len() < 2 {
667                return Err(FaucetError::Config(format!(
668                    "{what}: `path` must be a JSON Pointer such as `/filters/0/value`, got '{p}'"
669                )));
670            }
671            Ok(())
672        }
673        (_, Some(_)) => Err(FaucetError::Config(format!(
674            "{what}: `path` applies only to `into: body`"
675        ))),
676        (BindTarget::Body, None) if !has_name => Err(FaucetError::Config(format!(
677            "{what}: `into: body` needs `name` (top-level field) or `path` (JSON Pointer)"
678        ))),
679        (_, None) if !has_name => Err(FaucetError::Config(format!(
680            "{what}: `name` must not be empty"
681        ))),
682        _ => Ok(()),
683    }
684}
685
686/// Declarative binding of the stored bookmark into the **outgoing request** —
687/// "server-side incremental push-down" (#513).
688///
689/// Today faucet tracks bookmarks and filters incrementally *client-side* (after
690/// download). A bind lets a source instead push the bookmark into the request
691/// (query param / header / body field / path) so the server returns only the
692/// new rows. The existing client-side [`filter_incremental`] stays active as a
693/// safety net for servers that don't honour the filter exactly.
694#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
695#[serde(deny_unknown_fields)]
696pub struct ReplicationBind {
697    /// Where to place the rendered value.
698    #[serde(default)]
699    pub into: BindTarget,
700    /// The parameter / header / body-field / path-placeholder name. Optional
701    /// only for `into: body` with a `path`.
702    #[serde(default)]
703    pub name: String,
704    /// `into: body` only: an RFC 6901 JSON Pointer into the configured `body`
705    /// (`/filterGroups/0/filters/0/value`) instead of a top-level `name` (#748).
706    /// It must resolve to an existing scalar or to a new key of an existing
707    /// object; array elements are never created.
708    #[serde(default, skip_serializing_if = "Option::is_none")]
709    pub path: Option<String>,
710    /// JSON type written by a body bind: `string` (default) or `number`.
711    #[serde(default)]
712    pub value_type: BindValueType,
713    /// Template rendered with [`BIND_PLACEHOLDER`] (`${bookmark}`) replaced by
714    /// the formatted bookmark. Defaults to the bare `${bookmark}`; set e.g.
715    /// `"gte|${bookmark}"` (an operator-prefixed filter) or `"[${bookmark} TO *]"` (Lucene).
716    #[serde(default = "default_bind_template")]
717    pub template: String,
718    /// How to format the bookmark before substitution.
719    #[serde(default)]
720    pub format: BindFormat,
721    /// Optional JSONPath into the response body to advance the bookmark from,
722    /// instead of `max(record[replication_key])`.
723    #[serde(default, skip_serializing_if = "Option::is_none")]
724    pub advance_from: Option<String>,
725}
726
727impl ReplicationBind {
728    /// Validate the binding at config-load time.
729    pub fn validate(&self) -> Result<(), FaucetError> {
730        validate_bind_placement(
731            "replication bind",
732            self.into,
733            &self.name,
734            self.path.as_deref(),
735        )?;
736        if !self.template.contains(BIND_PLACEHOLDER) {
737            return Err(FaucetError::Config(format!(
738                "replication bind: `template` must contain the `{BIND_PLACEHOLDER}` placeholder"
739            )));
740        }
741        Ok(())
742    }
743
744    /// Render the binding for a concrete bookmark: format the value, then
745    /// substitute it into the template.
746    pub fn render(&self, bookmark: &Value) -> Result<String, FaucetError> {
747        let formatted = format_bookmark(bookmark, self.format)?;
748        Ok(self.template.replace(BIND_PLACEHOLDER, &formatted))
749    }
750}
751
752/// Parse a bookmark value into a UTC instant (see [`BindFormat`] for the rules).
753fn bookmark_instant(value: &Value) -> Result<DateTime<Utc>, FaucetError> {
754    match value {
755        Value::String(s) => {
756            let s = s.trim();
757            if let Ok(dt) = DateTime::parse_from_rfc3339(s) {
758                return Ok(dt.with_timezone(&Utc));
759            }
760            if let Ok(d) = NaiveDate::parse_from_str(s, "%Y-%m-%d")
761                && let Some(ndt) = d.and_hms_opt(0, 0, 0)
762            {
763                return Ok(DateTime::<Utc>::from_naive_utc_and_offset(ndt, Utc));
764            }
765            if let Ok(ndt) = NaiveDateTime::parse_from_str(s, "%Y-%m-%dT%H:%M:%S") {
766                return Ok(DateTime::<Utc>::from_naive_utc_and_offset(ndt, Utc));
767            }
768            Err(FaucetError::Config(format!(
769                "replication bind: cannot parse bookmark '{s}' as a timestamp \
770                 (expected RFC 3339, YYYY-MM-DD, or YYYY-MM-DDTHH:MM:SS)"
771            )))
772        }
773        Value::Number(n) => {
774            let secs = n.as_i64().or_else(|| n.as_f64().map(|f| f as i64));
775            secs.and_then(|s| DateTime::<Utc>::from_timestamp(s, 0))
776                .ok_or_else(|| {
777                    FaucetError::Config(format!(
778                        "replication bind: numeric bookmark {n} is out of range for epoch seconds"
779                    ))
780                })
781        }
782        other => Err(FaucetError::Config(format!(
783            "replication bind: bookmark must be a string or number, got {other}"
784        ))),
785    }
786}
787
788/// Parse a bookmark value into a UTC instant (public wrapper over the internal
789/// parser; see [`BindFormat`] for the accepted forms). Used by datetime window
790/// slicing (#527) to resolve the sweep's start bound from the stored bookmark.
791pub fn parse_instant(value: &Value) -> Result<DateTime<Utc>, FaucetError> {
792    bookmark_instant(value)
793}
794
795/// Format an already-resolved UTC instant per [`BindFormat`]. Unlike
796/// [`format_bookmark`] (which takes an arbitrary scalar and, for [`BindFormat::Raw`],
797/// echoes it verbatim), this always has a real instant, so `Raw` and `Iso8601`
798/// both emit an RFC 3339 UTC timestamp. Used to render window boundaries (#527).
799pub fn format_instant(dt: DateTime<Utc>, format: BindFormat) -> String {
800    match format {
801        BindFormat::Raw | BindFormat::Iso8601 => {
802            dt.to_rfc3339_opts(chrono::SecondsFormat::Secs, true)
803        }
804        BindFormat::Date => dt.format("%Y-%m-%d").to_string(),
805        BindFormat::EpochS => dt.timestamp().to_string(),
806        BindFormat::EpochMs => dt.timestamp_millis().to_string(),
807    }
808}
809
810/// Format a bookmark value per [`BindFormat`].
811pub fn format_bookmark(value: &Value, format: BindFormat) -> Result<String, FaucetError> {
812    match format {
813        BindFormat::Raw => match value {
814            Value::String(s) => Ok(s.clone()),
815            Value::Number(n) => Ok(n.to_string()),
816            Value::Bool(b) => Ok(b.to_string()),
817            other => Err(FaucetError::Config(format!(
818                "replication bind: cannot render {other} as a raw scalar"
819            ))),
820        },
821        BindFormat::Iso8601 => {
822            Ok(bookmark_instant(value)?.to_rfc3339_opts(chrono::SecondsFormat::Secs, true))
823        }
824        BindFormat::Date => Ok(bookmark_instant(value)?.format("%Y-%m-%d").to_string()),
825        BindFormat::EpochS | BindFormat::EpochMs if value.is_number() => Ok(epoch_number(value)),
826        BindFormat::EpochS => Ok(bookmark_instant(value)?.timestamp().to_string()),
827        BindFormat::EpochMs => Ok(bookmark_instant(value)?.timestamp_millis().to_string()),
828    }
829}
830
831/// A numeric bookmark under an epoch bind is already in the bind's unit (an
832/// `epoch_ms` cursor stores milliseconds), so it is emitted as the integer it
833/// holds rather than re-read as seconds and scaled.
834fn epoch_number(value: &Value) -> String {
835    match value.as_i64() {
836        Some(i) => i.to_string(),
837        None => match value.as_u64() {
838            Some(u) => u.to_string(),
839            None => (value.as_f64().unwrap_or(0.0).trunc() as i64).to_string(),
840        },
841    }
842}
843
844#[cfg(test)]
845mod inclusive_tests {
846    use super::*;
847    use serde_json::json;
848
849    #[test]
850    fn the_inclusive_filter_keeps_rows_at_the_bookmark() {
851        let key = ReplicationKey::top_level("ts");
852        let rows = vec![
853            json!({"ts": 4}),
854            json!({"ts": 5}),
855            json!({"ts": 6}),
856            json!({"ts": "x"}),
857            json!({"id": 1}),
858        ];
859        let kept =
860            filter_incremental_path_inclusive(rows.clone(), &key, &json!(5), OnMissingKey::Keep)
861                .unwrap();
862        assert_eq!(
863            kept.records,
864            vec![
865                json!({"ts": 5}),
866                json!({"ts": 6}),
867                json!({"ts": "x"}),
868                json!({"id": 1})
869            ]
870        );
871        assert_eq!(kept.missing, 1);
872        let strict = filter_incremental_path(rows, &key, &json!(5), OnMissingKey::Drop).unwrap();
873        assert_eq!(strict.records, vec![json!({"ts": 6}), json!({"ts": "x"})]);
874    }
875}
876
877#[cfg(test)]
878mod tests {
879    use super::*;
880    use serde_json::json;
881
882    #[test]
883    fn test_filter_incremental_strings() {
884        let records = vec![
885            json!({"id": 1, "updated_at": "2024-01-01"}),
886            json!({"id": 2, "updated_at": "2024-06-01"}),
887            json!({"id": 3, "updated_at": "2024-12-01"}),
888        ];
889        let start = json!("2024-06-01");
890        let filtered = filter_incremental(records, "updated_at", &start);
891        assert_eq!(filtered.len(), 1);
892        assert_eq!(filtered[0]["id"], 3);
893    }
894
895    #[test]
896    fn test_filter_incremental_numbers() {
897        let records = vec![
898            json!({"id": 1, "seq": 100}),
899            json!({"id": 2, "seq": 200}),
900            json!({"id": 3, "seq": 300}),
901        ];
902        let start = json!(150);
903        let filtered = filter_incremental(records, "seq", &start);
904        assert_eq!(filtered.len(), 2);
905        assert_eq!(filtered[0]["id"], 2);
906        assert_eq!(filtered[1]["id"], 3);
907    }
908
909    #[test]
910    fn test_filter_incremental_missing_key_kept() {
911        // #747: a record without the key used to be dropped silently.
912        let records = vec![
913            json!({"id": 1}),
914            json!({"id": 2, "updated_at": "2024-12-01"}),
915            json!({"id": 3, "updated_at": null}),
916        ];
917        let start = json!("2024-01-01");
918        let filtered = filter_incremental(records, "updated_at", &start);
919        assert_eq!(filtered.len(), 3);
920    }
921
922    #[test]
923    fn replication_key_parse_forms() {
924        assert!(!ReplicationKey::parse("updated").unwrap().is_nested());
925        let dot = ReplicationKey::parse("fields.updated").unwrap();
926        assert!(dot.is_nested() && !dot.is_pointer());
927        assert_eq!(dot.as_str(), "fields.updated");
928        let ptr = ReplicationKey::parse("/a.b/c").unwrap();
929        assert!(ptr.is_pointer() && ptr.is_nested());
930        assert!(ReplicationKey::parse(" ").is_err());
931        assert!(ReplicationKey::parse("a..b").is_err());
932        assert!(ReplicationKey::parse(".a").is_err());
933    }
934
935    #[test]
936    fn replication_key_resolves_nested_array_and_pointer() {
937        let r = json!({
938            "fields": {"updated": "2024-06-01"},
939            "items": [{"date": 1}, {"date": 2}],
940            "a.b": {"c": 7},
941            "x": 5
942        });
943        let k = |s: &str| ReplicationKey::parse(s).unwrap();
944        assert_eq!(k("fields.updated").resolve(&r), Some(&json!("2024-06-01")));
945        assert_eq!(k("items.1.date").resolve(&r), Some(&json!(2)));
946        assert_eq!(k("items.x.date").resolve(&r), None);
947        assert_eq!(k("items.9.date").resolve(&r), None);
948        assert_eq!(k("x.y").resolve(&r), None);
949        assert_eq!(k("/a.b/c").resolve(&r), Some(&json!(7)));
950        assert_eq!(k("x").resolve(&r), Some(&json!(5)));
951        let flat = json!({"Account.LastModifiedDate": "2024"});
952        assert_eq!(
953            k("Account.LastModifiedDate").resolve(&flat),
954            Some(&json!("2024"))
955        );
956        assert_eq!(
957            ReplicationKey::top_level("a.b").resolve(&r),
958            Some(&json!({"c": 7}))
959        );
960    }
961
962    #[test]
963    fn filter_incremental_path_nested_and_policies() {
964        let records = || {
965            vec![
966                json!({"id": 1, "fields": {"updated": "2024-01-01"}}),
967                json!({"id": 2, "fields": {"updated": "2024-12-01"}}),
968                json!({"id": 3, "fields": {}}),
969                json!({"id": 4, "fields": {"updated": 5}}),
970            ]
971        };
972        let key = ReplicationKey::parse("fields.updated").unwrap();
973        let start = json!("2024-06-01");
974        let keep = filter_incremental_path(records(), &key, &start, OnMissingKey::Keep).unwrap();
975        let ids: Vec<i64> = keep
976            .records
977            .iter()
978            .map(|r| r["id"].as_i64().unwrap())
979            .collect();
980        assert_eq!(ids, vec![2, 3, 4]);
981        assert_eq!(keep.missing, 1);
982        let drop = filter_incremental_path(records(), &key, &start, OnMissingKey::Drop).unwrap();
983        assert_eq!(drop.records.len(), 2);
984        assert_eq!(drop.missing, 1);
985        let err = filter_incremental_path(records(), &key, &start, OnMissingKey::Fail).unwrap_err();
986        assert!(err.to_string().contains("fields.updated"), "{err}");
987    }
988
989    #[test]
990    fn max_replication_value_path_skips_missing_and_null() {
991        let key = ReplicationKey::parse("fields.updated").unwrap();
992        let records = vec![
993            json!({"fields": {"updated": "2024-01-01"}}),
994            json!({"fields": {"updated": null}}),
995            json!({"fields": {"updated": "2024-12-01"}}),
996            json!({}),
997        ];
998        assert_eq!(
999            max_replication_value_path(&records, &key),
1000            Some(&json!("2024-12-01"))
1001        );
1002        assert!(max_replication_value_path(&records[1..2], &key).is_none());
1003    }
1004
1005    #[test]
1006    fn on_missing_key_serde() {
1007        assert_eq!(OnMissingKey::default(), OnMissingKey::Keep);
1008        let v: OnMissingKey = serde_json::from_value(json!("fail")).unwrap();
1009        assert_eq!(v, OnMissingKey::Fail);
1010    }
1011
1012    #[test]
1013    fn test_filter_incremental_equal_excluded() {
1014        let records = vec![
1015            json!({"id": 1, "updated_at": "2024-06-01"}),
1016            json!({"id": 2, "updated_at": "2024-06-02"}),
1017        ];
1018        let start = json!("2024-06-01");
1019        let filtered = filter_incremental(records, "updated_at", &start);
1020        assert_eq!(filtered.len(), 1);
1021        assert_eq!(filtered[0]["id"], 2);
1022    }
1023
1024    #[test]
1025    fn test_max_replication_value_strings() {
1026        let records = vec![
1027            json!({"updated_at": "2024-01-01"}),
1028            json!({"updated_at": "2024-12-01"}),
1029            json!({"updated_at": "2024-06-01"}),
1030        ];
1031        let max = max_replication_value(&records, "updated_at").unwrap();
1032        assert_eq!(max, &json!("2024-12-01"));
1033    }
1034
1035    #[test]
1036    fn test_max_replication_value_numbers() {
1037        let records = vec![json!({"seq": 5}), json!({"seq": 10}), json!({"seq": 3})];
1038        let max = max_replication_value(&records, "seq").unwrap();
1039        assert_eq!(max, &json!(10));
1040    }
1041
1042    #[test]
1043    fn test_max_replication_value_empty() {
1044        let records: Vec<Value> = vec![];
1045        assert!(max_replication_value(&records, "updated_at").is_none());
1046    }
1047
1048    #[test]
1049    fn test_max_value_picks_larger_string() {
1050        assert_eq!(
1051            max_value(json!("2024-01-01"), json!("2024-06-01")),
1052            json!("2024-06-01")
1053        );
1054    }
1055
1056    #[test]
1057    fn test_max_value_picks_larger_number() {
1058        assert_eq!(max_value(json!(5), json!(10)), json!(10));
1059    }
1060
1061    #[test]
1062    fn test_max_value_returns_a_on_type_mismatch() {
1063        // String outranks Number in the total type-rank ordering, so the
1064        // larger (a) is returned.
1065        assert_eq!(max_value(json!("string"), json!(5)), json!("string"));
1066    }
1067
1068    #[test]
1069    fn filter_incremental_keeps_large_integer_beyond_f64_precision() {
1070        // Regression for #78/#27: integer cursors above 2^53 lose precision
1071        // when compared as f64, so a genuinely-greater value compared Equal
1072        // and was silently dropped.
1073        let two_pow_53 = 9_007_199_254_740_992_i64; // 2^53
1074        let records = vec![
1075            json!({"id": 1, "seq": two_pow_53 + 1}),
1076            json!({"id": 2, "seq": two_pow_53 + 2}),
1077        ];
1078        let start = json!(two_pow_53);
1079        let filtered = filter_incremental(records, "seq", &start);
1080        assert_eq!(
1081            filtered.len(),
1082            2,
1083            "both values are strictly greater than 2^53"
1084        );
1085    }
1086
1087    #[test]
1088    fn json_compare_distinguishes_large_integers() {
1089        let a = json!(9_007_199_254_740_993_i64); // 2^53 + 1
1090        let b = json!(9_007_199_254_740_992_i64); // 2^53
1091        assert_eq!(json_compare(&a, &b), Ordering::Greater);
1092    }
1093
1094    #[test]
1095    fn filter_incremental_keeps_records_on_type_mismatch() {
1096        // Regression for #78/#27: a bookmark/key type mismatch must not be
1097        // silently treated as "not greater" and the record dropped — that is
1098        // data loss. Keep the record instead.
1099        let records = vec![json!({"id": 1, "seq": 20_240_701})];
1100        let start = json!("2024-06-01"); // string bookmark vs numeric key
1101        let filtered = filter_incremental(records, "seq", &start);
1102        assert_eq!(filtered.len(), 1, "type mismatch must not silently drop");
1103    }
1104
1105    fn cmp(a: Value, b: Value) -> Ordering {
1106        json_compare(&a, &b)
1107    }
1108
1109    #[test]
1110    fn decimal_strings_order_numerically_and_exactly() {
1111        use Ordering::*;
1112        let cases = [
1113            ("99", "100", Less),
1114            ("100", "99", Greater),
1115            ("989", "99", Greater),
1116            ("007", "7", Equal),
1117            ("1.50", "1.5", Equal),
1118            ("0.000", "-0", Equal),
1119            ("-1", "0", Less),
1120            ("-10", "-9", Less),
1121            ("-0.5", "-0.25", Less),
1122            ("1e3", "999", Greater),
1123            ("1E-2", "0.01", Equal),
1124            ("+5", "5", Equal),
1125            (".5", "0.4", Greater),
1126            (
1127                "12345678901234567890123456789012345678",
1128                "12345678901234567890123456789012345677",
1129                Greater,
1130            ),
1131            (
1132                "0.000000000000000000000000000000000002",
1133                "0.000000000000000000000000000000000001",
1134                Greater,
1135            ),
1136        ];
1137        for (a, b, want) in cases {
1138            assert_eq!(cmp(json!(a), json!(b)), want, "{a} vs {b}");
1139        }
1140    }
1141
1142    #[test]
1143    fn decimal_parse_rejects_what_is_not_a_number() {
1144        for text in [
1145            "",
1146            "-",
1147            ".",
1148            "1.2.3",
1149            "1e",
1150            "12a",
1151            "2024-06-01",
1152            "e5",
1153            "1e9999999999999999999",
1154        ] {
1155            assert!(Decimal::parse(text).is_none(), "{text:?}");
1156        }
1157    }
1158
1159    #[test]
1160    fn a_decimal_string_bookmark_keeps_larger_rows() {
1161        let records: Vec<Value> = ["100", "989", "50", "99"]
1162            .iter()
1163            .map(|v| json!({ "k": v }))
1164            .collect();
1165        let kept = filter_incremental(records, "k", &json!("99"));
1166        let ks: Vec<&str> = kept.iter().map(|r| r["k"].as_str().unwrap()).collect();
1167        assert_eq!(ks, ["100", "989"]);
1168    }
1169
1170    #[test]
1171    fn numbers_and_decimal_strings_compare_numerically() {
1172        assert_eq!(cmp(json!(100), json!("99")), Ordering::Greater);
1173        assert_eq!(
1174            cmp(json!("123456789012345678901234567890"), json!(5)),
1175            Ordering::Greater
1176        );
1177        assert_eq!(cmp(json!(2.5), json!("2.50")), Ordering::Equal);
1178        let records = vec![
1179            json!({"k": 7}),
1180            json!({"k": 123456789012345678901234567890_f64}),
1181        ];
1182        let kept = filter_incremental(records, "k", &json!("6.5"));
1183        assert_eq!(kept.len(), 2);
1184        assert_eq!(
1185            max_value(json!("12345678901234567890"), json!(12)),
1186            json!("12345678901234567890")
1187        );
1188        assert_eq!(max_value(json!("12"), json!(13)), json!(13));
1189    }
1190
1191    #[test]
1192    fn timestamps_order_as_instants_across_offsets_and_fraction_widths() {
1193        use Ordering::*;
1194        let cases = [
1195            ("2024-01-01T10:00:00+05:00", "2024-01-01T06:00:00Z", Less),
1196            ("2024-01-01T10:00:00.5Z", "2024-01-01T10:00:00.45Z", Greater),
1197            ("2024-01-01T10:00:00Z", "2024-01-01T10:00:00.000Z", Equal),
1198            ("2024-01-01 10:00:00+00:00", "2024-01-01T09:59:59Z", Greater),
1199            ("2024-01-01T10:00:00.5", "2024-01-01T10:00:00.45", Greater),
1200            ("2024-01-01 10:00:00", "2024-01-01T09:00:00", Greater),
1201        ];
1202        for (a, b, want) in cases {
1203            assert_eq!(cmp(json!(a), json!(b)), want, "{a} vs {b}");
1204        }
1205        let records = vec![
1206            json!({"t": "2024-01-01T08:00:00+01:00"}),
1207            json!({"t": "2024-01-01T08:00:00-01:00"}),
1208        ];
1209        let kept = filter_incremental(records, "t", &json!("2024-01-01T08:00:00Z"));
1210        assert_eq!(kept, vec![json!({"t": "2024-01-01T08:00:00-01:00"})]);
1211    }
1212
1213    #[test]
1214    fn mixed_or_unparseable_strings_stay_lexicographic() {
1215        assert_eq!(
1216            cmp(json!("2024-01-01T10:00:00Z"), json!("2024-01-01T10:00:00")),
1217            "2024-01-01T10:00:00Z".cmp("2024-01-01T10:00:00")
1218        );
1219        assert_eq!(cmp(json!("b"), json!("a")), Ordering::Greater);
1220        assert_eq!(
1221            cmp(json!("2024-13-99T99:99:99"), json!("2024")),
1222            Ordering::Greater
1223        );
1224        assert_eq!(cmp(json!("abc"), json!(5)), Ordering::Greater);
1225        assert_eq!(cmp(json!(true), json!("1")), Ordering::Less);
1226    }
1227
1228    // ── ReplicationBind (#513) ──────────────────────────────────────────────
1229
1230    fn bind(into: BindTarget, template: &str, format: BindFormat) -> ReplicationBind {
1231        ReplicationBind {
1232            into,
1233            name: "updated_after".to_owned(),
1234            template: template.to_owned(),
1235            format,
1236            advance_from: None,
1237            path: None,
1238            value_type: BindValueType::String,
1239        }
1240    }
1241
1242    #[test]
1243    fn bind_defaults_template_to_bare_placeholder() {
1244        let b: ReplicationBind =
1245            serde_json::from_value(json!({ "name": "since" })).expect("deserializes");
1246        assert_eq!(b.into, BindTarget::Query);
1247        assert_eq!(b.template, "${bookmark}");
1248        assert_eq!(b.format, BindFormat::Raw);
1249        assert!(b.advance_from.is_none());
1250    }
1251
1252    #[test]
1253    fn bind_render_raw_string_and_number() {
1254        let b = bind(BindTarget::Query, "${bookmark}", BindFormat::Raw);
1255        assert_eq!(b.render(&json!("2024-06-01")).unwrap(), "2024-06-01");
1256        assert_eq!(b.render(&json!(150)).unwrap(), "150");
1257    }
1258
1259    #[test]
1260    fn bind_render_applies_operator_template() {
1261        let b = bind(BindTarget::Query, "gte|${bookmark}", BindFormat::Raw);
1262        assert_eq!(
1263            b.render(&json!("2024-06-01T00:00:00Z")).unwrap(),
1264            "gte|2024-06-01T00:00:00Z"
1265        );
1266        // Lucene range form.
1267        let l = bind(BindTarget::Query, "[${bookmark} TO *]", BindFormat::Raw);
1268        assert_eq!(l.render(&json!("20240601")).unwrap(), "[20240601 TO *]");
1269    }
1270
1271    #[test]
1272    fn bind_format_iso8601_from_date_and_epoch() {
1273        let b = bind(BindTarget::Header, "${bookmark}", BindFormat::Iso8601);
1274        assert_eq!(
1275            b.render(&json!("2024-06-01")).unwrap(),
1276            "2024-06-01T00:00:00Z"
1277        );
1278        // Epoch seconds → ISO.
1279        assert_eq!(
1280            b.render(&json!(1_717_200_000)).unwrap(),
1281            "2024-06-01T00:00:00Z"
1282        );
1283    }
1284
1285    #[test]
1286    fn bind_format_epoch_s_and_ms_from_iso() {
1287        let s = bind(BindTarget::Query, "${bookmark}", BindFormat::EpochS);
1288        assert_eq!(
1289            s.render(&json!("2024-06-01T00:00:00Z")).unwrap(),
1290            "1717200000"
1291        );
1292        let ms = bind(BindTarget::Query, "${bookmark}", BindFormat::EpochMs);
1293        assert_eq!(
1294            ms.render(&json!("2024-06-01T00:00:00Z")).unwrap(),
1295            "1717200000000"
1296        );
1297    }
1298
1299    #[test]
1300    fn bind_format_date_truncates_datetime() {
1301        let b = bind(BindTarget::Query, "${bookmark}", BindFormat::Date);
1302        assert_eq!(
1303            b.render(&json!("2024-06-01T12:34:56Z")).unwrap(),
1304            "2024-06-01"
1305        );
1306    }
1307
1308    #[test]
1309    fn bind_format_naive_datetime_assumed_utc() {
1310        let b = bind(BindTarget::Query, "${bookmark}", BindFormat::Iso8601);
1311        assert_eq!(
1312            b.render(&json!("2024-06-01T08:00:00")).unwrap(),
1313            "2024-06-01T08:00:00Z"
1314        );
1315    }
1316
1317    #[test]
1318    fn bind_format_unparseable_string_errors() {
1319        let b = bind(BindTarget::Query, "${bookmark}", BindFormat::Iso8601);
1320        assert!(b.render(&json!("not-a-date")).is_err());
1321    }
1322
1323    #[test]
1324    fn bind_format_raw_rejects_composite() {
1325        let b = bind(BindTarget::Query, "${bookmark}", BindFormat::Raw);
1326        assert!(b.render(&json!({"a": 1})).is_err());
1327        assert!(b.render(&json!(null)).is_err());
1328    }
1329
1330    #[test]
1331    fn bind_validate_rejects_empty_name_and_missing_placeholder() {
1332        let mut b = bind(BindTarget::Query, "${bookmark}", BindFormat::Raw);
1333        b.name = "  ".to_owned();
1334        assert!(b.validate().is_err());
1335
1336        let mut b2 = bind(BindTarget::Query, "no placeholder here", BindFormat::Raw);
1337        b2.name = "since".to_owned();
1338        assert!(b2.validate().is_err());
1339
1340        let ok = bind(BindTarget::Query, "gte|${bookmark}", BindFormat::Raw);
1341        assert!(ok.validate().is_ok());
1342    }
1343
1344    #[test]
1345    fn bind_placement_validation() {
1346        let mut b = bind(BindTarget::Body, "${bookmark}", BindFormat::Raw);
1347        assert!(b.validate().is_ok());
1348        b.path = Some("/a/0/b".into());
1349        assert!(b.validate().unwrap_err().to_string().contains("not both"));
1350        b.name.clear();
1351        assert!(b.validate().is_ok());
1352        b.path = Some("a".into());
1353        assert!(b.validate().is_err());
1354        b.path = Some("/".into());
1355        assert!(b.validate().is_err());
1356        b.path = None;
1357        assert!(
1358            b.validate()
1359                .unwrap_err()
1360                .to_string()
1361                .contains("needs `name`")
1362        );
1363        let mut q = bind(BindTarget::Query, "${bookmark}", BindFormat::Raw);
1364        q.path = Some("/a".into());
1365        assert!(
1366            q.validate()
1367                .unwrap_err()
1368                .to_string()
1369                .contains("only to `into: body`")
1370        );
1371        let parsed: ReplicationBind = serde_json::from_value(json!({
1372            "into": "body", "path": "/f/0/v", "value_type": "number"
1373        }))
1374        .unwrap();
1375        assert!(parsed.validate().is_ok());
1376        assert_eq!(parsed.value_type, BindValueType::Number);
1377    }
1378
1379    #[test]
1380    fn value_type_conversion() {
1381        assert_eq!(BindValueType::String.to_value("5").unwrap(), json!("5"));
1382        assert_eq!(BindValueType::Number.to_value("5").unwrap(), json!(5));
1383        assert_eq!(
1384            BindValueType::Number
1385                .to_value("18446744073709551615")
1386                .unwrap(),
1387            json!(18_446_744_073_709_551_615_u64)
1388        );
1389        assert_eq!(BindValueType::Number.to_value("1.5").unwrap(), json!(1.5));
1390        assert!(BindValueType::Number.to_value("x").is_err());
1391    }
1392
1393    #[test]
1394    fn set_body_pointer_rules() {
1395        let mut body = json!({"filterGroups": [{"filters": [{"value": null}]}], "v": {}, "a/b": 1});
1396        set_body_pointer(&mut body, "/filterGroups/0/filters/0/value", json!("x")).unwrap();
1397        assert_eq!(body["filterGroups"][0]["filters"][0]["value"], json!("x"));
1398        set_body_pointer(&mut body, "/v/after", json!("c")).unwrap();
1399        assert_eq!(body["v"]["after"], json!("c"));
1400        set_body_pointer(&mut body, "/top", json!(1)).unwrap();
1401        assert_eq!(body["top"], json!(1));
1402        set_body_pointer(&mut body, "/a~1b", json!(2)).unwrap();
1403        assert_eq!(body["a/b"], json!(2));
1404        set_body_pointer(&mut body, "/v/x~1y~0z", json!(3)).unwrap();
1405        assert_eq!(body["v"]["x/y~z"], json!(3));
1406        assert!(set_body_pointer(&mut body, "/filterGroups/1/filters", json!(1)).is_err());
1407        assert!(set_body_pointer(&mut body, "/missing/leaf", json!(1)).is_err());
1408        assert!(set_body_pointer(&mut body, "/v", json!(1)).is_err());
1409        assert!(set_body_pointer(&mut body, "/filterGroups/0/filters/5", json!(1)).is_err());
1410        assert!(set_body_pointer(&mut body, "nope", json!(1)).is_err());
1411    }
1412
1413    #[test]
1414    fn bind_format_bookmark_bool_raw() {
1415        assert_eq!(
1416            format_bookmark(&json!(true), BindFormat::Raw).unwrap(),
1417            "true"
1418        );
1419    }
1420
1421    #[test]
1422    fn bind_format_non_scalar_bookmark_errors() {
1423        // A composite / null bookmark cannot be parsed into an instant.
1424        assert!(format_bookmark(&json!({"a": 1}), BindFormat::Iso8601).is_err());
1425        assert!(format_bookmark(&json!(null), BindFormat::EpochS).is_err());
1426    }
1427
1428    #[test]
1429    fn numeric_bookmarks_keep_the_epoch_binds_unit() {
1430        assert_eq!(
1431            format_bookmark(&json!(1_717_200_000_000_i64), BindFormat::EpochMs).unwrap(),
1432            "1717200000000"
1433        );
1434        assert_eq!(
1435            format_bookmark(&json!(1_717_200_000), BindFormat::EpochS).unwrap(),
1436            "1717200000"
1437        );
1438        assert_eq!(
1439            format_bookmark(&json!(u64::MAX), BindFormat::EpochMs).unwrap(),
1440            u64::MAX.to_string()
1441        );
1442        assert_eq!(
1443            format_bookmark(&json!(1_717_200_000.9), BindFormat::EpochS).unwrap(),
1444            "1717200000"
1445        );
1446    }
1447
1448    #[test]
1449    fn bind_format_out_of_range_epoch_errors() {
1450        // i64::MAX seconds is far outside chrono's representable range.
1451        assert!(format_bookmark(&json!(i64::MAX), BindFormat::Iso8601).is_err());
1452    }
1453}