Skip to main content

scirs2_io/streaming/
cdc.rs

1//! Change Data Capture (CDC) streaming support
2//!
3//! Provides ordered change event tracking with Log Sequence Numbers (LSN),
4//! Debezium-format JSON parsing, CDC log replay for table-state reconstruction,
5//! and materialisation of CDC streams to columnar (Parquet-lite) format.
6
7use std::collections::HashMap;
8use std::io::{BufRead, BufReader, Write};
9use std::path::Path;
10
11use crate::error::{IoError, Result};
12
13// ──────────────────────────────────────────────────────────────────────────────
14// Public types
15// ──────────────────────────────────────────────────────────────────────────────
16
17/// A single cell value in a row image.
18#[derive(Debug, Clone, PartialEq)]
19pub enum CdcValue {
20    /// NULL / absent value.
21    Null,
22    /// Boolean value.
23    Bool(bool),
24    /// 64-bit integer value.
25    Int(i64),
26    /// 64-bit floating-point value.
27    Float(f64),
28    /// UTF-8 text value.
29    Text(String),
30    /// Raw byte sequence.
31    Bytes(Vec<u8>),
32}
33
34impl CdcValue {
35    /// Return a human-readable display string.
36    pub fn to_display(&self) -> String {
37        match self {
38            CdcValue::Null => "NULL".to_string(),
39            CdcValue::Bool(b) => b.to_string(),
40            CdcValue::Int(i) => i.to_string(),
41            CdcValue::Float(f) => f.to_string(),
42            CdcValue::Text(s) => s.clone(),
43            CdcValue::Bytes(b) => format!("<bytes len={}>", b.len()),
44        }
45    }
46}
47
48/// A row image — an ordered map of column name → value.
49pub type RowImage = HashMap<String, CdcValue>;
50
51/// The operation that generated a CDC event.
52#[derive(Debug, Clone, PartialEq, Eq)]
53pub enum CdcOperation {
54    /// A row was inserted.
55    Insert,
56    /// A row was updated.
57    Update,
58    /// A row was deleted.
59    Delete,
60    /// A snapshot read (no previous state, used during initial load).
61    Read,
62    /// Schema-change DDL event.
63    SchemaChange,
64}
65
66impl CdcOperation {
67    fn from_str(s: &str) -> Option<Self> {
68        match s {
69            "c" | "insert" | "INSERT" | "i" => Some(CdcOperation::Insert),
70            "u" | "update" | "UPDATE" => Some(CdcOperation::Update),
71            "d" | "delete" | "DELETE" => Some(CdcOperation::Delete),
72            "r" | "read" | "READ" => Some(CdcOperation::Read),
73            "s" | "schema" | "SCHEMA" => Some(CdcOperation::SchemaChange),
74            _ => None,
75        }
76    }
77}
78
79/// A single CDC event with before/after row images.
80#[derive(Debug, Clone)]
81pub struct CdcEvent {
82    /// Log Sequence Number — monotonically increasing.
83    pub lsn: u64,
84    /// Wall-clock timestamp (Unix millis) when the change occurred in the source.
85    pub source_ts_ms: i64,
86    /// Database/schema/table this event belongs to.
87    pub table: String,
88    /// The type of change.
89    pub operation: CdcOperation,
90    /// Row image before the change (None for INSERT/READ).
91    pub before: Option<RowImage>,
92    /// Row image after the change (None for DELETE).
93    pub after: Option<RowImage>,
94    /// Optional transaction identifier.
95    pub transaction_id: Option<String>,
96}
97
98impl CdcEvent {
99    /// Create an INSERT event.
100    pub fn insert(lsn: u64, ts_ms: i64, table: impl Into<String>, after: RowImage) -> Self {
101        CdcEvent {
102            lsn,
103            source_ts_ms: ts_ms,
104            table: table.into(),
105            operation: CdcOperation::Insert,
106            before: None,
107            after: Some(after),
108            transaction_id: None,
109        }
110    }
111
112    /// Create an UPDATE event.
113    pub fn update(
114        lsn: u64,
115        ts_ms: i64,
116        table: impl Into<String>,
117        before: RowImage,
118        after: RowImage,
119    ) -> Self {
120        CdcEvent {
121            lsn,
122            source_ts_ms: ts_ms,
123            table: table.into(),
124            operation: CdcOperation::Update,
125            before: Some(before),
126            after: Some(after),
127            transaction_id: None,
128        }
129    }
130
131    /// Create a DELETE event.
132    pub fn delete(lsn: u64, ts_ms: i64, table: impl Into<String>, before: RowImage) -> Self {
133        CdcEvent {
134            lsn,
135            source_ts_ms: ts_ms,
136            table: table.into(),
137            operation: CdcOperation::Delete,
138            before: Some(before),
139            after: None,
140            transaction_id: None,
141        }
142    }
143}
144
145// ──────────────────────────────────────────────────────────────────────────────
146// CDCLog
147// ──────────────────────────────────────────────────────────────────────────────
148
149/// An ordered log of CDC events.
150///
151/// Events are kept in LSN order. The log supports appending, serialisation
152/// to a newline-delimited JSON file, and replay.
153#[derive(Debug, Default)]
154pub struct CdcLog {
155    events: Vec<CdcEvent>,
156    /// Next LSN to assign when appending.
157    next_lsn: u64,
158}
159
160impl CdcLog {
161    /// Create an empty log starting at LSN 0.
162    pub fn new() -> Self {
163        CdcLog {
164            events: Vec::new(),
165            next_lsn: 0,
166        }
167    }
168
169    /// Create a log with a specific starting LSN.
170    pub fn with_start_lsn(start: u64) -> Self {
171        CdcLog {
172            events: Vec::new(),
173            next_lsn: start,
174        }
175    }
176
177    /// Append a pre-built event (its existing LSN is preserved; `next_lsn` is
178    /// advanced if needed to stay consistent).
179    pub fn push(&mut self, event: CdcEvent) {
180        if event.lsn >= self.next_lsn {
181            self.next_lsn = event.lsn + 1;
182        }
183        self.events.push(event);
184    }
185
186    /// Append an INSERT and auto-assign the next LSN.
187    pub fn insert(&mut self, ts_ms: i64, table: impl Into<String>, after: RowImage) -> u64 {
188        let lsn = self.next_lsn;
189        self.next_lsn += 1;
190        self.events.push(CdcEvent::insert(lsn, ts_ms, table, after));
191        lsn
192    }
193
194    /// Append an UPDATE and auto-assign the next LSN.
195    pub fn update(
196        &mut self,
197        ts_ms: i64,
198        table: impl Into<String>,
199        before: RowImage,
200        after: RowImage,
201    ) -> u64 {
202        let lsn = self.next_lsn;
203        self.next_lsn += 1;
204        self.events
205            .push(CdcEvent::update(lsn, ts_ms, table, before, after));
206        lsn
207    }
208
209    /// Append a DELETE and auto-assign the next LSN.
210    pub fn delete(&mut self, ts_ms: i64, table: impl Into<String>, before: RowImage) -> u64 {
211        let lsn = self.next_lsn;
212        self.next_lsn += 1;
213        self.events
214            .push(CdcEvent::delete(lsn, ts_ms, table, before));
215        lsn
216    }
217
218    /// Number of events in the log.
219    pub fn len(&self) -> usize {
220        self.events.len()
221    }
222
223    /// Returns true if the log contains no events.
224    pub fn is_empty(&self) -> bool {
225        self.events.is_empty()
226    }
227
228    /// Iterate over all events in LSN order.
229    pub fn events(&self) -> &[CdcEvent] {
230        &self.events
231    }
232
233    /// Filter events for a single table.
234    pub fn events_for_table(&self, table: &str) -> Vec<&CdcEvent> {
235        self.events.iter().filter(|e| e.table == table).collect()
236    }
237
238    /// Serialize the log to newline-delimited JSON (NDJSON).
239    pub fn write_ndjson<W: Write>(&self, mut writer: W) -> Result<()> {
240        for ev in &self.events {
241            let line = event_to_ndjson(ev);
242            writer
243                .write_all(line.as_bytes())
244                .map_err(|e| IoError::Io(e))?;
245            writer.write_all(b"\n").map_err(|e| IoError::Io(e))?;
246        }
247        Ok(())
248    }
249
250    /// Deserialize a log from newline-delimited JSON produced by `write_ndjson`.
251    pub fn read_ndjson<R: BufRead>(reader: R) -> Result<Self> {
252        let mut log = CdcLog::new();
253        for (line_no, line_res) in reader.lines().enumerate() {
254            let line = line_res.map_err(|e| IoError::Io(e))?;
255            let trimmed = line.trim();
256            if trimmed.is_empty() {
257                continue;
258            }
259            let ev = ndjson_to_event(trimmed).map_err(|e| {
260                IoError::ParseError(format!("CDC NDJSON line {}: {}", line_no + 1, e))
261            })?;
262            log.push(ev);
263        }
264        Ok(log)
265    }
266}
267
268// ──────────────────────────────────────────────────────────────────────────────
269// Replay
270// ──────────────────────────────────────────────────────────────────────────────
271
272/// Replay a `CdcLog` to reconstruct the final state of a table.
273///
274/// The primary key column(s) identify rows. Returns a map of
275/// `pk_value → current_row`.
276///
277/// * `pk_columns` — column name(s) used as primary key (in order). The key
278///   string in the returned map is the PK columns joined with `\x00`.
279pub fn replay_cdc(
280    log: &CdcLog,
281    table: &str,
282    pk_columns: &[&str],
283) -> Result<HashMap<String, RowImage>> {
284    if pk_columns.is_empty() {
285        return Err(IoError::ValidationError(
286            "pk_columns must not be empty".to_string(),
287        ));
288    }
289
290    let mut state: HashMap<String, RowImage> = HashMap::new();
291
292    for ev in log.events_for_table(table) {
293        match ev.operation {
294            CdcOperation::Insert | CdcOperation::Read => {
295                if let Some(after) = &ev.after {
296                    let pk = extract_pk(after, pk_columns)?;
297                    state.insert(pk, after.clone());
298                }
299            }
300            CdcOperation::Update => {
301                if let Some(after) = &ev.after {
302                    let pk = extract_pk(after, pk_columns)?;
303                    state.insert(pk, after.clone());
304                }
305            }
306            CdcOperation::Delete => {
307                if let Some(before) = &ev.before {
308                    let pk = extract_pk(before, pk_columns)?;
309                    state.remove(&pk);
310                }
311            }
312            CdcOperation::SchemaChange => {
313                // schema changes don't alter row state
314            }
315        }
316    }
317
318    Ok(state)
319}
320
321fn extract_pk(row: &RowImage, pk_columns: &[&str]) -> Result<String> {
322    let mut parts = Vec::with_capacity(pk_columns.len());
323    for col in pk_columns {
324        let val = row.get(*col).ok_or_else(|| {
325            IoError::ValidationError(format!("PK column '{}' not found in row image", col))
326        })?;
327        parts.push(val.to_display());
328    }
329    Ok(parts.join("\x00"))
330}
331
332// ──────────────────────────────────────────────────────────────────────────────
333// CDC → columnar materialisation
334// ──────────────────────────────────────────────────────────────────────────────
335
336/// Materialised columnar snapshot produced by `cdc_to_parquet`.
337#[derive(Debug, Default)]
338pub struct CdcColumnarSnapshot {
339    /// Column names in stable order.
340    pub columns: Vec<String>,
341    /// Column data: each inner Vec has one entry per row.
342    pub data: HashMap<String, Vec<CdcValue>>,
343    /// Number of rows.
344    pub row_count: usize,
345}
346
347impl CdcColumnarSnapshot {
348    fn new(columns: Vec<String>) -> Self {
349        let mut data = HashMap::new();
350        for col in &columns {
351            data.insert(col.clone(), Vec::new());
352        }
353        CdcColumnarSnapshot {
354            columns,
355            data,
356            row_count: 0,
357        }
358    }
359
360    fn push_row(&mut self, row: &RowImage) {
361        for col in &self.columns {
362            let val = row.get(col).cloned().unwrap_or(CdcValue::Null);
363            if let Some(col_data) = self.data.get_mut(col) {
364                col_data.push(val);
365            }
366        }
367        self.row_count += 1;
368    }
369}
370
371/// Materialise a CDC log for a given table into a columnar snapshot.
372///
373/// Equivalent to calling `replay_cdc` and then laying the result out column-
374/// wise. `column_order` determines the column ordering; pass `None` to infer
375/// from the first row encountered.
376pub fn cdc_to_parquet(
377    log: &CdcLog,
378    table: &str,
379    pk_columns: &[&str],
380    column_order: Option<&[&str]>,
381) -> Result<CdcColumnarSnapshot> {
382    let state = replay_cdc(log, table, pk_columns)?;
383
384    // Determine column names
385    let columns: Vec<String> = if let Some(order) = column_order {
386        order.iter().map(|s| s.to_string()).collect()
387    } else {
388        // Collect from first row, then sort for determinism
389        if let Some(row) = state.values().next() {
390            let mut cols: Vec<String> = row.keys().cloned().collect();
391            cols.sort();
392            cols
393        } else {
394            return Ok(CdcColumnarSnapshot::default());
395        }
396    };
397
398    let mut snapshot = CdcColumnarSnapshot::new(columns);
399
400    // Sort rows by PK for deterministic output
401    let mut sorted_rows: Vec<(&String, &RowImage)> = state.iter().collect();
402    sorted_rows.sort_by_key(|(pk, _)| pk.as_str());
403
404    for (_, row) in sorted_rows {
405        snapshot.push_row(row);
406    }
407
408    Ok(snapshot)
409}
410
411// ──────────────────────────────────────────────────────────────────────────────
412// Debezium JSON parser
413// ──────────────────────────────────────────────────────────────────────────────
414
415/// Parse a Debezium-format CDC JSON string into a `CdcEvent`.
416///
417/// Supports the standard Debezium envelope:
418/// ```json
419/// {
420///   "payload": {
421///     "op": "c",
422///     "ts_ms": 1234567890000,
423///     "source": { "table": "orders", ... },
424///     "before": null,
425///     "after": { "id": 1, "name": "Alice" }
426///   }
427/// }
428/// ```
429pub fn debezium_json_parser(json: &str, lsn: u64) -> Result<CdcEvent> {
430    let trimmed = json.trim();
431    // Walk into "payload" if present; otherwise treat the root as the payload.
432    let payload_str = extract_payload_str(trimmed);
433
434    let op_str = extract_string_field(payload_str, "op")
435        .ok_or_else(|| IoError::ParseError("Debezium JSON missing 'op' field".to_string()))?;
436
437    let operation = CdcOperation::from_str(&op_str)
438        .ok_or_else(|| IoError::ParseError(format!("Unknown Debezium op '{}'", op_str)))?;
439
440    let ts_ms = extract_i64_field(payload_str, "ts_ms").unwrap_or(0);
441
442    // Extract table from source.table
443    let table = extract_source_table(payload_str).unwrap_or_else(|| "unknown".to_string());
444
445    let before = extract_row_object(payload_str, "before");
446    let after = extract_row_object(payload_str, "after");
447
448    Ok(CdcEvent {
449        lsn,
450        source_ts_ms: ts_ms,
451        table,
452        operation,
453        before,
454        after,
455        transaction_id: extract_string_field(payload_str, "transaction"),
456    })
457}
458
459/// Parse a CDC log file where each line is a Debezium JSON event.
460pub fn parse_debezium_log<P: AsRef<Path>>(path: P) -> Result<CdcLog> {
461    let file = std::fs::File::open(path.as_ref()).map_err(|e| IoError::Io(e))?;
462    let reader = BufReader::new(file);
463    let mut log = CdcLog::new();
464    let mut lsn: u64 = 0;
465
466    for (line_no, line_res) in reader.lines().enumerate() {
467        let line = line_res.map_err(|e| IoError::Io(e))?;
468        let trimmed = line.trim();
469        if trimmed.is_empty() {
470            continue;
471        }
472        let ev = debezium_json_parser(trimmed, lsn)
473            .map_err(|e| IoError::ParseError(format!("Debezium line {}: {}", line_no + 1, e)))?;
474        lsn = ev.lsn + 1;
475        log.push(ev);
476    }
477    Ok(log)
478}
479
480// ──────────────────────────────────────────────────────────────────────────────
481// Internal serialisation helpers
482// ──────────────────────────────────────────────────────────────────────────────
483
484fn cdc_value_to_json(v: &CdcValue) -> String {
485    match v {
486        CdcValue::Null => "null".to_string(),
487        CdcValue::Bool(b) => b.to_string(),
488        CdcValue::Int(i) => i.to_string(),
489        CdcValue::Float(f) => {
490            if f.is_nan() || f.is_infinite() {
491                "null".to_string()
492            } else {
493                f.to_string()
494            }
495        }
496        CdcValue::Text(s) => format!("\"{}\"", s.replace('\\', "\\\\").replace('"', "\\\"")),
497        CdcValue::Bytes(b) => {
498            // Base-64-like hex encoding for simplicity
499            let hex: String = b.iter().map(|byte| format!("{:02x}", byte)).collect();
500            format!("\"\\u0000hex:{hex}\"")
501        }
502    }
503}
504
505fn row_image_to_json(row: &RowImage) -> String {
506    let mut pairs: Vec<String> = row
507        .iter()
508        .map(|(k, v)| format!("\"{}\":{}", k, cdc_value_to_json(v)))
509        .collect();
510    pairs.sort(); // deterministic order
511    format!("{{{}}}", pairs.join(","))
512}
513
514fn event_to_ndjson(ev: &CdcEvent) -> String {
515    let op = match ev.operation {
516        CdcOperation::Insert => "c",
517        CdcOperation::Update => "u",
518        CdcOperation::Delete => "d",
519        CdcOperation::Read => "r",
520        CdcOperation::SchemaChange => "s",
521    };
522    let before_str = ev
523        .before
524        .as_ref()
525        .map(row_image_to_json)
526        .unwrap_or_else(|| "null".to_string());
527    let after_str = ev
528        .after
529        .as_ref()
530        .map(row_image_to_json)
531        .unwrap_or_else(|| "null".to_string());
532    let tx = ev
533        .transaction_id
534        .as_deref()
535        .map(|t| format!("\"{}\"", t))
536        .unwrap_or_else(|| "null".to_string());
537
538    format!(
539        "{{\"lsn\":{},\"ts_ms\":{},\"table\":\"{}\",\"op\":\"{}\",\"before\":{},\"after\":{},\"tx\":{}}}",
540        ev.lsn, ev.source_ts_ms, ev.table, op, before_str, after_str, tx
541    )
542}
543
544fn ndjson_to_event(s: &str) -> std::result::Result<CdcEvent, String> {
545    let lsn = extract_u64_field(s, "lsn").ok_or_else(|| "missing 'lsn'".to_string())?;
546    let ts_ms = extract_i64_field(s, "ts_ms").unwrap_or(0);
547    let table = extract_string_field(s, "table").ok_or_else(|| "missing 'table'".to_string())?;
548    let op_str = extract_string_field(s, "op").ok_or_else(|| "missing 'op'".to_string())?;
549    let operation =
550        CdcOperation::from_str(&op_str).ok_or_else(|| format!("unknown op '{}'", op_str))?;
551    let before = extract_row_object(s, "before");
552    let after = extract_row_object(s, "after");
553    let transaction_id = extract_string_field(s, "tx");
554
555    Ok(CdcEvent {
556        lsn,
557        source_ts_ms: ts_ms,
558        table,
559        operation,
560        before,
561        after,
562        transaction_id,
563    })
564}
565
566// ──────────────────────────────────────────────────────────────────────────────
567// Minimal JSON extraction helpers (no external JSON crate dependency)
568// ──────────────────────────────────────────────────────────────────────────────
569
570/// Returns the content of a `"payload": {...}` sub-object, or the whole input.
571fn extract_payload_str(s: &str) -> &str {
572    if let Some(start) = find_key_value_start(s, "payload") {
573        // find the matching `{`
574        if let Some(obj_start) = s[start..].find('{') {
575            let abs = start + obj_start;
576            if let Some(end) = matching_brace(s, abs) {
577                return &s[abs..=end];
578            }
579        }
580    }
581    s
582}
583
584fn extract_string_field(s: &str, key: &str) -> Option<String> {
585    let pattern = format!("\"{}\"", key);
586    let pos = s.find(&pattern)?;
587    let after_key = &s[pos + pattern.len()..];
588    let colon_pos = after_key.find(':')? + 1;
589    let val_str = after_key[colon_pos..].trim_start();
590    if let Some(inner) = val_str.strip_prefix('"') {
591        // quoted string
592        let mut result = String::new();
593        let mut chars = inner.chars();
594        loop {
595            match chars.next() {
596                None => break,
597                Some('"') => break,
598                Some('\\') => match chars.next() {
599                    Some('"') => result.push('"'),
600                    Some('\\') => result.push('\\'),
601                    Some('n') => result.push('\n'),
602                    Some('r') => result.push('\r'),
603                    Some('t') => result.push('\t'),
604                    Some(c) => {
605                        result.push('\\');
606                        result.push(c);
607                    }
608                    None => break,
609                },
610                Some(c) => result.push(c),
611            }
612        }
613        Some(result)
614    } else {
615        None
616    }
617}
618
619fn extract_i64_field(s: &str, key: &str) -> Option<i64> {
620    let pattern = format!("\"{}\"", key);
621    let pos = s.find(&pattern)?;
622    let after_key = &s[pos + pattern.len()..];
623    let colon_pos = after_key.find(':')? + 1;
624    let val_str = after_key[colon_pos..].trim_start();
625    // Read digits (possibly with leading minus)
626    let end = val_str
627        .find(|c: char| !c.is_ascii_digit() && c != '-')
628        .unwrap_or(val_str.len());
629    val_str[..end].parse::<i64>().ok()
630}
631
632fn extract_u64_field(s: &str, key: &str) -> Option<u64> {
633    let pattern = format!("\"{}\"", key);
634    let pos = s.find(&pattern)?;
635    let after_key = &s[pos + pattern.len()..];
636    let colon_pos = after_key.find(':')? + 1;
637    let val_str = after_key[colon_pos..].trim_start();
638    let end = val_str
639        .find(|c: char| !c.is_ascii_digit())
640        .unwrap_or(val_str.len());
641    val_str[..end].parse::<u64>().ok()
642}
643
644fn extract_source_table(s: &str) -> Option<String> {
645    // Try "source": { ... "table": "x" ... }
646    let source_pos = find_key_value_start(s, "source")?;
647    let source_str = &s[source_pos..];
648    let obj_start = source_str.find('{')?;
649    let abs = source_pos + obj_start;
650    let end = matching_brace(s, abs)?;
651    let source_obj = &s[abs..=end];
652    extract_string_field(source_obj, "table")
653}
654
655fn extract_row_object(s: &str, key: &str) -> Option<RowImage> {
656    let pattern = format!("\"{}\"", key);
657    let pos = s.find(&pattern)?;
658    let after_key = &s[pos + pattern.len()..];
659    let colon_pos = after_key.find(':')? + 1;
660    let val_str = after_key[colon_pos..].trim_start();
661
662    if val_str.starts_with("null") || val_str.is_empty() {
663        return None;
664    }
665
666    if !val_str.starts_with('{') {
667        return None;
668    }
669
670    // The absolute position within `s` where the object starts
671    let abs_start =
672        s.len() - after_key.len() + colon_pos + (after_key[colon_pos..].len() - val_str.len());
673    let abs_end = matching_brace(s, abs_start)?;
674    let obj_str = &s[abs_start..=abs_end];
675
676    parse_flat_json_object(obj_str)
677}
678
679/// Parse a flat (single-level) JSON object into a RowImage.
680fn parse_flat_json_object(s: &str) -> Option<RowImage> {
681    let inner = s
682        .strip_prefix('{')
683        .and_then(|t| t.strip_suffix('}'))
684        .unwrap_or(s);
685
686    let mut map = HashMap::new();
687    let mut rest = inner.trim();
688
689    while !rest.is_empty() {
690        // Read key
691        rest = rest.trim_start_matches(',').trim();
692        if rest.is_empty() {
693            break;
694        }
695        if !rest.starts_with('"') {
696            break;
697        }
698        let (key, after_key) = parse_json_string(rest)?;
699        rest = after_key.trim_start();
700        if !rest.starts_with(':') {
701            break;
702        }
703        rest = rest[1..].trim_start();
704
705        // Read value
706        let (val, after_val) = parse_json_value(rest)?;
707        map.insert(key, val);
708        rest = after_val.trim_start();
709    }
710
711    Some(map)
712}
713
714fn parse_json_string(s: &str) -> Option<(String, &str)> {
715    if !s.starts_with('"') {
716        return None;
717    }
718    let mut result = String::new();
719    let mut chars = s[1..].char_indices();
720    loop {
721        match chars.next() {
722            None => return None,
723            Some((i, '"')) => {
724                // +2: leading `"` + closing `"`
725                return Some((result, &s[i + 2..]));
726            }
727            Some((_, '\\')) => match chars.next() {
728                Some((_, '"')) => result.push('"'),
729                Some((_, '\\')) => result.push('\\'),
730                Some((_, 'n')) => result.push('\n'),
731                Some((_, 'r')) => result.push('\r'),
732                Some((_, 't')) => result.push('\t'),
733                Some((_, c)) => {
734                    result.push('\\');
735                    result.push(c);
736                }
737                None => return None,
738            },
739            Some((_, c)) => result.push(c),
740        }
741    }
742}
743
744fn parse_json_value(s: &str) -> Option<(CdcValue, &str)> {
745    if let Some(rest) = s.strip_prefix("null") {
746        return Some((CdcValue::Null, rest));
747    }
748    if let Some(rest) = s.strip_prefix("true") {
749        return Some((CdcValue::Bool(true), rest));
750    }
751    if let Some(rest) = s.strip_prefix("false") {
752        return Some((CdcValue::Bool(false), rest));
753    }
754    if s.starts_with('"') {
755        let (string, rest) = parse_json_string(s)?;
756        return Some((CdcValue::Text(string), rest));
757    }
758    // Number: integer or float
759    let end = s
760        .find(|c: char| {
761            !c.is_ascii_digit() && c != '-' && c != '.' && c != 'e' && c != 'E' && c != '+'
762        })
763        .unwrap_or(s.len());
764    let num_str = &s[..end];
765    if num_str.is_empty() {
766        return None;
767    }
768    if num_str.contains('.') || num_str.contains('e') || num_str.contains('E') {
769        let f = num_str.parse::<f64>().ok()?;
770        Some((CdcValue::Float(f), &s[end..]))
771    } else {
772        let i = num_str.parse::<i64>().ok()?;
773        Some((CdcValue::Int(i), &s[end..]))
774    }
775}
776
777/// Find the position in `s` where the value for key `key` starts.
778fn find_key_value_start(s: &str, key: &str) -> Option<usize> {
779    let pattern = format!("\"{}\"", key);
780    let pos = s.find(&pattern)?;
781    Some(pos)
782}
783
784/// Return the index of the `}` that matches the `{` at `start` in `s`.
785fn matching_brace(s: &str, start: usize) -> Option<usize> {
786    let bytes = s.as_bytes();
787    if bytes.get(start) != Some(&b'{') {
788        return None;
789    }
790    let mut depth = 0usize;
791    let mut in_str = false;
792    let mut escape = false;
793    for (i, &b) in bytes[start..].iter().enumerate() {
794        if escape {
795            escape = false;
796            continue;
797        }
798        if b == b'\\' && in_str {
799            escape = true;
800            continue;
801        }
802        if b == b'"' {
803            in_str = !in_str;
804            continue;
805        }
806        if in_str {
807            continue;
808        }
809        match b {
810            b'{' => depth += 1,
811            b'}' => {
812                depth -= 1;
813                if depth == 0 {
814                    return Some(start + i);
815                }
816            }
817            _ => {}
818        }
819    }
820    None
821}
822
823// ──────────────────────────────────────────────────────────────────────────────
824// Tests
825// ──────────────────────────────────────────────────────────────────────────────
826
827#[cfg(test)]
828mod tests {
829    use super::*;
830
831    fn make_row(pairs: &[(&str, CdcValue)]) -> RowImage {
832        pairs
833            .iter()
834            .map(|(k, v)| (k.to_string(), v.clone()))
835            .collect()
836    }
837
838    #[test]
839    fn test_cdc_log_basic() {
840        let mut log = CdcLog::new();
841        let after = make_row(&[
842            ("id", CdcValue::Int(1)),
843            ("name", CdcValue::Text("Alice".into())),
844        ]);
845        let lsn = log.insert(1_000, "users", after);
846        assert_eq!(lsn, 0);
847        assert_eq!(log.len(), 1);
848    }
849
850    #[test]
851    fn test_replay_insert_delete() {
852        let mut log = CdcLog::new();
853
854        let row1 = make_row(&[
855            ("id", CdcValue::Int(1)),
856            ("val", CdcValue::Text("a".into())),
857        ]);
858        let row2 = make_row(&[
859            ("id", CdcValue::Int(2)),
860            ("val", CdcValue::Text("b".into())),
861        ]);
862
863        log.insert(0, "t", row1.clone());
864        log.insert(0, "t", row2.clone());
865        log.delete(0, "t", row1.clone());
866
867        let state = replay_cdc(&log, "t", &["id"]).expect("replay failed");
868        assert_eq!(state.len(), 1);
869        assert!(state.contains_key("2"));
870    }
871
872    #[test]
873    fn test_replay_update() {
874        let mut log = CdcLog::new();
875
876        let before = make_row(&[
877            ("id", CdcValue::Int(1)),
878            ("val", CdcValue::Text("old".into())),
879        ]);
880        let after = make_row(&[
881            ("id", CdcValue::Int(1)),
882            ("val", CdcValue::Text("new".into())),
883        ]);
884
885        log.insert(0, "t", before.clone());
886        log.update(0, "t", before, after);
887
888        let state = replay_cdc(&log, "t", &["id"]).expect("replay failed");
889        let row = state.get("1").expect("row must exist");
890        assert_eq!(row.get("val"), Some(&CdcValue::Text("new".into())));
891    }
892
893    #[test]
894    fn test_ndjson_roundtrip() {
895        let mut log = CdcLog::new();
896        let row = make_row(&[
897            ("id", CdcValue::Int(42)),
898            ("x", CdcValue::Float(std::f64::consts::PI)),
899        ]);
900        log.insert(9999, "metrics", row);
901
902        let mut buf = Vec::new();
903        log.write_ndjson(&mut buf).expect("write failed");
904
905        let log2 = CdcLog::read_ndjson(std::io::BufReader::new(&buf[..])).expect("read failed");
906        assert_eq!(log2.len(), 1);
907        let ev = &log2.events()[0];
908        assert_eq!(ev.operation, CdcOperation::Insert);
909        assert_eq!(ev.table, "metrics");
910    }
911
912    #[test]
913    fn test_debezium_parser() {
914        let json = r#"{
915            "payload": {
916                "op": "c",
917                "ts_ms": 1711234567890,
918                "source": {"table": "orders", "db": "mydb"},
919                "before": null,
920                "after": {"id": 99, "amount": 10}
921            }
922        }"#;
923        let ev = debezium_json_parser(json, 7).expect("parse failed");
924        assert_eq!(ev.operation, CdcOperation::Insert);
925        assert_eq!(ev.table, "orders");
926        assert_eq!(ev.lsn, 7);
927        assert!(ev.before.is_none());
928        assert!(ev.after.is_some());
929    }
930
931    #[test]
932    fn test_cdc_to_parquet() {
933        let mut log = CdcLog::new();
934        for i in 0..5i64 {
935            let row = make_row(&[
936                ("id", CdcValue::Int(i)),
937                ("v", CdcValue::Float(i as f64 * 1.5)),
938            ]);
939            log.insert(0, "data", row);
940        }
941
942        let snap =
943            cdc_to_parquet(&log, "data", &["id"], Some(&["id", "v"])).expect("materialise failed");
944        assert_eq!(snap.row_count, 5);
945        assert_eq!(snap.columns.len(), 2);
946    }
947}