dataflow_rs/engine/message.rs
1use crate::engine::error::{DataflowError, ErrorInfo};
2use chrono::{DateTime, Utc};
3use datavalue::OwnedDataValue;
4use serde::{Deserialize, Serialize};
5use serde_json::Value as JsonValue;
6use std::sync::Arc;
7use uuid::Uuid;
8
9/// A message flowing through the dataflow engine.
10///
11/// Construct via [`Message::builder`] for the full API, or use the shortcuts
12/// [`Message::new`] (already-owned `Arc<OwnedDataValue>` payload — the perf
13/// path) and [`Message::from_value`] (bridges from `serde_json::Value`).
14///
15/// `context` is held as an [`OwnedDataValue`] tree (not `serde_json::Value`)
16/// so the JSONLogic evaluator can borrow it into its arena via
17/// `OwnedDataValue::to_arena` with a single deep walk in, and project the
18/// result back via `DataValue::to_owned` with a single deep walk out — no
19/// `serde_json::Value` round-trip in the hot path. The on-the-wire JSON
20/// shape is preserved by datavalue's native `Serialize` / `Deserialize`
21/// impls.
22///
23/// Every other field is encapsulated — read via `id()`, `payload()`,
24/// `audit_trail()`, `errors()`, `capture_changes()`; mutate `errors` via
25/// [`Message::add_error`]; mutate `context` via [`crate::TaskContext::set`].
26/// Direct mutation of `audit_trail` is engine-internal.
27/// Internal id storage for [`Message`]. The default UUID v7 id is written
28/// into a fixed inline buffer (36 hyphenated lowercase ASCII bytes) — no
29/// per-message heap allocation; a caller-supplied id keeps its `String`.
30/// Private repr: every public surface exposes it as `&str` via
31/// [`Message::id`], and the serialized wire shape (a JSON string) is
32/// unchanged.
33#[derive(Clone)]
34pub(crate) enum MessageId {
35 Uuid([u8; 36]),
36 Custom(String),
37}
38
39impl MessageId {
40 /// Generate a fresh UUID v7 id directly into the inline buffer.
41 fn new_uuid_v7() -> Self {
42 let mut buf = [0u8; 36];
43 Uuid::now_v7().hyphenated().encode_lower(&mut buf);
44 Self::Uuid(buf)
45 }
46
47 fn as_str(&self) -> &str {
48 match self {
49 // encode_lower fills all 36 bytes with hyphenated lowercase
50 // ASCII, so the buffer is always valid UTF-8.
51 Self::Uuid(buf) => std::str::from_utf8(buf).expect("uuid ids are ascii"),
52 Self::Custom(s) => s,
53 }
54 }
55}
56
57impl std::fmt::Debug for MessageId {
58 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
59 std::fmt::Debug::fmt(self.as_str(), f)
60 }
61}
62
63#[derive(Debug, Clone)]
64pub struct Message {
65 pub(crate) id: MessageId,
66 pub(crate) payload: Arc<OwnedDataValue>,
67 /// Unified context containing `data`, `metadata`, and `temp_data` keys.
68 /// Always an `OwnedDataValue::Object`; the engine populates the three
69 /// top-level keys at construction. Public for read access (tests do
70 /// `message.context["data"]["x"]` lookups); inside handlers prefer
71 /// [`crate::TaskContext::set`] which records audit-trail changes.
72 pub context: OwnedDataValue,
73 pub(crate) audit_trail: Vec<AuditTrail>,
74 /// Errors that occurred during message processing. Read via
75 /// `errors()`, append via `add_error()`.
76 pub(crate) errors: Vec<ErrorInfo>,
77 /// When `true` (default), built-in functions emit per-write `Change`
78 /// entries into `audit_trail`, capturing `old_value` and `new_value` deep
79 /// clones. When `false`, `AuditTrail` entries are still recorded
80 /// (workflow_id, task_id, status, timestamp) but `changes` is empty —
81 /// the bulk-pipeline fast path. UI debug consumers should leave this at
82 /// `true`. Wire shape is unchanged either way.
83 pub(crate) capture_changes: bool,
84 /// Routing bucket `0..=99` for a workflow traffic split
85 /// ([`crate::Workflow::rollout`]).
86 ///
87 /// Deliberately **not** stored in `context`, so it never appears in `data` /
88 /// `metadata` / `temp_data` and never has to be stripped before the context
89 /// is serialized — that is the point of the field.
90 ///
91 /// Like `capture_changes`, an in-memory hint: the hand-written `Serialize` /
92 /// `Deserialize` impls keep the 5-field wire shape, so the bucket does not
93 /// survive a JSON round trip.
94 pub(crate) routing_bucket: Option<u8>,
95}
96
97// Custom Serialize: stable wire format ({id, payload, context, audit_trail, errors}).
98// `capture_changes` is an in-memory hint only — never serialized.
99impl Serialize for Message {
100 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
101 where
102 S: serde::Serializer,
103 {
104 use serde::ser::SerializeStruct;
105 let mut state = serializer.serialize_struct("Message", 5)?;
106 state.serialize_field("id", &self.id.as_str())?;
107 state.serialize_field("payload", &self.payload)?;
108 state.serialize_field("context", &self.context)?;
109 state.serialize_field("audit_trail", &self.audit_trail)?;
110 state.serialize_field("errors", &self.errors)?;
111 state.end()
112 }
113}
114
115// Custom Deserialize: mirrors the Serialize shape; no cache field to seed.
116// `capture_changes` defaults to `true` for back-compat.
117impl<'de> Deserialize<'de> for Message {
118 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
119 where
120 D: serde::Deserializer<'de>,
121 {
122 #[derive(Deserialize)]
123 struct MessageData {
124 id: String,
125 payload: Arc<OwnedDataValue>,
126 context: OwnedDataValue,
127 audit_trail: Vec<AuditTrail>,
128 errors: Vec<ErrorInfo>,
129 }
130
131 let data = MessageData::deserialize(deserializer)?;
132 Ok(Self {
133 id: MessageId::Custom(data.id),
134 payload: data.payload,
135 context: data.context,
136 audit_trail: data.audit_trail,
137 errors: data.errors,
138 capture_changes: true,
139 routing_bucket: None,
140 })
141 }
142}
143
144impl Message {
145 /// Start building a message. The recommended constructor — chains
146 /// `.id(...)`, `.payload(...)` / `.payload_json(...)`, and
147 /// `.capture_changes(...)` calls, then `.build()`.
148 pub fn builder() -> MessageBuilder {
149 MessageBuilder::new()
150 }
151
152 /// Construct a message from an already-owned payload `Arc`. The perf
153 /// path: zero `serde_json::Value` walk, one Arc refcount bump per
154 /// message. Use this from a hot loop with a payload `Arc` shared across
155 /// messages (e.g. a benchmark harness or an HTTP handler that receives
156 /// already-parsed payloads).
157 pub fn new(payload: Arc<OwnedDataValue>) -> Self {
158 Self {
159 // UUID v7: ms-precision timestamp in the high bits, random tail.
160 // Time-ordered and sortable — better for databases/logs than v4
161 // (random-only) and the same `rng` backend cost. Encoded into the
162 // inline id buffer — no `String` allocation per message.
163 id: MessageId::new_uuid_v7(),
164 payload,
165 context: empty_context(),
166 audit_trail: vec![],
167 errors: vec![],
168 capture_changes: true,
169 routing_bucket: None,
170 }
171 }
172
173 /// Construct a message from a `serde_json::Value` payload. Convenience
174 /// for code that already speaks serde_json; goes through the
175 /// `OwnedDataValue::from(&Value)` bridge (one deep walk).
176 pub fn from_value(payload: &JsonValue) -> Self {
177 Self::new(Arc::new(OwnedDataValue::from(payload)))
178 }
179
180 /// Construct a message from a JSON payload string. Parses with
181 /// `serde_json` and bridges into `OwnedDataValue`. Returns
182 /// `DataflowError::Deserialization` on parse failure.
183 pub fn from_json_str(payload: &str) -> crate::engine::error::Result<Self> {
184 let value: JsonValue = serde_json::from_str(payload).map_err(DataflowError::from_serde)?;
185 Ok(Self::from_value(&value))
186 }
187
188 /// Add an error to the message
189 pub fn add_error(&mut self, error: ErrorInfo) {
190 self.errors.push(error);
191 }
192
193 /// Check if message has errors
194 pub fn has_errors(&self) -> bool {
195 !self.errors.is_empty()
196 }
197
198 /// Message id (UUID v7 string by default; caller-supplied if set via
199 /// [`MessageBuilder::id`]).
200 #[inline]
201 pub fn id(&self) -> &str {
202 self.id.as_str()
203 }
204
205 /// Original payload as the engine received it. Immutable for the
206 /// lifetime of the message — the engine reads it through this Arc and
207 /// copies into `context` only as needed by handlers.
208 #[inline]
209 pub fn payload(&self) -> &OwnedDataValue {
210 &self.payload
211 }
212
213 /// The shared payload `Arc` itself. Useful when forwarding the same
214 /// payload to multiple messages without recloning the underlying
215 /// `OwnedDataValue` tree.
216 #[inline]
217 pub fn payload_arc(&self) -> &Arc<OwnedDataValue> {
218 &self.payload
219 }
220
221 /// Audit-trail entries recorded by the engine, one per task that ran
222 /// (skipped tasks are absent unless `Trace` mode is on).
223 #[inline]
224 pub fn audit_trail(&self) -> &[AuditTrail] {
225 &self.audit_trail
226 }
227
228 /// Errors collected while processing — both validation failures and
229 /// task errors that the workflow swallowed via `continue_on_error`.
230 #[inline]
231 pub fn errors(&self) -> &[ErrorInfo] {
232 &self.errors
233 }
234
235 /// Whether per-write `Change` capture is on. When `false`, audit-trail
236 /// entries are still emitted but their `changes` lists are empty —
237 /// the bulk-pipeline fast path.
238 ///
239 /// Set it with [`MessageBuilder::capture_changes`], which documents the
240 /// cost — including the memory a looping workflow accumulates.
241 #[inline]
242 pub fn capture_changes(&self) -> bool {
243 self.capture_changes
244 }
245
246 /// Routing bucket for the traffic-split gate, if one was set.
247 ///
248 /// `None` means every workflow admits this message regardless of its
249 /// [`crate::Workflow::rollout`].
250 #[inline]
251 pub fn routing_bucket(&self) -> Option<u8> {
252 self.routing_bucket
253 }
254
255 /// Get a reference to the `data` field in context. Returns
256 /// `&OwnedDataValue::Null` if missing (matches `serde_json::Value`'s
257 /// `Index` fallback semantics).
258 pub fn data(&self) -> &OwnedDataValue {
259 &self.context["data"]
260 }
261
262 /// Get a reference to the `metadata` field in context.
263 pub fn metadata(&self) -> &OwnedDataValue {
264 &self.context["metadata"]
265 }
266
267 /// Get a reference to the `temp_data` field in context.
268 pub fn temp_data(&self) -> &OwnedDataValue {
269 &self.context["temp_data"]
270 }
271}
272
273/// Builder for [`Message`]. Collapses the historical
274/// `new` / `with_id` / `from_value` / `without_change_capture` four-way
275/// constructor split into a single fluent shape.
276///
277/// ```
278/// use dataflow_rs::Message;
279/// use serde_json::json;
280///
281/// // Minimal: serde_json payload, default UUID id, capture on.
282/// let m = Message::builder()
283/// .payload_json(&json!({"order": {"total": 1500}}))
284/// .build();
285/// assert!(m.id().len() > 0);
286/// assert!(m.capture_changes());
287/// ```
288#[must_use = "MessageBuilder must be `.build()` to produce a Message"]
289#[derive(Default)]
290pub struct MessageBuilder {
291 id: Option<String>,
292 payload: Option<Arc<OwnedDataValue>>,
293 capture_changes: Option<bool>,
294 data: Option<OwnedDataValue>,
295 metadata: Option<OwnedDataValue>,
296 temp_data: Option<OwnedDataValue>,
297 routing_bucket: Option<u8>,
298}
299
300impl MessageBuilder {
301 /// Create an empty builder. Equivalent to [`MessageBuilder::default`].
302 pub fn new() -> Self {
303 Self::default()
304 }
305
306 /// Caller-supplied id (typically a correlation id from upstream).
307 /// Defaults to a freshly-generated UUID v7.
308 pub fn id(mut self, id: impl Into<String>) -> Self {
309 self.id = Some(id.into());
310 self
311 }
312
313 /// Already-owned payload `Arc` — zero serde_json walk, refcount-only
314 /// share. Mutually exclusive with [`Self::payload_json`]; whichever is
315 /// called last wins.
316 pub fn payload(mut self, payload: Arc<OwnedDataValue>) -> Self {
317 self.payload = Some(payload);
318 self
319 }
320
321 /// Construct the payload from a `serde_json::Value`. Goes through the
322 /// `OwnedDataValue::from(&Value)` bridge (one deep walk).
323 pub fn payload_json(mut self, payload: &JsonValue) -> Self {
324 self.payload = Some(Arc::new(OwnedDataValue::from(payload)));
325 self
326 }
327
328 /// Seed `context.data`.
329 ///
330 /// Replaces the empty `Object` that [`Self::build`] would otherwise install;
331 /// the other two root fields are unaffected. Seeding records no audit-trail
332 /// entry and no `Change` — it is initial state, not a mutation.
333 ///
334 /// Keys are taken **literally**: unlike
335 /// [`crate::engine::utils::set_nested_value`], a key containing `.` stays a
336 /// single key and a leading `#` is not stripped.
337 ///
338 /// A non-`Object` value is **ignored**, preserving the crate-wide invariant
339 /// that the three root fields are always objects. Calling this twice keeps
340 /// the last value.
341 ///
342 /// ```
343 /// use dataflow_rs::Message;
344 /// use serde_json::json;
345 ///
346 /// let m = Message::builder().data_json(&json!({"order": {"total": 1500}})).build();
347 /// assert_eq!(m.data()["order"]["total"], json!(1500).into());
348 /// ```
349 pub fn data(mut self, data: OwnedDataValue) -> Self {
350 if data.is_object() {
351 self.data = Some(data);
352 }
353 self
354 }
355
356 /// [`Self::data`] from a `serde_json::Value` (one `OwnedDataValue::from`
357 /// deep walk).
358 pub fn data_json(self, data: &JsonValue) -> Self {
359 self.data(OwnedDataValue::from(data))
360 }
361
362 /// Seed `context.metadata` — request headers, correlation ids, routing
363 /// hints.
364 ///
365 /// `Engine::process_message` adds `processed_at` and `engine_version` on top
366 /// of whatever is seeded here; a seeded `channel` key is overwritten by
367 /// `process_message_for_channel`. Same literal-key and non-object rules as
368 /// [`Self::data`].
369 ///
370 /// ```
371 /// use dataflow_rs::Message;
372 /// use serde_json::json;
373 ///
374 /// let m = Message::builder().metadata_json(&json!({"source": "api"})).build();
375 /// assert_eq!(m.metadata()["source"], json!("api").into());
376 /// ```
377 pub fn metadata(mut self, metadata: OwnedDataValue) -> Self {
378 if metadata.is_object() {
379 self.metadata = Some(metadata);
380 }
381 self
382 }
383
384 /// [`Self::metadata`] from a `serde_json::Value`.
385 pub fn metadata_json(self, metadata: &JsonValue) -> Self {
386 self.metadata(OwnedDataValue::from(metadata))
387 }
388
389 /// Seed `context.temp_data` — scratch space for intermediate task output.
390 ///
391 /// Same literal-key and non-object rules as [`Self::data`].
392 ///
393 /// ```
394 /// use dataflow_rs::Message;
395 /// use serde_json::json;
396 ///
397 /// let m = Message::builder().temp_data_json(&json!({"scratch": 1})).build();
398 /// assert_eq!(m.temp_data()["scratch"], json!(1).into());
399 /// ```
400 pub fn temp_data(mut self, temp_data: OwnedDataValue) -> Self {
401 if temp_data.is_object() {
402 self.temp_data = Some(temp_data);
403 }
404 self
405 }
406
407 /// [`Self::temp_data`] from a `serde_json::Value`.
408 pub fn temp_data_json(self, temp_data: &JsonValue) -> Self {
409 self.temp_data(OwnedDataValue::from(temp_data))
410 }
411
412 /// Routing bucket `0..=99` for [`crate::Workflow::rollout`] matching.
413 ///
414 /// Values `>= 100` are clamped to `99`, keeping the builder infallible like
415 /// the rest of its methods. A message with no bucket is admitted by every
416 /// workflow, split or not.
417 ///
418 /// ```
419 /// use dataflow_rs::Message;
420 ///
421 /// assert_eq!(Message::builder().routing_bucket(7).build().routing_bucket(), Some(7));
422 /// assert_eq!(Message::builder().routing_bucket(200).build().routing_bucket(), Some(99));
423 /// assert_eq!(Message::builder().build().routing_bucket(), None);
424 /// ```
425 pub fn routing_bucket(mut self, bucket: u8) -> Self {
426 self.routing_bucket = Some(bucket.min(99));
427 self
428 }
429
430 /// When `false`, built-in functions and `TaskContext::set` skip per-write
431 /// `Change` capture: audit-trail entries are still recorded, with an empty
432 /// `changes` list. Defaults to `true`.
433 ///
434 /// Capture has two costs. Each write deep-copies its old and new value
435 /// (throughput), and the copies stay on the message until
436 /// `process_message` returns (memory). In a looping workflow the second
437 /// one grows with every sweep — see
438 /// [`LoopConfig`](crate::engine::workflow::LoopConfig). Turn it off unless
439 /// you read [`AuditTrail::changes`](crate::AuditTrail::changes), or trace
440 /// with `TraceOptions { changes: true }`: that option reports the captured
441 /// diff and does not turn capture on.
442 ///
443 /// ```
444 /// use dataflow_rs::Message;
445 ///
446 /// let m = Message::builder().capture_changes(false).build();
447 /// assert!(!m.capture_changes());
448 /// ```
449 pub fn capture_changes(mut self, on: bool) -> Self {
450 self.capture_changes = Some(on);
451 self
452 }
453
454 /// Finalize. Defaults: id = UUID v7, payload = `OwnedDataValue::Null`,
455 /// capture_changes = `true`.
456 pub fn build(self) -> Message {
457 Message {
458 id: self
459 .id
460 .map(MessageId::Custom)
461 .unwrap_or_else(MessageId::new_uuid_v7),
462 payload: self
463 .payload
464 .unwrap_or_else(|| Arc::new(OwnedDataValue::Null)),
465 context: context_from(self.data, self.metadata, self.temp_data),
466 audit_trail: vec![],
467 errors: vec![],
468 capture_changes: self.capture_changes.unwrap_or(true),
469 routing_bucket: self.routing_bucket,
470 }
471 }
472}
473
474/// Build the canonical context object, substituting any caller-seeded root field
475/// for the empty `Object`.
476///
477/// Key order is fixed at `data, metadata, temp_data` so the serialized shape is
478/// identical whether or not a seed was supplied.
479fn context_from(
480 data: Option<OwnedDataValue>,
481 metadata: Option<OwnedDataValue>,
482 temp_data: Option<OwnedDataValue>,
483) -> OwnedDataValue {
484 fn slot(v: Option<OwnedDataValue>) -> OwnedDataValue {
485 v.unwrap_or_else(|| OwnedDataValue::Object(Vec::new()))
486 }
487 OwnedDataValue::Object(vec![
488 ("data".to_string(), slot(data)),
489 ("metadata".to_string(), slot(metadata)),
490 ("temp_data".to_string(), slot(temp_data)),
491 ])
492}
493
494/// Build the canonical empty context shape used by `Message::new`. One
495/// implementation of the shape, shared with the seeded path.
496fn empty_context() -> OwnedDataValue {
497 context_from(None, None, None)
498}
499
500#[derive(Serialize, Deserialize, Debug, Clone)]
501pub struct AuditTrail {
502 pub workflow_id: Arc<str>,
503 pub task_id: Arc<str>,
504 pub timestamp: DateTime<Utc>,
505 pub changes: Vec<Change>,
506 pub status: usize,
507 /// Loop counter value for the sweep that produced this entry, for
508 /// workflows carrying a [`crate::engine::workflow::LoopConfig`]; `None`
509 /// otherwise.
510 ///
511 /// The counter rather than a sweep ordinal: `increment >= 1` makes it
512 /// strictly increasing, so it identifies the iteration *and* carries the
513 /// business meaning — in the per-item pattern it is the array index this
514 /// entry refers to. Recorded even when the loop leaves its counter
515 /// unnamed, since the engine tracks the value either way.
516 ///
517 /// Skipped when `None`, so a non-looping workflow's audit JSON is
518 /// byte-identical to what it was before loops existed.
519 #[serde(default, skip_serializing_if = "Option::is_none")]
520 pub loop_counter: Option<i64>,
521 /// Index of the element this entry records, for a task carrying a
522 /// [`crate::engine::for_each::ForEach`]; `None` otherwise. A fan-out
523 /// records one entry per element, in element order.
524 ///
525 /// Also `None` on the single entry an empty or unevaluable `over`
526 /// records, since no element was involved. Skipped when `None`, so an
527 /// ordinary task's audit JSON is unchanged.
528 #[serde(default, skip_serializing_if = "Option::is_none")]
529 pub element_index: Option<usize>,
530}
531
532/// A single recorded mutation in the audit trail.
533///
534/// `old_value` and `new_value` are owned `OwnedDataValue`s rather than
535/// `Arc<OwnedDataValue>` — eliminates one heap allocation per Change on the
536/// hot path. External consumers that need to share a `Change` across threads
537/// can wrap it themselves; in-process pipelines (audit-on map mappings) don't
538/// pay the Arc cost they were never going to use.
539///
540/// A path that was absent before the write records `old_value` as `null`.
541#[derive(Serialize, Deserialize, Debug, Clone)]
542pub struct Change {
543 pub path: Arc<str>,
544 pub old_value: OwnedDataValue,
545 /// The value written. `null` when [`Self::removed`] is set — read that
546 /// flag rather than this value to tell a removal from a write.
547 pub new_value: OwnedDataValue,
548 /// The key at `path` was removed rather than written — a `map` mapping
549 /// with `unset`, or `on_null: "unset"` meeting a null result. `new_value`
550 /// is then `null`, which alone could not tell "now absent" from "now
551 /// holds null".
552 ///
553 /// Skipped when `false`, so every write's audit JSON is byte-identical to
554 /// what it was before removals existed, and a reader that predates the
555 /// field still loads a removal (as a write of `null`).
556 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
557 pub removed: bool,
558}
559
560#[cfg(test)]
561mod tests {
562 use super::*;
563
564 #[test]
565 fn capture_changes_defaults_to_true_on_every_constructor() {
566 // Kept `true` deliberately (#63): the browser debugger, `TraceOptions
567 // { changes: true }` and hosts reading `AuditTrail::changes` all rely
568 // on it. Changing it is a behaviour change for all of them — read the
569 // CLAUDE.md note before touching this.
570 assert!(Message::builder().build().capture_changes());
571 assert!(Message::new(Arc::new(OwnedDataValue::Null)).capture_changes());
572 assert!(Message::from_value(&serde_json::json!({})).capture_changes());
573 let json = serde_json::to_value(Message::builder().build()).expect("serializes");
574 let back: Message = serde_json::from_value(json).expect("deserializes");
575 assert!(
576 back.capture_changes(),
577 "not serialized, so deserializing restores the default"
578 );
579 }
580
581 #[test]
582 fn audit_trail_loop_counter_is_absent_from_json_when_none() {
583 // A non-looping workflow must keep the historical wire shape byte for
584 // byte — dataflow-ui and any stored audit JSON depend on it.
585 let entry = AuditTrail {
586 workflow_id: Arc::from("w"),
587 task_id: Arc::from("t"),
588 timestamp: Utc::now(),
589 changes: vec![],
590 status: 200,
591 loop_counter: None,
592 element_index: None,
593 };
594 let json = serde_json::to_value(&entry).expect("should serialize");
595 assert!(json.get("loop_counter").is_none());
596
597 let with_counter = AuditTrail {
598 loop_counter: Some(7),
599 element_index: None,
600 ..entry
601 };
602 assert_eq!(
603 serde_json::to_value(&with_counter).expect("should serialize")["loop_counter"],
604 serde_json::json!(7)
605 );
606 }
607
608 #[test]
609 fn audit_trail_element_index_is_absent_from_json_when_none() {
610 let entry = AuditTrail {
611 workflow_id: Arc::from("w"),
612 task_id: Arc::from("t"),
613 timestamp: Utc::now(),
614 changes: vec![],
615 status: 200,
616 loop_counter: None,
617 element_index: None,
618 };
619 let json = serde_json::to_value(&entry).expect("should serialize");
620 assert!(
621 json.get("element_index").is_none(),
622 "an ordinary task keeps its wire shape"
623 );
624
625 // A reader that predates the field loads an entry without it.
626 let back: AuditTrail = serde_json::from_value(json).expect("should deserialize");
627 assert_eq!(back.element_index, None);
628
629 let fanned = AuditTrail {
630 element_index: Some(4),
631 ..entry
632 };
633 assert_eq!(
634 serde_json::to_value(&fanned).expect("should serialize")["element_index"],
635 serde_json::json!(4)
636 );
637 }
638
639 #[test]
640 fn a_write_change_keeps_its_historical_wire_shape() {
641 // `removed` is skipped when false, so writes serialize byte for byte as
642 // they did before removals existed.
643 let change = Change {
644 path: Arc::from("data.x"),
645 old_value: OwnedDataValue::Null,
646 new_value: OwnedDataValue::from_i64(1),
647 removed: false,
648 };
649 assert_eq!(
650 serde_json::to_value(&change).unwrap(),
651 serde_json::json!({"path": "data.x", "old_value": null, "new_value": 1})
652 );
653
654 // And a change written before the flag existed reads back as a write.
655 let legacy: Change = serde_json::from_value(
656 serde_json::json!({"path": "data.x", "old_value": null, "new_value": 1}),
657 )
658 .unwrap();
659 assert!(!legacy.removed);
660 }
661
662 #[test]
663 fn audit_trail_without_a_loop_counter_key_deserializes() {
664 // Audit JSON written before loops existed must still round-trip.
665 let entry: AuditTrail = serde_json::from_value(serde_json::json!({
666 "workflow_id": "w",
667 "task_id": "t",
668 "timestamp": "2026-08-11T00:00:00Z",
669 "changes": [],
670 "status": 200
671 }))
672 .expect("legacy audit JSON should deserialize");
673 assert_eq!(entry.loop_counter, None);
674 }
675
676 #[test]
677 fn from_json_str_parses_valid_payload() {
678 let msg =
679 Message::from_json_str(r#"{"order": {"total": 42}}"#).expect("valid JSON should parse");
680 let payload_json = serde_json::to_value(msg.payload()).unwrap();
681 assert_eq!(payload_json, serde_json::json!({"order": {"total": 42}}));
682 }
683
684 #[test]
685 fn from_json_str_rejects_malformed_payload() {
686 let err = Message::from_json_str("{ not json").expect_err("malformed input should fail");
687 assert!(matches!(err, DataflowError::Deserialization(_)));
688 }
689
690 #[test]
691 fn builder_with_no_seed_matches_the_historical_empty_context() {
692 let m = Message::builder().build();
693 let v = serde_json::to_value(&m).unwrap();
694 // Key order is part of the shape, so assert on the serialized object.
695 let ctx = v["context"].as_object().unwrap();
696 assert_eq!(
697 ctx.keys().collect::<Vec<_>>(),
698 vec!["data", "metadata", "temp_data"]
699 );
700 assert_eq!(
701 v["context"],
702 serde_json::json!({
703 "data": {}, "metadata": {}, "temp_data": {}
704 })
705 );
706 }
707
708 #[test]
709 fn each_setter_lands_in_its_own_root_field() {
710 let m = Message::builder()
711 .data_json(&serde_json::json!({"d": 1}))
712 .build();
713 assert_eq!(
714 serde_json::Value::from(m.data()),
715 serde_json::json!({"d": 1})
716 );
717 assert_eq!(serde_json::Value::from(m.metadata()), serde_json::json!({}));
718 assert_eq!(
719 serde_json::Value::from(m.temp_data()),
720 serde_json::json!({})
721 );
722
723 let m = Message::builder()
724 .metadata_json(&serde_json::json!({"m": 1}))
725 .build();
726 assert_eq!(
727 serde_json::Value::from(m.metadata()),
728 serde_json::json!({"m": 1})
729 );
730 assert_eq!(serde_json::Value::from(m.data()), serde_json::json!({}));
731
732 let m = Message::builder()
733 .temp_data_json(&serde_json::json!({"t": 1}))
734 .build();
735 assert_eq!(
736 serde_json::Value::from(m.temp_data()),
737 serde_json::json!({"t": 1})
738 );
739 assert_eq!(serde_json::Value::from(m.data()), serde_json::json!({}));
740 }
741
742 #[test]
743 fn the_owned_and_json_setter_forms_agree() {
744 let v = serde_json::json!({"a": {"b": [1, 2]}});
745 let via_json = Message::builder().data_json(&v).build();
746 let via_owned = Message::builder().data(OwnedDataValue::from(&v)).build();
747 assert_eq!(via_json.context, via_owned.context);
748 }
749
750 #[test]
751 fn seeding_records_no_audit_entry_or_change() {
752 for capture in [true, false] {
753 let m = Message::builder()
754 .capture_changes(capture)
755 .data_json(&serde_json::json!({"d": 1}))
756 .build();
757 assert!(
758 m.audit_trail().is_empty(),
759 "seeding is initial state, not a mutation"
760 );
761 assert_eq!(
762 serde_json::Value::from(m.data()),
763 serde_json::json!({"d": 1})
764 );
765 }
766 }
767
768 #[test]
769 fn calling_a_setter_twice_keeps_the_last_value() {
770 let m = Message::builder()
771 .data_json(&serde_json::json!({"first": 1}))
772 .data_json(&serde_json::json!({"second": 2}))
773 .build();
774 assert_eq!(
775 serde_json::Value::from(m.data()),
776 serde_json::json!({"second": 2})
777 );
778 }
779
780 #[test]
781 fn an_empty_object_seed_is_indistinguishable_from_no_seed() {
782 let seeded = Message::builder().data_json(&serde_json::json!({})).build();
783 let bare = Message::builder().build();
784 assert_eq!(seeded.context, bare.context);
785 }
786
787 #[test]
788 fn seed_keys_are_literal_not_paths() {
789 use crate::engine::utils::get_nested_value;
790
791 let m = Message::builder()
792 .metadata_json(&serde_json::json!({"a.b": 1}))
793 .build();
794 // One literal key, not nested.
795 assert_eq!(
796 serde_json::Value::from(m.metadata()),
797 serde_json::json!({"a.b": 1})
798 );
799 assert!(
800 get_nested_value(&m.context, "metadata.a.b").is_none(),
801 "a dotted key must not become a nested path"
802 );
803
804 // Leading `#` is not stripped.
805 let m = Message::builder()
806 .metadata_json(&serde_json::json!({"#20": 1}))
807 .build();
808 assert_eq!(
809 serde_json::Value::from(m.metadata()),
810 serde_json::json!({"#20": 1})
811 );
812
813 // A numeric-string key stays an object key, not an Array.
814 let m = Message::builder()
815 .data_json(&serde_json::json!({"0": 1}))
816 .build();
817 assert!(matches!(m.data(), OwnedDataValue::Object(_)));
818 assert_eq!(
819 serde_json::Value::from(m.data()),
820 serde_json::json!({"0": 1})
821 );
822 }
823
824 #[test]
825 fn non_ascii_keys_and_values_round_trip() {
826 let v = serde_json::json!({"régión": "東京"});
827 let m = Message::builder().data_json(&v).build();
828 assert_eq!(serde_json::Value::from(m.data()), v);
829 assert_eq!(serde_json::to_value(&m).unwrap()["context"]["data"], v);
830 }
831
832 #[test]
833 fn nested_seeds_resolve_through_the_path_api() {
834 use crate::engine::utils::get_nested_value;
835
836 let m = Message::builder()
837 .data_json(&serde_json::json!({"order": {"items": [1, 2]}}))
838 .build();
839 assert_eq!(
840 get_nested_value(&m.context, "data.order.items.1"),
841 Some(&OwnedDataValue::from(&serde_json::json!(2)))
842 );
843 }
844
845 #[test]
846 fn non_object_seeds_are_ignored_to_preserve_the_context_invariant() {
847 // The recorded decision: the three root fields are always Objects.
848 // Storing a scalar verbatim would make `set_processing_metadata` bail out
849 // and silently drop `processed_at` / `engine_version`.
850 for bad in [
851 serde_json::json!("scalar"),
852 serde_json::json!([1, 2]),
853 serde_json::json!(null),
854 serde_json::json!(7),
855 ] {
856 let m = Message::builder()
857 .data_json(&bad)
858 .metadata_json(&bad)
859 .temp_data_json(&bad)
860 .build();
861 assert_eq!(
862 serde_json::Value::from(&m.context),
863 serde_json::json!({"data": {}, "metadata": {}, "temp_data": {}}),
864 "non-object seed {bad} must be ignored"
865 );
866 }
867 }
868}