Skip to main content

faucet_core/
replication.rs

1//! Incremental replication support.
2
3use crate::error::FaucetError;
4use chrono::{DateTime, NaiveDate, 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            // The same spellings the bookmark ordering accepts (a space or `T`,
758            // optional fractional seconds), so a value stored as the bookmark
759            // always renders back; a naive instant is read as UTC.
760            match Instant::parse(s) {
761                Some(Instant::Zoned(dt)) => return Ok(dt),
762                Some(Instant::Naive(ndt)) => {
763                    return Ok(DateTime::<Utc>::from_naive_utc_and_offset(ndt, Utc));
764                }
765                None => {}
766            }
767            if let Ok(d) = NaiveDate::parse_from_str(s, "%Y-%m-%d")
768                && let Some(ndt) = d.and_hms_opt(0, 0, 0)
769            {
770                return Ok(DateTime::<Utc>::from_naive_utc_and_offset(ndt, Utc));
771            }
772            Err(FaucetError::Config(format!(
773                "replication bind: cannot parse bookmark '{s}' as a timestamp \
774                 (expected RFC 3339, YYYY-MM-DD, or YYYY-MM-DDTHH:MM:SS)"
775            )))
776        }
777        Value::Number(n) => {
778            let secs = n.as_i64().or_else(|| n.as_f64().map(|f| f as i64));
779            secs.and_then(|s| DateTime::<Utc>::from_timestamp(s, 0))
780                .ok_or_else(|| {
781                    FaucetError::Config(format!(
782                        "replication bind: numeric bookmark {n} is out of range for epoch seconds"
783                    ))
784                })
785        }
786        other => Err(FaucetError::Config(format!(
787            "replication bind: bookmark must be a string or number, got {other}"
788        ))),
789    }
790}
791
792/// Parse a bookmark value into a UTC instant (public wrapper over the internal
793/// parser; see [`BindFormat`] for the accepted forms). Used by datetime window
794/// slicing (#527) to resolve the sweep's start bound from the stored bookmark.
795pub fn parse_instant(value: &Value) -> Result<DateTime<Utc>, FaucetError> {
796    bookmark_instant(value)
797}
798
799/// Format an already-resolved UTC instant per [`BindFormat`]. Unlike
800/// [`format_bookmark`] (which takes an arbitrary scalar and, for [`BindFormat::Raw`],
801/// echoes it verbatim), this always has a real instant, so `Raw` and `Iso8601`
802/// both emit an RFC 3339 UTC timestamp. Used to render window boundaries (#527).
803pub fn format_instant(dt: DateTime<Utc>, format: BindFormat) -> String {
804    match format {
805        BindFormat::Raw | BindFormat::Iso8601 => {
806            dt.to_rfc3339_opts(chrono::SecondsFormat::Secs, true)
807        }
808        BindFormat::Date => dt.format("%Y-%m-%d").to_string(),
809        BindFormat::EpochS => dt.timestamp().to_string(),
810        BindFormat::EpochMs => dt.timestamp_millis().to_string(),
811    }
812}
813
814/// Format a bookmark value per [`BindFormat`].
815pub fn format_bookmark(value: &Value, format: BindFormat) -> Result<String, FaucetError> {
816    match format {
817        BindFormat::Raw => match value {
818            Value::String(s) => Ok(s.clone()),
819            Value::Number(n) => Ok(n.to_string()),
820            Value::Bool(b) => Ok(b.to_string()),
821            other => Err(FaucetError::Config(format!(
822                "replication bind: cannot render {other} as a raw scalar"
823            ))),
824        },
825        BindFormat::Iso8601 => {
826            Ok(bookmark_instant(value)?.to_rfc3339_opts(chrono::SecondsFormat::Secs, true))
827        }
828        BindFormat::Date => Ok(bookmark_instant(value)?.format("%Y-%m-%d").to_string()),
829        BindFormat::EpochS | BindFormat::EpochMs if value.is_number() => Ok(epoch_number(value)),
830        BindFormat::EpochS => Ok(bookmark_instant(value)?.timestamp().to_string()),
831        BindFormat::EpochMs => Ok(bookmark_instant(value)?.timestamp_millis().to_string()),
832    }
833}
834
835/// A numeric bookmark under an epoch bind is already in the bind's unit (an
836/// `epoch_ms` cursor stores milliseconds), so it is emitted as the integer it
837/// holds rather than re-read as seconds and scaled.
838fn epoch_number(value: &Value) -> String {
839    match value.as_i64() {
840        Some(i) => i.to_string(),
841        None => match value.as_u64() {
842            Some(u) => u.to_string(),
843            None => (value.as_f64().unwrap_or(0.0).trunc() as i64).to_string(),
844        },
845    }
846}
847
848#[cfg(test)]
849mod inclusive_tests {
850    use super::*;
851    use serde_json::json;
852
853    #[test]
854    fn the_inclusive_filter_keeps_rows_at_the_bookmark() {
855        let key = ReplicationKey::top_level("ts");
856        let rows = vec![
857            json!({"ts": 4}),
858            json!({"ts": 5}),
859            json!({"ts": 6}),
860            json!({"ts": "x"}),
861            json!({"id": 1}),
862        ];
863        let kept =
864            filter_incremental_path_inclusive(rows.clone(), &key, &json!(5), OnMissingKey::Keep)
865                .unwrap();
866        assert_eq!(
867            kept.records,
868            vec![
869                json!({"ts": 5}),
870                json!({"ts": 6}),
871                json!({"ts": "x"}),
872                json!({"id": 1})
873            ]
874        );
875        assert_eq!(kept.missing, 1);
876        let strict = filter_incremental_path(rows, &key, &json!(5), OnMissingKey::Drop).unwrap();
877        assert_eq!(strict.records, vec![json!({"ts": 6}), json!({"ts": "x"})]);
878    }
879}
880
881#[cfg(test)]
882mod tests {
883    use super::*;
884    use serde_json::json;
885
886    #[test]
887    fn test_filter_incremental_strings() {
888        let records = vec![
889            json!({"id": 1, "updated_at": "2024-01-01"}),
890            json!({"id": 2, "updated_at": "2024-06-01"}),
891            json!({"id": 3, "updated_at": "2024-12-01"}),
892        ];
893        let start = json!("2024-06-01");
894        let filtered = filter_incremental(records, "updated_at", &start);
895        assert_eq!(filtered.len(), 1);
896        assert_eq!(filtered[0]["id"], 3);
897    }
898
899    #[test]
900    fn test_filter_incremental_numbers() {
901        let records = vec![
902            json!({"id": 1, "seq": 100}),
903            json!({"id": 2, "seq": 200}),
904            json!({"id": 3, "seq": 300}),
905        ];
906        let start = json!(150);
907        let filtered = filter_incremental(records, "seq", &start);
908        assert_eq!(filtered.len(), 2);
909        assert_eq!(filtered[0]["id"], 2);
910        assert_eq!(filtered[1]["id"], 3);
911    }
912
913    #[test]
914    fn test_filter_incremental_missing_key_kept() {
915        // #747: a record without the key used to be dropped silently.
916        let records = vec![
917            json!({"id": 1}),
918            json!({"id": 2, "updated_at": "2024-12-01"}),
919            json!({"id": 3, "updated_at": null}),
920        ];
921        let start = json!("2024-01-01");
922        let filtered = filter_incremental(records, "updated_at", &start);
923        assert_eq!(filtered.len(), 3);
924    }
925
926    #[test]
927    fn replication_key_parse_forms() {
928        assert!(!ReplicationKey::parse("updated").unwrap().is_nested());
929        let dot = ReplicationKey::parse("fields.updated").unwrap();
930        assert!(dot.is_nested() && !dot.is_pointer());
931        assert_eq!(dot.as_str(), "fields.updated");
932        let ptr = ReplicationKey::parse("/a.b/c").unwrap();
933        assert!(ptr.is_pointer() && ptr.is_nested());
934        assert!(ReplicationKey::parse(" ").is_err());
935        assert!(ReplicationKey::parse("a..b").is_err());
936        assert!(ReplicationKey::parse(".a").is_err());
937    }
938
939    #[test]
940    fn replication_key_resolves_nested_array_and_pointer() {
941        let r = json!({
942            "fields": {"updated": "2024-06-01"},
943            "items": [{"date": 1}, {"date": 2}],
944            "a.b": {"c": 7},
945            "x": 5
946        });
947        let k = |s: &str| ReplicationKey::parse(s).unwrap();
948        assert_eq!(k("fields.updated").resolve(&r), Some(&json!("2024-06-01")));
949        assert_eq!(k("items.1.date").resolve(&r), Some(&json!(2)));
950        assert_eq!(k("items.x.date").resolve(&r), None);
951        assert_eq!(k("items.9.date").resolve(&r), None);
952        assert_eq!(k("x.y").resolve(&r), None);
953        assert_eq!(k("/a.b/c").resolve(&r), Some(&json!(7)));
954        assert_eq!(k("x").resolve(&r), Some(&json!(5)));
955        let flat = json!({"Account.LastModifiedDate": "2024"});
956        assert_eq!(
957            k("Account.LastModifiedDate").resolve(&flat),
958            Some(&json!("2024"))
959        );
960        assert_eq!(
961            ReplicationKey::top_level("a.b").resolve(&r),
962            Some(&json!({"c": 7}))
963        );
964    }
965
966    #[test]
967    fn filter_incremental_path_nested_and_policies() {
968        let records = || {
969            vec![
970                json!({"id": 1, "fields": {"updated": "2024-01-01"}}),
971                json!({"id": 2, "fields": {"updated": "2024-12-01"}}),
972                json!({"id": 3, "fields": {}}),
973                json!({"id": 4, "fields": {"updated": 5}}),
974            ]
975        };
976        let key = ReplicationKey::parse("fields.updated").unwrap();
977        let start = json!("2024-06-01");
978        let keep = filter_incremental_path(records(), &key, &start, OnMissingKey::Keep).unwrap();
979        let ids: Vec<i64> = keep
980            .records
981            .iter()
982            .map(|r| r["id"].as_i64().unwrap())
983            .collect();
984        assert_eq!(ids, vec![2, 3, 4]);
985        assert_eq!(keep.missing, 1);
986        let drop = filter_incremental_path(records(), &key, &start, OnMissingKey::Drop).unwrap();
987        assert_eq!(drop.records.len(), 2);
988        assert_eq!(drop.missing, 1);
989        let err = filter_incremental_path(records(), &key, &start, OnMissingKey::Fail).unwrap_err();
990        assert!(err.to_string().contains("fields.updated"), "{err}");
991    }
992
993    #[test]
994    fn max_replication_value_path_skips_missing_and_null() {
995        let key = ReplicationKey::parse("fields.updated").unwrap();
996        let records = vec![
997            json!({"fields": {"updated": "2024-01-01"}}),
998            json!({"fields": {"updated": null}}),
999            json!({"fields": {"updated": "2024-12-01"}}),
1000            json!({}),
1001        ];
1002        assert_eq!(
1003            max_replication_value_path(&records, &key),
1004            Some(&json!("2024-12-01"))
1005        );
1006        assert!(max_replication_value_path(&records[1..2], &key).is_none());
1007    }
1008
1009    #[test]
1010    fn on_missing_key_serde() {
1011        assert_eq!(OnMissingKey::default(), OnMissingKey::Keep);
1012        let v: OnMissingKey = serde_json::from_value(json!("fail")).unwrap();
1013        assert_eq!(v, OnMissingKey::Fail);
1014    }
1015
1016    #[test]
1017    fn test_filter_incremental_equal_excluded() {
1018        let records = vec![
1019            json!({"id": 1, "updated_at": "2024-06-01"}),
1020            json!({"id": 2, "updated_at": "2024-06-02"}),
1021        ];
1022        let start = json!("2024-06-01");
1023        let filtered = filter_incremental(records, "updated_at", &start);
1024        assert_eq!(filtered.len(), 1);
1025        assert_eq!(filtered[0]["id"], 2);
1026    }
1027
1028    #[test]
1029    fn test_max_replication_value_strings() {
1030        let records = vec![
1031            json!({"updated_at": "2024-01-01"}),
1032            json!({"updated_at": "2024-12-01"}),
1033            json!({"updated_at": "2024-06-01"}),
1034        ];
1035        let max = max_replication_value(&records, "updated_at").unwrap();
1036        assert_eq!(max, &json!("2024-12-01"));
1037    }
1038
1039    #[test]
1040    fn test_max_replication_value_numbers() {
1041        let records = vec![json!({"seq": 5}), json!({"seq": 10}), json!({"seq": 3})];
1042        let max = max_replication_value(&records, "seq").unwrap();
1043        assert_eq!(max, &json!(10));
1044    }
1045
1046    #[test]
1047    fn test_max_replication_value_empty() {
1048        let records: Vec<Value> = vec![];
1049        assert!(max_replication_value(&records, "updated_at").is_none());
1050    }
1051
1052    #[test]
1053    fn test_max_value_picks_larger_string() {
1054        assert_eq!(
1055            max_value(json!("2024-01-01"), json!("2024-06-01")),
1056            json!("2024-06-01")
1057        );
1058    }
1059
1060    #[test]
1061    fn test_max_value_picks_larger_number() {
1062        assert_eq!(max_value(json!(5), json!(10)), json!(10));
1063    }
1064
1065    #[test]
1066    fn test_max_value_returns_a_on_type_mismatch() {
1067        // String outranks Number in the total type-rank ordering, so the
1068        // larger (a) is returned.
1069        assert_eq!(max_value(json!("string"), json!(5)), json!("string"));
1070    }
1071
1072    #[test]
1073    fn filter_incremental_keeps_large_integer_beyond_f64_precision() {
1074        // Regression for #78/#27: integer cursors above 2^53 lose precision
1075        // when compared as f64, so a genuinely-greater value compared Equal
1076        // and was silently dropped.
1077        let two_pow_53 = 9_007_199_254_740_992_i64; // 2^53
1078        let records = vec![
1079            json!({"id": 1, "seq": two_pow_53 + 1}),
1080            json!({"id": 2, "seq": two_pow_53 + 2}),
1081        ];
1082        let start = json!(two_pow_53);
1083        let filtered = filter_incremental(records, "seq", &start);
1084        assert_eq!(
1085            filtered.len(),
1086            2,
1087            "both values are strictly greater than 2^53"
1088        );
1089    }
1090
1091    #[test]
1092    fn json_compare_distinguishes_large_integers() {
1093        let a = json!(9_007_199_254_740_993_i64); // 2^53 + 1
1094        let b = json!(9_007_199_254_740_992_i64); // 2^53
1095        assert_eq!(json_compare(&a, &b), Ordering::Greater);
1096    }
1097
1098    #[test]
1099    fn filter_incremental_keeps_records_on_type_mismatch() {
1100        // Regression for #78/#27: a bookmark/key type mismatch must not be
1101        // silently treated as "not greater" and the record dropped — that is
1102        // data loss. Keep the record instead.
1103        let records = vec![json!({"id": 1, "seq": 20_240_701})];
1104        let start = json!("2024-06-01"); // string bookmark vs numeric key
1105        let filtered = filter_incremental(records, "seq", &start);
1106        assert_eq!(filtered.len(), 1, "type mismatch must not silently drop");
1107    }
1108
1109    fn cmp(a: Value, b: Value) -> Ordering {
1110        json_compare(&a, &b)
1111    }
1112
1113    #[test]
1114    fn decimal_strings_order_numerically_and_exactly() {
1115        use Ordering::*;
1116        let cases = [
1117            ("99", "100", Less),
1118            ("100", "99", Greater),
1119            ("989", "99", Greater),
1120            ("007", "7", Equal),
1121            ("1.50", "1.5", Equal),
1122            ("0.000", "-0", Equal),
1123            ("-1", "0", Less),
1124            ("-10", "-9", Less),
1125            ("-0.5", "-0.25", Less),
1126            ("1e3", "999", Greater),
1127            ("1E-2", "0.01", Equal),
1128            ("+5", "5", Equal),
1129            (".5", "0.4", Greater),
1130            (
1131                "12345678901234567890123456789012345678",
1132                "12345678901234567890123456789012345677",
1133                Greater,
1134            ),
1135            (
1136                "0.000000000000000000000000000000000002",
1137                "0.000000000000000000000000000000000001",
1138                Greater,
1139            ),
1140        ];
1141        for (a, b, want) in cases {
1142            assert_eq!(cmp(json!(a), json!(b)), want, "{a} vs {b}");
1143        }
1144    }
1145
1146    #[test]
1147    fn decimal_parse_rejects_what_is_not_a_number() {
1148        for text in [
1149            "",
1150            "-",
1151            ".",
1152            "1.2.3",
1153            "1e",
1154            "12a",
1155            "2024-06-01",
1156            "e5",
1157            "1e9999999999999999999",
1158        ] {
1159            assert!(Decimal::parse(text).is_none(), "{text:?}");
1160        }
1161    }
1162
1163    #[test]
1164    fn a_decimal_string_bookmark_keeps_larger_rows() {
1165        let records: Vec<Value> = ["100", "989", "50", "99"]
1166            .iter()
1167            .map(|v| json!({ "k": v }))
1168            .collect();
1169        let kept = filter_incremental(records, "k", &json!("99"));
1170        let ks: Vec<&str> = kept.iter().map(|r| r["k"].as_str().unwrap()).collect();
1171        assert_eq!(ks, ["100", "989"]);
1172    }
1173
1174    #[test]
1175    fn numbers_and_decimal_strings_compare_numerically() {
1176        assert_eq!(cmp(json!(100), json!("99")), Ordering::Greater);
1177        assert_eq!(
1178            cmp(json!("123456789012345678901234567890"), json!(5)),
1179            Ordering::Greater
1180        );
1181        assert_eq!(cmp(json!(2.5), json!("2.50")), Ordering::Equal);
1182        let records = vec![
1183            json!({"k": 7}),
1184            json!({"k": 123456789012345678901234567890_f64}),
1185        ];
1186        let kept = filter_incremental(records, "k", &json!("6.5"));
1187        assert_eq!(kept.len(), 2);
1188        assert_eq!(
1189            max_value(json!("12345678901234567890"), json!(12)),
1190            json!("12345678901234567890")
1191        );
1192        assert_eq!(max_value(json!("12"), json!(13)), json!(13));
1193    }
1194
1195    #[test]
1196    fn timestamps_order_as_instants_across_offsets_and_fraction_widths() {
1197        use Ordering::*;
1198        let cases = [
1199            ("2024-01-01T10:00:00+05:00", "2024-01-01T06:00:00Z", Less),
1200            ("2024-01-01T10:00:00.5Z", "2024-01-01T10:00:00.45Z", Greater),
1201            ("2024-01-01T10:00:00Z", "2024-01-01T10:00:00.000Z", Equal),
1202            ("2024-01-01 10:00:00+00:00", "2024-01-01T09:59:59Z", Greater),
1203            ("2024-01-01T10:00:00.5", "2024-01-01T10:00:00.45", Greater),
1204            ("2024-01-01 10:00:00", "2024-01-01T09:00:00", Greater),
1205        ];
1206        for (a, b, want) in cases {
1207            assert_eq!(cmp(json!(a), json!(b)), want, "{a} vs {b}");
1208        }
1209        let records = vec![
1210            json!({"t": "2024-01-01T08:00:00+01:00"}),
1211            json!({"t": "2024-01-01T08:00:00-01:00"}),
1212        ];
1213        let kept = filter_incremental(records, "t", &json!("2024-01-01T08:00:00Z"));
1214        assert_eq!(kept, vec![json!({"t": "2024-01-01T08:00:00-01:00"})]);
1215    }
1216
1217    #[test]
1218    fn mixed_or_unparseable_strings_stay_lexicographic() {
1219        assert_eq!(
1220            cmp(json!("2024-01-01T10:00:00Z"), json!("2024-01-01T10:00:00")),
1221            "2024-01-01T10:00:00Z".cmp("2024-01-01T10:00:00")
1222        );
1223        assert_eq!(cmp(json!("b"), json!("a")), Ordering::Greater);
1224        assert_eq!(
1225            cmp(json!("2024-13-99T99:99:99"), json!("2024")),
1226            Ordering::Greater
1227        );
1228        assert_eq!(cmp(json!("abc"), json!(5)), Ordering::Greater);
1229        assert_eq!(cmp(json!(true), json!("1")), Ordering::Less);
1230    }
1231
1232    // ── ReplicationBind (#513) ──────────────────────────────────────────────
1233
1234    fn bind(into: BindTarget, template: &str, format: BindFormat) -> ReplicationBind {
1235        ReplicationBind {
1236            into,
1237            name: "updated_after".to_owned(),
1238            template: template.to_owned(),
1239            format,
1240            advance_from: None,
1241            path: None,
1242            value_type: BindValueType::String,
1243        }
1244    }
1245
1246    #[test]
1247    fn bind_defaults_template_to_bare_placeholder() {
1248        let b: ReplicationBind =
1249            serde_json::from_value(json!({ "name": "since" })).expect("deserializes");
1250        assert_eq!(b.into, BindTarget::Query);
1251        assert_eq!(b.template, "${bookmark}");
1252        assert_eq!(b.format, BindFormat::Raw);
1253        assert!(b.advance_from.is_none());
1254    }
1255
1256    #[test]
1257    fn bind_render_raw_string_and_number() {
1258        let b = bind(BindTarget::Query, "${bookmark}", BindFormat::Raw);
1259        assert_eq!(b.render(&json!("2024-06-01")).unwrap(), "2024-06-01");
1260        assert_eq!(b.render(&json!(150)).unwrap(), "150");
1261    }
1262
1263    #[test]
1264    fn bind_render_applies_operator_template() {
1265        let b = bind(BindTarget::Query, "gte|${bookmark}", BindFormat::Raw);
1266        assert_eq!(
1267            b.render(&json!("2024-06-01T00:00:00Z")).unwrap(),
1268            "gte|2024-06-01T00:00:00Z"
1269        );
1270        // Lucene range form.
1271        let l = bind(BindTarget::Query, "[${bookmark} TO *]", BindFormat::Raw);
1272        assert_eq!(l.render(&json!("20240601")).unwrap(), "[20240601 TO *]");
1273    }
1274
1275    #[test]
1276    fn bind_format_iso8601_from_date_and_epoch() {
1277        let b = bind(BindTarget::Header, "${bookmark}", BindFormat::Iso8601);
1278        assert_eq!(
1279            b.render(&json!("2024-06-01")).unwrap(),
1280            "2024-06-01T00:00:00Z"
1281        );
1282        // Epoch seconds → ISO.
1283        assert_eq!(
1284            b.render(&json!(1_717_200_000)).unwrap(),
1285            "2024-06-01T00:00:00Z"
1286        );
1287    }
1288
1289    #[test]
1290    fn bind_format_epoch_s_and_ms_from_iso() {
1291        let s = bind(BindTarget::Query, "${bookmark}", BindFormat::EpochS);
1292        assert_eq!(
1293            s.render(&json!("2024-06-01T00:00:00Z")).unwrap(),
1294            "1717200000"
1295        );
1296        let ms = bind(BindTarget::Query, "${bookmark}", BindFormat::EpochMs);
1297        assert_eq!(
1298            ms.render(&json!("2024-06-01T00:00:00Z")).unwrap(),
1299            "1717200000000"
1300        );
1301    }
1302
1303    #[test]
1304    fn bind_format_date_truncates_datetime() {
1305        let b = bind(BindTarget::Query, "${bookmark}", BindFormat::Date);
1306        assert_eq!(
1307            b.render(&json!("2024-06-01T12:34:56Z")).unwrap(),
1308            "2024-06-01"
1309        );
1310    }
1311
1312    #[test]
1313    fn bind_format_accepts_every_spelling_the_bookmark_ordering_stores() {
1314        let ms = bind(BindTarget::Query, "${bookmark}", BindFormat::EpochMs);
1315        assert_eq!(
1316            ms.render(&json!("2024-06-01T12:00:00.123")).unwrap(),
1317            "1717243200123"
1318        );
1319        assert_eq!(
1320            ms.render(&json!("2024-06-01 12:00:00")).unwrap(),
1321            "1717243200000"
1322        );
1323        assert_eq!(
1324            ms.render(&json!("2024-06-01 12:00:00.5+02:00")).unwrap(),
1325            "1717236000500"
1326        );
1327        assert!(ms.render(&json!("not a time")).is_err());
1328    }
1329
1330    #[test]
1331    fn bind_format_naive_datetime_assumed_utc() {
1332        let b = bind(BindTarget::Query, "${bookmark}", BindFormat::Iso8601);
1333        assert_eq!(
1334            b.render(&json!("2024-06-01T08:00:00")).unwrap(),
1335            "2024-06-01T08:00:00Z"
1336        );
1337    }
1338
1339    #[test]
1340    fn bind_format_unparseable_string_errors() {
1341        let b = bind(BindTarget::Query, "${bookmark}", BindFormat::Iso8601);
1342        assert!(b.render(&json!("not-a-date")).is_err());
1343    }
1344
1345    #[test]
1346    fn bind_format_raw_rejects_composite() {
1347        let b = bind(BindTarget::Query, "${bookmark}", BindFormat::Raw);
1348        assert!(b.render(&json!({"a": 1})).is_err());
1349        assert!(b.render(&json!(null)).is_err());
1350    }
1351
1352    #[test]
1353    fn bind_validate_rejects_empty_name_and_missing_placeholder() {
1354        let mut b = bind(BindTarget::Query, "${bookmark}", BindFormat::Raw);
1355        b.name = "  ".to_owned();
1356        assert!(b.validate().is_err());
1357
1358        let mut b2 = bind(BindTarget::Query, "no placeholder here", BindFormat::Raw);
1359        b2.name = "since".to_owned();
1360        assert!(b2.validate().is_err());
1361
1362        let ok = bind(BindTarget::Query, "gte|${bookmark}", BindFormat::Raw);
1363        assert!(ok.validate().is_ok());
1364    }
1365
1366    #[test]
1367    fn bind_placement_validation() {
1368        let mut b = bind(BindTarget::Body, "${bookmark}", BindFormat::Raw);
1369        assert!(b.validate().is_ok());
1370        b.path = Some("/a/0/b".into());
1371        assert!(b.validate().unwrap_err().to_string().contains("not both"));
1372        b.name.clear();
1373        assert!(b.validate().is_ok());
1374        b.path = Some("a".into());
1375        assert!(b.validate().is_err());
1376        b.path = Some("/".into());
1377        assert!(b.validate().is_err());
1378        b.path = None;
1379        assert!(
1380            b.validate()
1381                .unwrap_err()
1382                .to_string()
1383                .contains("needs `name`")
1384        );
1385        let mut q = bind(BindTarget::Query, "${bookmark}", BindFormat::Raw);
1386        q.path = Some("/a".into());
1387        assert!(
1388            q.validate()
1389                .unwrap_err()
1390                .to_string()
1391                .contains("only to `into: body`")
1392        );
1393        let parsed: ReplicationBind = serde_json::from_value(json!({
1394            "into": "body", "path": "/f/0/v", "value_type": "number"
1395        }))
1396        .unwrap();
1397        assert!(parsed.validate().is_ok());
1398        assert_eq!(parsed.value_type, BindValueType::Number);
1399    }
1400
1401    #[test]
1402    fn value_type_conversion() {
1403        assert_eq!(BindValueType::String.to_value("5").unwrap(), json!("5"));
1404        assert_eq!(BindValueType::Number.to_value("5").unwrap(), json!(5));
1405        assert_eq!(
1406            BindValueType::Number
1407                .to_value("18446744073709551615")
1408                .unwrap(),
1409            json!(18_446_744_073_709_551_615_u64)
1410        );
1411        assert_eq!(BindValueType::Number.to_value("1.5").unwrap(), json!(1.5));
1412        assert!(BindValueType::Number.to_value("x").is_err());
1413    }
1414
1415    #[test]
1416    fn set_body_pointer_rules() {
1417        let mut body = json!({"filterGroups": [{"filters": [{"value": null}]}], "v": {}, "a/b": 1});
1418        set_body_pointer(&mut body, "/filterGroups/0/filters/0/value", json!("x")).unwrap();
1419        assert_eq!(body["filterGroups"][0]["filters"][0]["value"], json!("x"));
1420        set_body_pointer(&mut body, "/v/after", json!("c")).unwrap();
1421        assert_eq!(body["v"]["after"], json!("c"));
1422        set_body_pointer(&mut body, "/top", json!(1)).unwrap();
1423        assert_eq!(body["top"], json!(1));
1424        set_body_pointer(&mut body, "/a~1b", json!(2)).unwrap();
1425        assert_eq!(body["a/b"], json!(2));
1426        set_body_pointer(&mut body, "/v/x~1y~0z", json!(3)).unwrap();
1427        assert_eq!(body["v"]["x/y~z"], json!(3));
1428        assert!(set_body_pointer(&mut body, "/filterGroups/1/filters", json!(1)).is_err());
1429        assert!(set_body_pointer(&mut body, "/missing/leaf", json!(1)).is_err());
1430        assert!(set_body_pointer(&mut body, "/v", json!(1)).is_err());
1431        assert!(set_body_pointer(&mut body, "/filterGroups/0/filters/5", json!(1)).is_err());
1432        assert!(set_body_pointer(&mut body, "nope", json!(1)).is_err());
1433    }
1434
1435    #[test]
1436    fn bind_format_bookmark_bool_raw() {
1437        assert_eq!(
1438            format_bookmark(&json!(true), BindFormat::Raw).unwrap(),
1439            "true"
1440        );
1441    }
1442
1443    #[test]
1444    fn bind_format_non_scalar_bookmark_errors() {
1445        // A composite / null bookmark cannot be parsed into an instant.
1446        assert!(format_bookmark(&json!({"a": 1}), BindFormat::Iso8601).is_err());
1447        assert!(format_bookmark(&json!(null), BindFormat::EpochS).is_err());
1448    }
1449
1450    #[test]
1451    fn numeric_bookmarks_keep_the_epoch_binds_unit() {
1452        assert_eq!(
1453            format_bookmark(&json!(1_717_200_000_000_i64), BindFormat::EpochMs).unwrap(),
1454            "1717200000000"
1455        );
1456        assert_eq!(
1457            format_bookmark(&json!(1_717_200_000), BindFormat::EpochS).unwrap(),
1458            "1717200000"
1459        );
1460        assert_eq!(
1461            format_bookmark(&json!(u64::MAX), BindFormat::EpochMs).unwrap(),
1462            u64::MAX.to_string()
1463        );
1464        assert_eq!(
1465            format_bookmark(&json!(1_717_200_000.9), BindFormat::EpochS).unwrap(),
1466            "1717200000"
1467        );
1468    }
1469
1470    #[test]
1471    fn bind_format_out_of_range_epoch_errors() {
1472        // i64::MAX seconds is far outside chrono's representable range.
1473        assert!(format_bookmark(&json!(i64::MAX), BindFormat::Iso8601).is_err());
1474    }
1475}