Skip to main content

aimdb_core/session/aimx/
codec.rs

1//! AimX NDJSON envelope codec (`no_std + alloc`, features `connector-session`
2//! + `remote`).
3//!
4//! One JSON object per line, tagged by a `"t"` field, mapping onto the engine's
5//! role-neutral [`Inbound`]/[`Outbound`] message set. This is **not**
6//! backward-compatible with the legacy AimX wire:
7//!
8//! - `record.subscribe` is an [`Inbound::Subscribe`] keyed by the request `id`;
9//!   events carry the `id` back as [`Outbound::Event::sub`]. An explicit
10//!   `{"t":"subscribed"}` ack exists for servers running
11//!   `acks_subscribe:true` (the WebSocket connector); UDS/serial/TCP leave it
12//!   off and the ack stays implicit.
13//! - events carry `{sub, seq, data}` plus an optional `topic` naming the
14//!   concrete record on wildcard/bus subscriptions (no server-side
15//!   `timestamp`/`dropped`).
16//! - snapshots carry the opening subscription's `sub` so the client demux
17//!   routes them like events.
18//! - the Hello/Welcome handshake is a normal `call("hello", …)`, so
19//!   `authenticate` stays peer-only.
20//!
21//! The record-value `Payload` is spliced into / sliced out of the envelope
22//! verbatim via [`serde_json::value::RawValue`] — no intermediate `Value` tree,
23//! no re-escaping.
24
25use alloc::sync::Arc;
26
27use serde::{Deserialize, Serialize};
28use serde_json::value::RawValue;
29
30use crate::session::{CodecError, EnvelopeCodec, Inbound, Outbound, Payload, RpcError};
31
32/// The zero-sized AimX NDJSON codec.
33#[derive(Clone, Copy, Default)]
34pub struct AimxCodec;
35
36/// One wire frame. A single flat, all-optional struct (rather than an internally
37/// tagged enum, which cannot borrow) so both directions can zero-copy-borrow the
38/// `&str` / [`RawValue`] fields out of the frame slice. Each logical message
39/// fills the subset of fields its `"t"` tag implies; the rest skip-serialize.
40#[derive(Serialize, Deserialize)]
41struct Frame<'a> {
42    t: &'a str,
43    #[serde(default, skip_serializing_if = "Option::is_none")]
44    id: Option<u64>,
45    #[serde(default, skip_serializing_if = "Option::is_none")]
46    seq: Option<u64>,
47    /// Marks the final `snap` of a late-join burst; absent everywhere else.
48    #[serde(default, skip_serializing_if = "Option::is_none")]
49    last: Option<bool>,
50    #[serde(default, borrow, skip_serializing_if = "Option::is_none")]
51    method: Option<&'a str>,
52    #[serde(default, borrow, skip_serializing_if = "Option::is_none")]
53    topic: Option<&'a str>,
54    #[serde(default, borrow, skip_serializing_if = "Option::is_none")]
55    sub: Option<&'a str>,
56    #[serde(default, borrow, skip_serializing_if = "Option::is_none")]
57    params: Option<&'a RawValue>,
58    #[serde(default, borrow, skip_serializing_if = "Option::is_none")]
59    payload: Option<&'a RawValue>,
60    #[serde(default, borrow, skip_serializing_if = "Option::is_none")]
61    data: Option<&'a RawValue>,
62    #[serde(default, borrow, skip_serializing_if = "Option::is_none")]
63    ok: Option<&'a RawValue>,
64    #[serde(default, borrow, skip_serializing_if = "Option::is_none")]
65    err: Option<&'a str>,
66}
67
68impl<'a> Frame<'a> {
69    /// An empty frame with only the tag set; fill the relevant fields after.
70    fn tagged(t: &'a str) -> Self {
71        Self {
72            t,
73            id: None,
74            seq: None,
75            last: None,
76            method: None,
77            topic: None,
78            sub: None,
79            params: None,
80            payload: None,
81            data: None,
82            ok: None,
83            err: None,
84        }
85    }
86}
87
88/// Borrow a [`RawValue`] view over already-serialized payload bytes (validates
89/// the bytes are one JSON value, but does not re-serialize the structure).
90fn as_raw(bytes: &[u8]) -> Result<&RawValue, CodecError> {
91    serde_json::from_slice(bytes).map_err(|_| CodecError::Malformed)
92}
93
94/// Slice a [`RawValue`]'s verbatim JSON bytes back out into an owned [`Payload`];
95/// a missing field decodes as the JSON literal `null`.
96fn payload_of(raw: Option<&RawValue>) -> Payload {
97    match raw {
98        Some(r) => Arc::from(r.get().as_bytes()),
99        None => Arc::from(&b"null"[..]),
100    }
101}
102
103fn err_code(e: &RpcError) -> &'static str {
104    match e {
105        RpcError::NotFound => "not_found",
106        RpcError::Denied => "denied",
107        RpcError::Internal => "internal",
108        RpcError::VersionMismatch => "version_mismatch",
109    }
110}
111
112fn code_err(s: &str) -> RpcError {
113    match s {
114        "not_found" => RpcError::NotFound,
115        "denied" => RpcError::Denied,
116        "version_mismatch" => RpcError::VersionMismatch,
117        _ => RpcError::Internal,
118    }
119}
120
121fn write_frame(out: &mut alloc::vec::Vec<u8>, frame: &Frame<'_>) -> Result<(), CodecError> {
122    // `serde_json::to_writer` is gated behind serde_json's `std` feature (the
123    // workspace builds it on `alloc` only), so serialize via `to_vec` and splice.
124    let bytes = serde_json::to_vec(frame).map_err(|_| CodecError::Malformed)?;
125    out.extend_from_slice(&bytes);
126    Ok(())
127}
128
129/// Encode a frame whose `data` field is already-serialized JSON (a record
130/// serializer's output), splicing `data` verbatim instead of re-validating it
131/// into a [`RawValue`]. Outbound event payloads are trusted-valid, so `as_raw`'s
132/// O(payload) scan is redundant — and on a fan-out subscription it would run
133/// once *per subscriber* over the shared payload (the fan-out encode fast path;
134/// see `docs/design/048-fanout-event-encoding.md`).
135///
136/// `frame` must carry `data = None` so serde emits every other field; the
137/// spliced `data` is then appended as the final member. This is byte-identical
138/// to serializing the same frame with `data = Some(as_raw(payload))` **only
139/// while `data` is the last member serde emits** — i.e. every field declared
140/// after `data` in [`Frame`] (`ok`, `err`) also skip-serializes. That holds for
141/// the frames this path encodes (`Event` never sets `ok`/`err`), and the
142/// `debug_assert` below makes the precondition explicit rather than incidental:
143/// splicing a frame that set `ok`/`err` would emit `…,"ok":X,"data":Y}` with
144/// `data` no longer last, breaking the byte-identical claim.
145///
146/// # Contract (deliberately unchecked)
147///
148/// `data` **must** be exactly one well-formed JSON value. The splice does not
149/// validate it, that is the entire point of Improvement A and re-checking
150/// here (or per-subscriber) is the O(payload) cost this path exists to avoid.
151/// The invariant is upheld upstream, not defended here: `data` is a record
152/// serializer's output and every record reachable over an AimX transport
153/// serializes to JSON (the wire envelope *is* JSON). A caller that feeds
154/// non-JSON bytes — e.g. a non-JSON per-link codec (`linked_*_with`) on a
155/// `ws://` route, which is a JSON-envelope transport — violates the contract
156/// and produces a corrupt frame (empty `data` yields `…,"data":}`); such a
157/// codec/transport pairing is a misconfiguration, not an input to guard against.
158/// The `debug_assert` below is a dev-time tripwire for the `frame.data` half of
159/// the contract; there is intentionally no release-mode check on `data` itself.
160fn write_frame_splicing_data(
161    out: &mut alloc::vec::Vec<u8>,
162    frame: &Frame<'_>,
163    data: &[u8],
164) -> Result<(), CodecError> {
165    debug_assert!(frame.data.is_none(), "data must be spliced, not serialized");
166    debug_assert!(
167        frame.ok.is_none() && frame.err.is_none(),
168        "fields declared after `data` must be None so the spliced `data` stays last"
169    );
170    let scaffold = serde_json::to_vec(frame).map_err(|_| CodecError::Malformed)?;
171    // A `Frame` always serializes to a JSON object closed by `}`.
172    let Some((&b'}', head)) = scaffold.split_last() else {
173        return Err(CodecError::Malformed);
174    };
175    out.extend_from_slice(head);
176    out.extend_from_slice(br#","data":"#);
177    out.extend_from_slice(data);
178    out.push(b'}');
179    Ok(())
180}
181
182impl EnvelopeCodec for AimxCodec {
183    // --- server direction: read a request, write a reply/event -------------
184    fn decode(&self, frame: &[u8]) -> Result<Inbound, CodecError> {
185        let f: Frame = serde_json::from_slice(frame).map_err(|_| CodecError::Malformed)?;
186        match f.t {
187            "req" => Ok(Inbound::Request {
188                id: f.id.ok_or(CodecError::Malformed)?,
189                method: f.method.ok_or(CodecError::Malformed)?.into(),
190                params: payload_of(f.params),
191            }),
192            "sub" => Ok(Inbound::Subscribe {
193                id: f.id.ok_or(CodecError::Malformed)?,
194                topic: f.topic.ok_or(CodecError::Malformed)?.into(),
195            }),
196            "unsub" => Ok(Inbound::Unsubscribe {
197                sub: f.sub.ok_or(CodecError::Malformed)?.into(),
198            }),
199            "write" => Ok(Inbound::Write {
200                topic: f.topic.ok_or(CodecError::Malformed)?.into(),
201                payload: payload_of(f.payload),
202            }),
203            "ping" => Ok(Inbound::Ping),
204            _ => Err(CodecError::Malformed),
205        }
206    }
207
208    fn encode(&self, msg: Outbound<'_>, out: &mut alloc::vec::Vec<u8>) -> Result<(), CodecError> {
209        match msg {
210            Outbound::Reply { id, result } => {
211                let mut frame = Frame::tagged("reply");
212                frame.id = Some(id);
213                match &result {
214                    Ok(data) => {
215                        let raw = as_raw(data)?;
216                        frame.ok = Some(raw);
217                        write_frame(out, &frame)
218                    }
219                    Err(e) => {
220                        frame.err = Some(err_code(e));
221                        write_frame(out, &frame)
222                    }
223                }
224            }
225            Outbound::Event {
226                sub,
227                seq,
228                topic,
229                data,
230            } => {
231                let mut frame = Frame::tagged("event");
232                frame.sub = Some(sub);
233                frame.seq = Some(seq);
234                frame.topic = topic;
235                // `data` is spliced verbatim (Improvement A) — see
236                // `write_frame_splicing_data`.
237                write_frame_splicing_data(out, &frame, &data)
238            }
239            Outbound::Snapshot {
240                sub,
241                seq,
242                last,
243                topic,
244                data,
245            } => {
246                let raw = as_raw(&data)?;
247                let mut frame = Frame::tagged("snap");
248                frame.sub = Some(sub);
249                frame.seq = Some(seq);
250                // Only the burst's final frame carries it, so mid-burst frames
251                // stay byte-identical to before.
252                frame.last = last.then_some(true);
253                frame.topic = Some(topic);
254                frame.data = Some(raw);
255                write_frame(out, &frame)
256            }
257            Outbound::Pong => write_frame(out, &Frame::tagged("pong")),
258            // Explicit subscribe ack — only emitted by servers running
259            // `acks_subscribe:true` (the WebSocket connector).
260            Outbound::Subscribed { sub } => {
261                let mut frame = Frame::tagged("subscribed");
262                frame.sub = Some(sub);
263                write_frame(out, &frame)
264            }
265        }
266    }
267
268    // --- client direction: write a request, read a reply/event -------------
269    fn encode_inbound(
270        &self,
271        msg: Inbound,
272        out: &mut alloc::vec::Vec<u8>,
273    ) -> Result<(), CodecError> {
274        match msg {
275            Inbound::Request { id, method, params } => {
276                let raw = as_raw(&params)?;
277                let mut frame = Frame::tagged("req");
278                frame.id = Some(id);
279                frame.method = Some(&method);
280                frame.params = Some(raw);
281                write_frame(out, &frame)
282            }
283            Inbound::Subscribe { id, topic } => {
284                let mut frame = Frame::tagged("sub");
285                frame.id = Some(id);
286                frame.topic = Some(&topic);
287                write_frame(out, &frame)
288            }
289            Inbound::Unsubscribe { sub } => {
290                let mut frame = Frame::tagged("unsub");
291                frame.sub = Some(&sub);
292                write_frame(out, &frame)
293            }
294            Inbound::Write { topic, payload } => {
295                let raw = as_raw(&payload)?;
296                let mut frame = Frame::tagged("write");
297                frame.topic = Some(&topic);
298                frame.payload = Some(raw);
299                write_frame(out, &frame)
300            }
301            Inbound::Ping => write_frame(out, &Frame::tagged("ping")),
302        }
303    }
304
305    fn decode_outbound<'a>(&self, frame: &'a [u8]) -> Result<Outbound<'a>, CodecError> {
306        let f: Frame<'a> = serde_json::from_slice(frame).map_err(|_| CodecError::Malformed)?;
307        match f.t {
308            "reply" => {
309                let id = f.id.ok_or(CodecError::Malformed)?;
310                let result = match (f.ok, f.err) {
311                    (Some(_), _) => Ok(payload_of(f.ok)),
312                    (None, Some(code)) => Err(code_err(code)),
313                    (None, None) => return Err(CodecError::Malformed),
314                };
315                Ok(Outbound::Reply { id, result })
316            }
317            "event" => Ok(Outbound::Event {
318                sub: f.sub.ok_or(CodecError::Malformed)?,
319                seq: f.seq.ok_or(CodecError::Malformed)?,
320                topic: f.topic,
321                data: payload_of(f.data),
322            }),
323            "snap" => Ok(Outbound::Snapshot {
324                sub: f.sub.ok_or(CodecError::Malformed)?,
325                seq: f.seq.ok_or(CodecError::Malformed)?,
326                last: f.last.unwrap_or(false),
327                topic: f.topic.ok_or(CodecError::Malformed)?,
328                data: payload_of(f.data),
329            }),
330            "subscribed" => Ok(Outbound::Subscribed {
331                sub: f.sub.ok_or(CodecError::Malformed)?,
332            }),
333            "pong" => Ok(Outbound::Pong),
334            _ => Err(CodecError::Malformed),
335        }
336    }
337}
338
339#[cfg(test)]
340mod tests {
341    use super::*;
342    use alloc::string::ToString;
343    use alloc::vec::Vec;
344
345    fn payload(s: &str) -> Payload {
346        Arc::from(s.as_bytes())
347    }
348
349    /// Encode client-direction, decode server-direction (the request path).
350    fn roundtrip_inbound(msg: Inbound) -> Inbound {
351        let codec = AimxCodec;
352        let mut out = Vec::new();
353        codec.encode_inbound(msg, &mut out).expect("encode_inbound");
354        codec.decode(&out).expect("decode")
355    }
356
357    /// Encode server-direction, return the frame bytes (the reply/event path).
358    fn encode_outbound(msg: Outbound<'_>) -> Vec<u8> {
359        let codec = AimxCodec;
360        let mut out = Vec::new();
361        codec.encode(msg, &mut out).expect("encode");
362        out
363    }
364
365    #[test]
366    fn request_roundtrip() {
367        match roundtrip_inbound(Inbound::Request {
368            id: 7,
369            method: "record.get".to_string(),
370            params: payload(r#"{"name":"temp"}"#),
371        }) {
372            Inbound::Request { id, method, params } => {
373                assert_eq!(id, 7);
374                assert_eq!(method, "record.get");
375                assert_eq!(&params[..], br#"{"name":"temp"}"#);
376            }
377            _ => panic!("expected Request"),
378        }
379    }
380
381    #[test]
382    fn subscribe_and_unsubscribe_roundtrip() {
383        match roundtrip_inbound(Inbound::Subscribe {
384            id: 3,
385            topic: "sensors.#".to_string(),
386        }) {
387            Inbound::Subscribe { id, topic } => {
388                assert_eq!(id, 3);
389                assert_eq!(topic, "sensors.#");
390            }
391            _ => panic!("expected Subscribe"),
392        }
393        match roundtrip_inbound(Inbound::Unsubscribe {
394            sub: "3".to_string(),
395        }) {
396            Inbound::Unsubscribe { sub } => assert_eq!(sub, "3"),
397            _ => panic!("expected Unsubscribe"),
398        }
399    }
400
401    #[test]
402    fn write_roundtrip_splices_payload_verbatim() {
403        match roundtrip_inbound(Inbound::Write {
404            topic: "cfg".to_string(),
405            payload: payload(r#"{"value":{"level":9}}"#),
406        }) {
407            Inbound::Write { topic, payload } => {
408                assert_eq!(topic, "cfg");
409                assert_eq!(&payload[..], br#"{"value":{"level":9}}"#);
410            }
411            _ => panic!("expected Write"),
412        }
413    }
414
415    #[test]
416    fn ping_pong_roundtrip() {
417        assert!(matches!(roundtrip_inbound(Inbound::Ping), Inbound::Ping));
418        let frame = encode_outbound(Outbound::Pong);
419        assert!(matches!(
420            AimxCodec.decode_outbound(&frame).unwrap(),
421            Outbound::Pong
422        ));
423    }
424
425    #[test]
426    fn reply_ok_roundtrip() {
427        let frame = encode_outbound(Outbound::Reply {
428            id: 11,
429            result: Ok(payload(r#"{"status":"success"}"#)),
430        });
431        match AimxCodec.decode_outbound(&frame).unwrap() {
432            Outbound::Reply { id, result } => {
433                assert_eq!(id, 11);
434                assert_eq!(&result.unwrap()[..], br#"{"status":"success"}"#);
435            }
436            _ => panic!("expected Reply"),
437        }
438    }
439
440    #[test]
441    fn reply_err_codes_roundtrip() {
442        for err in [
443            RpcError::NotFound,
444            RpcError::Denied,
445            RpcError::Internal,
446            RpcError::VersionMismatch,
447        ] {
448            let frame = encode_outbound(Outbound::Reply {
449                id: 1,
450                result: Err(err.clone()),
451            });
452            match AimxCodec.decode_outbound(&frame).unwrap() {
453                Outbound::Reply { result, .. } => assert_eq!(result.unwrap_err(), err),
454                _ => panic!("expected Reply"),
455            }
456        }
457    }
458
459    #[test]
460    fn event_roundtrip_without_topic() {
461        let frame = encode_outbound(Outbound::Event {
462            sub: "5",
463            seq: 2,
464            topic: None,
465            data: payload("42"),
466        });
467        // The optional field skip-serializes — the exact-topic wire is unchanged.
468        assert!(!frame.windows(7).any(|w| w == b"\"topic\""));
469        match AimxCodec.decode_outbound(&frame).unwrap() {
470            Outbound::Event {
471                sub,
472                seq,
473                topic,
474                data,
475            } => {
476                assert_eq!((sub, seq, topic), ("5", 2, None));
477                assert_eq!(&data[..], b"42");
478            }
479            _ => panic!("expected Event"),
480        }
481    }
482
483    #[test]
484    fn event_roundtrip_with_topic() {
485        let frame = encode_outbound(Outbound::Event {
486            sub: "5",
487            seq: 9,
488            topic: Some("temp.vienna"),
489            data: payload(r#"{"c":21.5}"#),
490        });
491        match AimxCodec.decode_outbound(&frame).unwrap() {
492            Outbound::Event { sub, topic, .. } => {
493                assert_eq!(sub, "5");
494                assert_eq!(topic, Some("temp.vienna"));
495            }
496            _ => panic!("expected Event"),
497        }
498    }
499
500    /// The verbatim-splice event encode (Improvement A) must be byte-identical
501    /// to the previous `data = Some(as_raw(payload))` serde path — the record
502    /// payload is spliced unchanged as the final `data` member, in both the
503    /// with-topic and without-topic layouts.
504    #[test]
505    fn event_encode_splices_data_byte_for_byte() {
506        let with_topic = encode_outbound(Outbound::Event {
507            sub: "5",
508            seq: 9,
509            topic: Some("temp.vienna"),
510            data: payload(r#"{"c":21.5,"a":[1,2,3]}"#),
511        });
512        assert_eq!(
513            with_topic,
514            br#"{"t":"event","seq":9,"topic":"temp.vienna","sub":"5","data":{"c":21.5,"a":[1,2,3]}}"#
515        );
516
517        let without_topic = encode_outbound(Outbound::Event {
518            sub: "5",
519            seq: 2,
520            topic: None,
521            data: payload("42"),
522        });
523        assert_eq!(
524            without_topic,
525            br#"{"t":"event","seq":2,"sub":"5","data":42}"#
526        );
527    }
528
529    #[test]
530    fn snapshot_roundtrip_carries_sub_seq_and_topic() {
531        let frame = encode_outbound(Outbound::Snapshot {
532            sub: "8",
533            seq: 3,
534            last: false,
535            topic: "temp.berlin",
536            data: payload(r#"{"c":18.0}"#),
537        });
538        // A mid-burst snapshot omits `last` entirely.
539        assert_eq!(
540            frame,
541            br#"{"t":"snap","seq":3,"topic":"temp.berlin","sub":"8","data":{"c":18.0}}"#
542        );
543        match AimxCodec.decode_outbound(&frame).unwrap() {
544            Outbound::Snapshot {
545                sub,
546                seq,
547                last,
548                topic,
549                data,
550            } => {
551                assert_eq!((sub, seq, last, topic), ("8", 3, false, "temp.berlin"));
552                assert_eq!(&data[..], br#"{"c":18.0}"#);
553            }
554            _ => panic!("expected Snapshot"),
555        }
556    }
557
558    /// The burst's final snapshot is flagged, so the client can attach the
559    /// burst's loss total to an update it is guaranteed to deliver.
560    #[test]
561    fn last_snapshot_of_a_burst_is_flagged() {
562        let frame = encode_outbound(Outbound::Snapshot {
563            sub: "8",
564            seq: 4,
565            last: true,
566            topic: "temp.berlin",
567            data: payload("1"),
568        });
569        match AimxCodec.decode_outbound(&frame).unwrap() {
570            Outbound::Snapshot { seq, last, .. } => assert_eq!((seq, last), (4, true)),
571            _ => panic!("expected Snapshot"),
572        }
573    }
574
575    /// A `snap` frame without `seq` is not decodable: snapshots share the
576    /// subscription's sequence space, so an unnumbered one would silently
577    /// defeat the client's gap accounting.
578    #[test]
579    fn snapshot_without_seq_is_malformed() {
580        let frame = br#"{"t":"snap","sub":"8","topic":"temp.berlin","data":1}"#;
581        assert!(matches!(
582            AimxCodec.decode_outbound(frame),
583            Err(CodecError::Malformed)
584        ));
585    }
586
587    #[test]
588    fn subscribed_ack_roundtrip() {
589        let frame = encode_outbound(Outbound::Subscribed { sub: "13" });
590        match AimxCodec.decode_outbound(&frame).unwrap() {
591            Outbound::Subscribed { sub } => assert_eq!(sub, "13"),
592            _ => panic!("expected Subscribed"),
593        }
594    }
595
596    #[test]
597    fn malformed_frames_are_rejected() {
598        let codec = AimxCodec;
599        assert!(codec.decode(b"{not json").is_err());
600        assert!(codec.decode(br#"{"t":"nope"}"#).is_err());
601        // A `sub` frame missing its topic is malformed.
602        assert!(codec.decode(br#"{"t":"sub","id":1}"#).is_err());
603        assert!(codec.decode_outbound(br#"{"t":"reply","id":1}"#).is_err());
604        // A snap without its routing sub is malformed on the new wire.
605        assert!(codec
606            .decode_outbound(br#"{"t":"snap","topic":"x","data":1}"#)
607            .is_err());
608    }
609}