surreal_sync_runtime/
transforms.rs1use anyhow::Context;
9use std::path::Path;
10use surreal_sync_core::InPlaceTransform;
11
12use crate::pipeline::{load_pipeline_and_opts, ApplyOpts, Pipeline};
13
14pub 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
64pub 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
87pub 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 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 #[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 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}