Skip to main content

surreal_sync_runtime/
transforms.rs

1//! Shared CLI helpers for `--transforms-config` and in-process Rust stages.
2//!
3//! Every `from *` sync command that has a sync path should call
4//! [`load_transforms_from_args`] so identity vs configured transforms behave
5//! the same across sources. Embedders that append Rust [`InPlaceTransform`]
6//! stages should use [`merge_inplace_transforms`].
7
8use anyhow::Context;
9use std::path::Path;
10use surreal_sync_core::InPlaceTransform;
11
12use crate::pipeline::{load_pipeline_and_opts, ApplyOpts, Pipeline};
13
14/// Load `--transforms-config` into a [`Pipeline`] + [`ApplyOpts`].
15///
16/// **Omit flag vs empty / passthrough file — different buffering cadence:**
17///
18/// | CLI input | Pipeline | [`ApplyOpts`] |
19/// |-----------|----------|---------------|
20/// | Flag omitted (`None`) | Identity (no stage dispatch) | [`ApplyOpts::identity()`] (`batch_size = 1`, `max_in_flight = 1`) |
21/// | Empty / passthrough-only TOML | Identity stages | [`ApplyOpts::default()`] (`batch_size = 1000`) |
22///
23/// Both yield an identity pipeline, but omit uses per-event CDC cadence
24/// (`batch_size = 1`, `max_in_flight = 1`) while an empty/passthrough config
25/// keeps config defaults (`batch_size = 1000`). That changes buffering cadence
26/// even when no transform stages run. There is no separate oneshot write path
27/// for identity — omit pins W=1 for CDC cadence only.
28///
29/// Bad TOML or unresolvable worker argv fails fast before sync starts (worker
30/// spawn / argv fail-fast is also covered in `pipeline` config tests).
31pub fn load_transforms_from_args(
32    transforms_config: Option<&Path>,
33) -> anyhow::Result<(Pipeline, ApplyOpts)> {
34    match transforms_config {
35        None => {
36            tracing::info!(
37                "No --transforms-config; using identity transform pipeline (no stage dispatch)"
38            );
39            Ok((Pipeline::new(), ApplyOpts::identity()))
40        }
41        Some(path) => {
42            let (pipeline, opts) = load_pipeline_and_opts(path)
43                .with_context(|| format!("load --transforms-config {}", path.display()))?;
44            if pipeline.is_identity() {
45                tracing::info!(
46                    path = %path.display(),
47                    "Loaded --transforms-config but pipeline is identity (empty / passthrough-only)"
48                );
49            } else {
50                tracing::info!(
51                    path = %path.display(),
52                    stages = pipeline.len(),
53                    max_in_flight = opts.max_in_flight,
54                    batch_size = opts.batch_size,
55                    failure_policy = ?opts.failure_policy,
56                    "Transform pipeline active from --transforms-config"
57                );
58            }
59            Ok((pipeline, opts))
60        }
61    }
62}
63
64/// Load optional `--transforms-config`, then **append** in-process
65/// [`InPlaceTransform`] stages (TOML stages run first).
66///
67/// When the flag is omitted, [`load_transforms_from_args`] yields
68/// [`ApplyOpts::identity()`]. If any Rust stages are appended, opts are
69/// upgraded to [`ApplyOpts::default()`] so batching is on — identity cadence
70/// is only for “no stages”.
71pub fn merge_inplace_transforms<I, T>(
72    transforms_config: Option<&Path>,
73    extra: I,
74) -> anyhow::Result<(Pipeline, ApplyOpts)>
75where
76    I: IntoIterator<Item = T>,
77    T: InPlaceTransform + 'static,
78{
79    merge_inplace_boxed(
80        transforms_config,
81        extra
82            .into_iter()
83            .map(|t| Box::new(t) as Box<dyn InPlaceTransform>),
84    )
85}
86
87/// Like [`merge_inplace_transforms`], but accepts mixed concrete stage types via
88/// [`Box<dyn InPlaceTransform>`] (needed for heterogeneous lists).
89pub fn merge_inplace_boxed(
90    transforms_config: Option<&Path>,
91    extra: impl IntoIterator<Item = Box<dyn InPlaceTransform>>,
92) -> anyhow::Result<(Pipeline, ApplyOpts)> {
93    let (mut pipeline, mut apply_opts) = load_transforms_from_args(transforms_config)?;
94    let before = pipeline.len();
95    for stage in extra {
96        pipeline.push_inplace_arc(std::sync::Arc::from(stage));
97    }
98    let added = pipeline.len().saturating_sub(before);
99    if added > 0 && apply_opts == ApplyOpts::identity() {
100        tracing::info!(
101            added,
102            "Rust InPlaceTransform stages appended after omitted --transforms-config; \
103             upgrading ApplyOpts from identity to default (batching on)"
104        );
105        apply_opts = ApplyOpts::default();
106    } else if added > 0 {
107        tracing::info!(
108            added,
109            stages = pipeline.len(),
110            "Appended Rust InPlaceTransform stages after --transforms-config"
111        );
112    }
113    Ok((pipeline, apply_opts))
114}
115
116#[cfg(test)]
117mod tests {
118    use super::*;
119    use crate::pipeline::FlattenId;
120    use std::io::Write;
121
122    #[test]
123    fn missing_path_returns_identity() {
124        let (pipeline, opts) = load_transforms_from_args(None).expect("identity load");
125        assert!(pipeline.is_identity());
126        assert_eq!(opts, ApplyOpts::identity());
127        assert_eq!(
128            opts.max_in_flight, 1,
129            "omit path must pin W=1 for CDC cadence"
130        );
131        assert_eq!(opts.batch_size, 1);
132    }
133
134    #[test]
135    fn missing_file_fails_fast() {
136        let err = load_transforms_from_args(Some(Path::new(
137            "/nonexistent/surreal-sync-transforms-cli-xyz.toml",
138        )))
139        .expect_err("missing file must fail");
140        let msg = format!("{err:#}");
141        assert!(
142            msg.contains("load --transforms-config"),
143            "expected load context in error, got: {msg}"
144        );
145    }
146
147    #[test]
148    fn empty_toml_is_identity_pipeline_with_default_opts() {
149        let dir = tempfile::tempdir().expect("tempdir");
150        let path = dir.path().join("empty.toml");
151        std::fs::write(&path, "transforms = []\n").expect("write");
152        let (pipeline, opts) = load_transforms_from_args(Some(&path)).expect("load");
153        // Empty list → identity stages, but ApplyOpts come from config defaults
154        // (batch_size 1000), not ApplyOpts::identity() (batch_size 1).
155        assert!(pipeline.is_identity());
156        assert_eq!(opts.batch_size, ApplyOpts::default().batch_size);
157        assert_ne!(opts.batch_size, ApplyOpts::identity().batch_size);
158        assert_eq!(opts, ApplyOpts::default());
159    }
160
161    #[test]
162    fn bad_toml_fails_fast() {
163        let dir = tempfile::tempdir().expect("tempdir");
164        let path = dir.path().join("bad.toml");
165        let mut f = std::fs::File::create(&path).expect("create");
166        writeln!(f, "this is not [[transforms]] toml {{{{").expect("write");
167        let err = load_transforms_from_args(Some(&path)).expect_err("bad TOML must fail");
168        let msg = format!("{err:#}");
169        assert!(
170            msg.contains("load --transforms-config"),
171            "expected load context in error, got: {msg}"
172        );
173    }
174
175    /// Thin wrapper check: bad persistent worker argv fails through the CLI
176    /// loader with the shared context string. Deeper spawn/resolvable coverage
177    /// lives in `pipeline` config tests.
178    #[test]
179    fn bad_persistent_worker_fails_fast_with_load_context() {
180        let dir = tempfile::tempdir().expect("tempdir");
181        let path = dir.path().join("bad-worker.toml");
182        std::fs::write(
183            &path,
184            r#"
185[[transforms]]
186type = "command"
187mode = "persistent"
188command = ["/nonexistent/surreal-sync-transform-worker-cli-xyz"]
189"#,
190        )
191        .expect("write");
192        let err = load_transforms_from_args(Some(&path)).expect_err("bad worker must fail");
193        let msg = format!("{err:#}");
194        assert!(
195            msg.contains("load --transforms-config"),
196            "expected load context in error, got: {msg}"
197        );
198    }
199
200    struct Tag;
201
202    impl InPlaceTransform for Tag {
203        fn transform(
204            &self,
205            _table: &str,
206            _id: &mut surreal_sync_core::Value,
207            fields: Option<&mut std::collections::HashMap<String, surreal_sync_core::Value>>,
208        ) -> anyhow::Result<()> {
209            if let Some(fields) = fields {
210                fields.insert("tagged".into(), surreal_sync_core::Value::Bool(true));
211            }
212            Ok(())
213        }
214    }
215
216    #[test]
217    fn merge_rust_only_upgrades_identity_opts() {
218        let (pipeline, opts) = merge_inplace_transforms(None, [Tag]).expect("merge rust-only");
219        assert!(!pipeline.is_identity());
220        assert_eq!(pipeline.len(), 1);
221        assert_eq!(
222            opts,
223            ApplyOpts::default(),
224            "Rust stages after omitted --transforms-config must upgrade off identity cadence"
225        );
226        assert_ne!(opts, ApplyOpts::identity());
227    }
228
229    #[test]
230    fn merge_toml_flatten_id_then_rust() {
231        let dir = tempfile::tempdir().expect("tempdir");
232        let path = dir.path().join("flatten.toml");
233        std::fs::write(
234            &path,
235            r#"
236[[transforms]]
237type = "flatten_id"
238"#,
239        )
240        .expect("write");
241        let (pipeline, opts) =
242            merge_inplace_transforms(Some(path.as_path()), [Tag]).expect("merge toml+rust");
243        assert!(!pipeline.is_identity());
244        assert_eq!(
245            pipeline.len(),
246            2,
247            "TOML flatten_id then Rust stage → 2 stages"
248        );
249        // TOML load already uses default opts; upgrade rule must not clobber.
250        assert_eq!(opts.batch_size, ApplyOpts::default().batch_size);
251        let _ = FlattenId::default();
252    }
253
254    #[test]
255    fn merge_no_extras_keeps_identity() {
256        let (pipeline, opts) =
257            merge_inplace_transforms(None, std::iter::empty::<Tag>()).expect("merge empty");
258        assert!(pipeline.is_identity());
259        assert_eq!(opts, ApplyOpts::identity());
260    }
261
262    #[test]
263    fn merge_boxed_heterogeneous() {
264        let (pipeline, opts) = merge_inplace_boxed(
265            None,
266            [
267                Box::new(FlattenId::default()) as Box<dyn InPlaceTransform>,
268                Box::new(Tag),
269            ],
270        )
271        .expect("boxed merge");
272        assert_eq!(pipeline.len(), 2);
273        assert_eq!(opts, ApplyOpts::default());
274    }
275}