1use 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
10#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, JsonSchema)]
12#[serde(tag = "type")]
13pub enum ReplicationMethod {
14 #[default]
16 FullTable,
17 Incremental,
20}
21
22pub fn filter_incremental(records: Vec<Value>, key: &str, start: &Value) -> Vec<Value> {
33 records
34 .into_iter()
35 .filter(|r| match r.get(key) {
36 None => false,
37 Some(v) if type_rank(v) != type_rank(start) => {
38 tracing::warn!(
39 key,
40 "incremental replication: record key type does not match the bookmark \
41 type; keeping the record to avoid silently dropping data"
42 );
43 true
44 }
45 Some(v) => json_gt(v, start),
46 })
47 .collect()
48}
49
50pub fn max_replication_value<'a>(records: &'a [Value], key: &str) -> Option<&'a Value> {
52 records
53 .iter()
54 .filter_map(|r| r.get(key))
55 .max_by(|a, b| json_compare(a, b))
56}
57
58pub fn max_value(a: Value, b: Value) -> Value {
62 match json_compare(&a, &b) {
63 Ordering::Less => b,
64 _ => a,
65 }
66}
67
68fn type_rank(v: &Value) -> u8 {
71 match v {
72 Value::Null => 0,
73 Value::Bool(_) => 1,
74 Value::Number(_) => 2,
75 Value::String(_) => 3,
76 Value::Array(_) => 4,
77 Value::Object(_) => 5,
78 }
79}
80
81fn number_as_i128(n: &serde_json::Number) -> Option<i128> {
85 n.as_i64()
86 .map(i128::from)
87 .or_else(|| n.as_u64().map(i128::from))
88}
89
90pub(crate) fn json_compare(a: &Value, b: &Value) -> Ordering {
99 match (a, b) {
100 (Value::Number(an), Value::Number(bn)) => {
101 match (number_as_i128(an), number_as_i128(bn)) {
102 (Some(ai), Some(bi)) => ai.cmp(&bi),
103 _ => {
104 let af = an.as_f64().unwrap_or(f64::NAN);
105 let bf = bn.as_f64().unwrap_or(f64::NAN);
106 af.partial_cmp(&bf).unwrap_or_else(|| {
107 match (af.is_nan(), bf.is_nan()) {
109 (false, true) => Ordering::Less,
110 (true, false) => Ordering::Greater,
111 _ => Ordering::Equal,
112 }
113 })
114 }
115 }
116 }
117 (Value::String(x), Value::String(y)) => x.cmp(y),
118 (Value::Bool(x), Value::Bool(y)) => x.cmp(y),
119 (Value::Null, Value::Null) => Ordering::Equal,
120 (Value::Array(x), Value::Array(y)) => {
121 for (xi, yi) in x.iter().zip(y.iter()) {
122 let c = json_compare(xi, yi);
123 if c != Ordering::Equal {
124 return c;
125 }
126 }
127 x.len().cmp(&y.len())
128 }
129 (Value::Object(_), Value::Object(_)) => a.to_string().cmp(&b.to_string()),
132 _ => type_rank(a).cmp(&type_rank(b)),
134 }
135}
136
137pub fn json_gt(a: &Value, b: &Value) -> bool {
143 json_compare(a, b) == Ordering::Greater
144}
145
146pub const BIND_PLACEHOLDER: &str = "${bookmark}";
151
152fn default_bind_template() -> String {
153 BIND_PLACEHOLDER.to_owned()
154}
155
156#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)]
158#[serde(rename_all = "snake_case")]
159pub enum BindTarget {
160 #[default]
162 Query,
163 Header,
165 Body,
167 Path,
169}
170
171#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)]
181#[serde(rename_all = "snake_case")]
182pub enum BindFormat {
183 #[default]
186 Raw,
187 Iso8601,
189 EpochS,
191 EpochMs,
193 Date,
195}
196
197#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
206#[serde(deny_unknown_fields)]
207pub struct ReplicationBind {
208 #[serde(default)]
210 pub into: BindTarget,
211 pub name: String,
213 #[serde(default = "default_bind_template")]
217 pub template: String,
218 #[serde(default)]
220 pub format: BindFormat,
221 #[serde(default, skip_serializing_if = "Option::is_none")]
224 pub advance_from: Option<String>,
225}
226
227impl ReplicationBind {
228 pub fn validate(&self) -> Result<(), FaucetError> {
230 if self.name.trim().is_empty() {
231 return Err(FaucetError::Config(
232 "replication bind: `name` must not be empty".to_owned(),
233 ));
234 }
235 if !self.template.contains(BIND_PLACEHOLDER) {
236 return Err(FaucetError::Config(format!(
237 "replication bind: `template` must contain the `{BIND_PLACEHOLDER}` placeholder"
238 )));
239 }
240 Ok(())
241 }
242
243 pub fn render(&self, bookmark: &Value) -> Result<String, FaucetError> {
246 let formatted = format_bookmark(bookmark, self.format)?;
247 Ok(self.template.replace(BIND_PLACEHOLDER, &formatted))
248 }
249}
250
251fn bookmark_instant(value: &Value) -> Result<DateTime<Utc>, FaucetError> {
253 match value {
254 Value::String(s) => {
255 let s = s.trim();
256 if let Ok(dt) = DateTime::parse_from_rfc3339(s) {
257 return Ok(dt.with_timezone(&Utc));
258 }
259 if let Ok(d) = NaiveDate::parse_from_str(s, "%Y-%m-%d")
260 && let Some(ndt) = d.and_hms_opt(0, 0, 0)
261 {
262 return Ok(DateTime::<Utc>::from_naive_utc_and_offset(ndt, Utc));
263 }
264 if let Ok(ndt) = NaiveDateTime::parse_from_str(s, "%Y-%m-%dT%H:%M:%S") {
265 return Ok(DateTime::<Utc>::from_naive_utc_and_offset(ndt, Utc));
266 }
267 Err(FaucetError::Config(format!(
268 "replication bind: cannot parse bookmark '{s}' as a timestamp \
269 (expected RFC 3339, YYYY-MM-DD, or YYYY-MM-DDTHH:MM:SS)"
270 )))
271 }
272 Value::Number(n) => {
273 let secs = n.as_i64().or_else(|| n.as_f64().map(|f| f as i64));
274 secs.and_then(|s| DateTime::<Utc>::from_timestamp(s, 0))
275 .ok_or_else(|| {
276 FaucetError::Config(format!(
277 "replication bind: numeric bookmark {n} is out of range for epoch seconds"
278 ))
279 })
280 }
281 other => Err(FaucetError::Config(format!(
282 "replication bind: bookmark must be a string or number, got {other}"
283 ))),
284 }
285}
286
287pub fn parse_instant(value: &Value) -> Result<DateTime<Utc>, FaucetError> {
291 bookmark_instant(value)
292}
293
294pub fn format_instant(dt: DateTime<Utc>, format: BindFormat) -> String {
299 match format {
300 BindFormat::Raw | BindFormat::Iso8601 => {
301 dt.to_rfc3339_opts(chrono::SecondsFormat::Secs, true)
302 }
303 BindFormat::Date => dt.format("%Y-%m-%d").to_string(),
304 BindFormat::EpochS => dt.timestamp().to_string(),
305 BindFormat::EpochMs => dt.timestamp_millis().to_string(),
306 }
307}
308
309pub fn format_bookmark(value: &Value, format: BindFormat) -> Result<String, FaucetError> {
311 match format {
312 BindFormat::Raw => match value {
313 Value::String(s) => Ok(s.clone()),
314 Value::Number(n) => Ok(n.to_string()),
315 Value::Bool(b) => Ok(b.to_string()),
316 other => Err(FaucetError::Config(format!(
317 "replication bind: cannot render {other} as a raw scalar"
318 ))),
319 },
320 BindFormat::Iso8601 => {
321 Ok(bookmark_instant(value)?.to_rfc3339_opts(chrono::SecondsFormat::Secs, true))
322 }
323 BindFormat::Date => Ok(bookmark_instant(value)?.format("%Y-%m-%d").to_string()),
324 BindFormat::EpochS => Ok(bookmark_instant(value)?.timestamp().to_string()),
325 BindFormat::EpochMs => Ok(bookmark_instant(value)?.timestamp_millis().to_string()),
326 }
327}
328
329#[cfg(test)]
330mod tests {
331 use super::*;
332 use serde_json::json;
333
334 #[test]
335 fn test_filter_incremental_strings() {
336 let records = vec![
337 json!({"id": 1, "updated_at": "2024-01-01"}),
338 json!({"id": 2, "updated_at": "2024-06-01"}),
339 json!({"id": 3, "updated_at": "2024-12-01"}),
340 ];
341 let start = json!("2024-06-01");
342 let filtered = filter_incremental(records, "updated_at", &start);
343 assert_eq!(filtered.len(), 1);
344 assert_eq!(filtered[0]["id"], 3);
345 }
346
347 #[test]
348 fn test_filter_incremental_numbers() {
349 let records = vec![
350 json!({"id": 1, "seq": 100}),
351 json!({"id": 2, "seq": 200}),
352 json!({"id": 3, "seq": 300}),
353 ];
354 let start = json!(150);
355 let filtered = filter_incremental(records, "seq", &start);
356 assert_eq!(filtered.len(), 2);
357 assert_eq!(filtered[0]["id"], 2);
358 assert_eq!(filtered[1]["id"], 3);
359 }
360
361 #[test]
362 fn test_filter_incremental_missing_key_excluded() {
363 let records = vec![
364 json!({"id": 1}),
365 json!({"id": 2, "updated_at": "2024-12-01"}),
366 ];
367 let start = json!("2024-01-01");
368 let filtered = filter_incremental(records, "updated_at", &start);
369 assert_eq!(filtered.len(), 1);
370 assert_eq!(filtered[0]["id"], 2);
371 }
372
373 #[test]
374 fn test_filter_incremental_equal_excluded() {
375 let records = vec![
376 json!({"id": 1, "updated_at": "2024-06-01"}),
377 json!({"id": 2, "updated_at": "2024-06-02"}),
378 ];
379 let start = json!("2024-06-01");
380 let filtered = filter_incremental(records, "updated_at", &start);
381 assert_eq!(filtered.len(), 1);
382 assert_eq!(filtered[0]["id"], 2);
383 }
384
385 #[test]
386 fn test_max_replication_value_strings() {
387 let records = vec![
388 json!({"updated_at": "2024-01-01"}),
389 json!({"updated_at": "2024-12-01"}),
390 json!({"updated_at": "2024-06-01"}),
391 ];
392 let max = max_replication_value(&records, "updated_at").unwrap();
393 assert_eq!(max, &json!("2024-12-01"));
394 }
395
396 #[test]
397 fn test_max_replication_value_numbers() {
398 let records = vec![json!({"seq": 5}), json!({"seq": 10}), json!({"seq": 3})];
399 let max = max_replication_value(&records, "seq").unwrap();
400 assert_eq!(max, &json!(10));
401 }
402
403 #[test]
404 fn test_max_replication_value_empty() {
405 let records: Vec<Value> = vec![];
406 assert!(max_replication_value(&records, "updated_at").is_none());
407 }
408
409 #[test]
410 fn test_max_value_picks_larger_string() {
411 assert_eq!(
412 max_value(json!("2024-01-01"), json!("2024-06-01")),
413 json!("2024-06-01")
414 );
415 }
416
417 #[test]
418 fn test_max_value_picks_larger_number() {
419 assert_eq!(max_value(json!(5), json!(10)), json!(10));
420 }
421
422 #[test]
423 fn test_max_value_returns_a_on_type_mismatch() {
424 assert_eq!(max_value(json!("string"), json!(5)), json!("string"));
427 }
428
429 #[test]
430 fn filter_incremental_keeps_large_integer_beyond_f64_precision() {
431 let two_pow_53 = 9_007_199_254_740_992_i64; let records = vec![
436 json!({"id": 1, "seq": two_pow_53 + 1}),
437 json!({"id": 2, "seq": two_pow_53 + 2}),
438 ];
439 let start = json!(two_pow_53);
440 let filtered = filter_incremental(records, "seq", &start);
441 assert_eq!(
442 filtered.len(),
443 2,
444 "both values are strictly greater than 2^53"
445 );
446 }
447
448 #[test]
449 fn json_compare_distinguishes_large_integers() {
450 let a = json!(9_007_199_254_740_993_i64); let b = json!(9_007_199_254_740_992_i64); assert_eq!(json_compare(&a, &b), Ordering::Greater);
453 }
454
455 #[test]
456 fn filter_incremental_keeps_records_on_type_mismatch() {
457 let records = vec![json!({"id": 1, "seq": 20_240_701})];
461 let start = json!("2024-06-01"); let filtered = filter_incremental(records, "seq", &start);
463 assert_eq!(filtered.len(), 1, "type mismatch must not silently drop");
464 }
465
466 fn bind(into: BindTarget, template: &str, format: BindFormat) -> ReplicationBind {
469 ReplicationBind {
470 into,
471 name: "updated_after".to_owned(),
472 template: template.to_owned(),
473 format,
474 advance_from: None,
475 }
476 }
477
478 #[test]
479 fn bind_defaults_template_to_bare_placeholder() {
480 let b: ReplicationBind =
481 serde_json::from_value(json!({ "name": "since" })).expect("deserializes");
482 assert_eq!(b.into, BindTarget::Query);
483 assert_eq!(b.template, "${bookmark}");
484 assert_eq!(b.format, BindFormat::Raw);
485 assert!(b.advance_from.is_none());
486 }
487
488 #[test]
489 fn bind_render_raw_string_and_number() {
490 let b = bind(BindTarget::Query, "${bookmark}", BindFormat::Raw);
491 assert_eq!(b.render(&json!("2024-06-01")).unwrap(), "2024-06-01");
492 assert_eq!(b.render(&json!(150)).unwrap(), "150");
493 }
494
495 #[test]
496 fn bind_render_applies_operator_template() {
497 let b = bind(BindTarget::Query, "gte|${bookmark}", BindFormat::Raw);
498 assert_eq!(
499 b.render(&json!("2024-06-01T00:00:00Z")).unwrap(),
500 "gte|2024-06-01T00:00:00Z"
501 );
502 let l = bind(BindTarget::Query, "[${bookmark} TO *]", BindFormat::Raw);
504 assert_eq!(l.render(&json!("20240601")).unwrap(), "[20240601 TO *]");
505 }
506
507 #[test]
508 fn bind_format_iso8601_from_date_and_epoch() {
509 let b = bind(BindTarget::Header, "${bookmark}", BindFormat::Iso8601);
510 assert_eq!(
511 b.render(&json!("2024-06-01")).unwrap(),
512 "2024-06-01T00:00:00Z"
513 );
514 assert_eq!(
516 b.render(&json!(1_717_200_000)).unwrap(),
517 "2024-06-01T00:00:00Z"
518 );
519 }
520
521 #[test]
522 fn bind_format_epoch_s_and_ms_from_iso() {
523 let s = bind(BindTarget::Query, "${bookmark}", BindFormat::EpochS);
524 assert_eq!(
525 s.render(&json!("2024-06-01T00:00:00Z")).unwrap(),
526 "1717200000"
527 );
528 let ms = bind(BindTarget::Query, "${bookmark}", BindFormat::EpochMs);
529 assert_eq!(
530 ms.render(&json!("2024-06-01T00:00:00Z")).unwrap(),
531 "1717200000000"
532 );
533 }
534
535 #[test]
536 fn bind_format_date_truncates_datetime() {
537 let b = bind(BindTarget::Query, "${bookmark}", BindFormat::Date);
538 assert_eq!(
539 b.render(&json!("2024-06-01T12:34:56Z")).unwrap(),
540 "2024-06-01"
541 );
542 }
543
544 #[test]
545 fn bind_format_naive_datetime_assumed_utc() {
546 let b = bind(BindTarget::Query, "${bookmark}", BindFormat::Iso8601);
547 assert_eq!(
548 b.render(&json!("2024-06-01T08:00:00")).unwrap(),
549 "2024-06-01T08:00:00Z"
550 );
551 }
552
553 #[test]
554 fn bind_format_unparseable_string_errors() {
555 let b = bind(BindTarget::Query, "${bookmark}", BindFormat::Iso8601);
556 assert!(b.render(&json!("not-a-date")).is_err());
557 }
558
559 #[test]
560 fn bind_format_raw_rejects_composite() {
561 let b = bind(BindTarget::Query, "${bookmark}", BindFormat::Raw);
562 assert!(b.render(&json!({"a": 1})).is_err());
563 assert!(b.render(&json!(null)).is_err());
564 }
565
566 #[test]
567 fn bind_validate_rejects_empty_name_and_missing_placeholder() {
568 let mut b = bind(BindTarget::Query, "${bookmark}", BindFormat::Raw);
569 b.name = " ".to_owned();
570 assert!(b.validate().is_err());
571
572 let mut b2 = bind(BindTarget::Query, "no placeholder here", BindFormat::Raw);
573 b2.name = "since".to_owned();
574 assert!(b2.validate().is_err());
575
576 let ok = bind(BindTarget::Query, "gte|${bookmark}", BindFormat::Raw);
577 assert!(ok.validate().is_ok());
578 }
579
580 #[test]
581 fn bind_format_bookmark_bool_raw() {
582 assert_eq!(
583 format_bookmark(&json!(true), BindFormat::Raw).unwrap(),
584 "true"
585 );
586 }
587
588 #[test]
589 fn bind_format_non_scalar_bookmark_errors() {
590 assert!(format_bookmark(&json!({"a": 1}), BindFormat::Iso8601).is_err());
592 assert!(format_bookmark(&json!(null), BindFormat::EpochS).is_err());
593 }
594
595 #[test]
596 fn bind_format_out_of_range_epoch_errors() {
597 assert!(format_bookmark(&json!(i64::MAX), BindFormat::Iso8601).is_err());
599 }
600}