Skip to main content

ssp2/
realtime.rs

1//! Realtime JSON control messages (SPEC.md §8). Server→client messages use
2//! `{"event": …, "data": …}`; the client ack uses `{"type":"ack", …}` (§8.2).
3//! Unknown events are tolerated and preserved verbatim (§8.1 forward-compat
4//! mirror of the frame-skip rule).
5
6use serde_json::{Map, Value};
7
8use crate::error::{DecodeError, Result};
9use crate::primitives::I64_SAFE_MAX;
10
11/// §8.3 wake-up reason codes — the closed three-value set.
12#[derive(Debug, Clone, Copy, PartialEq, Eq)]
13pub enum WakeReason {
14    DeltaTooLarge,
15    CatchupRequired,
16    ResetRequired,
17}
18
19impl WakeReason {
20    pub fn as_str(self) -> &'static str {
21        match self {
22            WakeReason::DeltaTooLarge => "delta-too-large",
23            WakeReason::CatchupRequired => "catchup-required",
24            WakeReason::ResetRequired => "reset-required",
25        }
26    }
27
28    pub fn from_reason(s: &str) -> Option<Self> {
29        match s {
30            "delta-too-large" => Some(WakeReason::DeltaTooLarge),
31            "catchup-required" => Some(WakeReason::CatchupRequired),
32            "reset-required" => Some(WakeReason::ResetRequired),
33            _ => None,
34        }
35    }
36}
37
38#[derive(Debug, Clone, PartialEq)]
39pub enum ControlMessage {
40    /// §8.1 connect handshake.
41    Hello {
42        protocol_version: i64,
43        session_id: String,
44        actor_id: String,
45        client_id: String,
46        cursor: i64,
47        latest_cursor: i64,
48        requires_sync: bool,
49        timestamp: i64,
50    },
51    /// §8.3 wake-up (`event = "sync"`).
52    Wake {
53        cursor: i64,
54        requires_pull: bool,
55        reason: WakeReason,
56        timestamp: i64,
57    },
58    /// §8.5 heartbeat.
59    Heartbeat { timestamp: i64 },
60    /// §8.2 client ack — the sole client→server control message.
61    Ack { cursor: i64 },
62    /// §8.6.2 presence fanout (server→client) or a publisher-directed error.
63    Presence {
64        scope_key: String,
65        /// `join` | `update` | `leave` for a fanout; `None` for an error.
66        kind: Option<PresenceKind>,
67        actor_id: Option<String>,
68        client_id: Option<String>,
69        /// The peer's document for join/update; `None`/null for leave.
70        doc: Option<Value>,
71        /// `presence.too_large` / `presence.forbidden` (§8.6.2), if this is
72        /// a publisher-directed error variant.
73        error: Option<String>,
74        timestamp: Option<i64>,
75    },
76    /// Unknown JSON control event, tolerated and preserved (§8.1).
77    Unknown(Value),
78}
79
80/// §8.6.2 presence fanout kind — a closed set of three.
81#[derive(Debug, Clone, Copy, PartialEq, Eq)]
82pub enum PresenceKind {
83    Join,
84    Update,
85    Leave,
86}
87
88impl PresenceKind {
89    fn from_str(s: &str) -> Option<Self> {
90        match s {
91            "join" => Some(Self::Join),
92            "update" => Some(Self::Update),
93            "leave" => Some(Self::Leave),
94            _ => None,
95        }
96    }
97
98    pub fn as_str(self) -> &'static str {
99        match self {
100            Self::Join => "join",
101            Self::Update => "update",
102            Self::Leave => "leave",
103        }
104    }
105}
106
107fn field<'a>(data: &'a Map<String, Value>, key: &str) -> Result<&'a Value> {
108    data.get(key)
109        .ok_or_else(|| DecodeError::invalid(format!("control message missing field {key:?}")))
110}
111
112fn as_i64(data: &Map<String, Value>, key: &str) -> Result<i64> {
113    // Realtime numeric fields are integers within the ±(2^53−1) i64 contract
114    // (SPEC.md §8.1); fractional or non-finite numbers are malformed.
115    let v = field(data, key)?.as_i64().ok_or_else(|| {
116        DecodeError::invalid(format!(
117            "control field {key:?} is not an integer within the i64 safe range"
118        ))
119    })?;
120    if !(-I64_SAFE_MAX..=I64_SAFE_MAX).contains(&v) {
121        return Err(DecodeError::invalid(format!(
122            "control field {key:?} = {v} outside the ±(2^53−1) safe-integer contract"
123        )));
124    }
125    Ok(v)
126}
127
128fn as_str(data: &Map<String, Value>, key: &str) -> Result<String> {
129    Ok(field(data, key)?
130        .as_str()
131        .ok_or_else(|| DecodeError::invalid(format!("control field {key:?} is not a string")))?
132        .to_owned())
133}
134
135fn as_bool(data: &Map<String, Value>, key: &str) -> Result<bool> {
136    field(data, key)?
137        .as_bool()
138        .ok_or_else(|| DecodeError::invalid(format!("control field {key:?} is not a boolean")))
139}
140
141/// Parse a JSON control message text frame.
142pub fn parse_control(text: &str) -> Result<ControlMessage> {
143    let value: Value = serde_json::from_str(text)
144        .map_err(|e| DecodeError::invalid(format!("control message is not JSON: {e}")))?;
145    parse_control_value(&value)
146}
147
148pub fn parse_control_value(value: &Value) -> Result<ControlMessage> {
149    let root = value
150        .as_object()
151        .ok_or_else(|| DecodeError::invalid("control message is not a JSON object"))?;
152
153    // Client ack shape: {"type":"ack","cursor":…} (§8.2).
154    if root.get("type").and_then(Value::as_str) == Some("ack") {
155        return Ok(ControlMessage::Ack {
156            cursor: as_i64(root, "cursor")?,
157        });
158    }
159
160    let Some(event) = root.get("event").and_then(Value::as_str) else {
161        return Err(DecodeError::invalid(
162            "control message has neither an \"event\" nor a known \"type\"",
163        ));
164    };
165    let data = |what: &str| -> Result<&Map<String, Value>> {
166        root.get("data")
167            .and_then(Value::as_object)
168            .ok_or_else(|| DecodeError::invalid(format!("{what} control message missing data")))
169    };
170    match event {
171        "hello" => {
172            let d = data("hello")?;
173            Ok(ControlMessage::Hello {
174                protocol_version: as_i64(d, "protocolVersion")?,
175                session_id: as_str(d, "sessionId")?,
176                actor_id: as_str(d, "actorId")?,
177                client_id: as_str(d, "clientId")?,
178                cursor: as_i64(d, "cursor")?,
179                latest_cursor: as_i64(d, "latestCursor")?,
180                requires_sync: as_bool(d, "requiresSync")?,
181                timestamp: as_i64(d, "timestamp")?,
182            })
183        }
184        "sync" => {
185            let d = data("sync")?;
186            let reason_raw = as_str(d, "reason")?;
187            let reason = WakeReason::from_reason(&reason_raw).ok_or_else(|| {
188                DecodeError::invalid(format!("unknown wake-up reason {reason_raw:?}"))
189            })?;
190            // §8.3: requiresPull MUST be the literal true; anything else is
191            // a malformed event.
192            if !as_bool(d, "requiresPull")? {
193                return Err(DecodeError::invalid(
194                    "sync.data.requiresPull must be the literal true",
195                ));
196            }
197            Ok(ControlMessage::Wake {
198                cursor: as_i64(d, "cursor")?,
199                requires_pull: true,
200                reason,
201                timestamp: as_i64(d, "timestamp")?,
202            })
203        }
204        "heartbeat" => {
205            let d = data("heartbeat")?;
206            Ok(ControlMessage::Heartbeat {
207                timestamp: as_i64(d, "timestamp")?,
208            })
209        }
210        "presence" => {
211            let d = data("presence")?;
212            let scope_key = as_str(d, "scopeKey")?;
213            if scope_key.is_empty() {
214                return Err(DecodeError::invalid(
215                    "presence.data.scopeKey must be non-empty",
216                ));
217            }
218            let timestamp = if d.contains_key("timestamp") {
219                Some(as_i64(d, "timestamp")?)
220            } else {
221                None
222            };
223            if let Some(error) = d.get("error") {
224                // §8.6.2 publisher-directed error variant.
225                let error = error
226                    .as_str()
227                    .ok_or_else(|| DecodeError::invalid("presence.data.error must be a string"))?
228                    .to_owned();
229                return Ok(ControlMessage::Presence {
230                    scope_key,
231                    kind: None,
232                    actor_id: None,
233                    client_id: None,
234                    doc: None,
235                    error: Some(error),
236                    timestamp,
237                });
238            }
239            match d.get("kind") {
240                Some(kind_val) => {
241                    // §8.6.2 fanout.
242                    let kind_raw = kind_val.as_str().ok_or_else(|| {
243                        DecodeError::invalid("presence.data.kind must be a string")
244                    })?;
245                    let kind = PresenceKind::from_str(kind_raw).ok_or_else(|| {
246                        DecodeError::invalid(format!("unknown presence kind {kind_raw:?}"))
247                    })?;
248                    let actor_id = as_str(d, "actorId")?;
249                    let client_id = as_str(d, "clientId")?;
250                    let doc = d.get("doc").cloned();
251                    // §8.6.2 doc-present-iff-not-leave.
252                    match kind {
253                        PresenceKind::Leave => {
254                            if matches!(doc, Some(Value::Object(_))) {
255                                return Err(DecodeError::invalid(
256                                    "presence leave must carry doc: null",
257                                ));
258                            }
259                        }
260                        _ => {
261                            if !matches!(doc, Some(Value::Object(_))) {
262                                return Err(DecodeError::invalid(
263                                    "presence.data.doc must be a JSON object",
264                                ));
265                            }
266                        }
267                    }
268                    Ok(ControlMessage::Presence {
269                        scope_key,
270                        kind: Some(kind),
271                        actor_id: Some(actor_id),
272                        client_id: Some(client_id),
273                        doc,
274                        error: None,
275                        timestamp,
276                    })
277                }
278                None => {
279                    // Client→server publish/leave: scopeKey + doc (object or null).
280                    let doc = d.get("doc").cloned();
281                    if !matches!(doc, None | Some(Value::Null) | Some(Value::Object(_))) {
282                        return Err(DecodeError::invalid(
283                            "presence.data.doc must be a JSON object or null",
284                        ));
285                    }
286                    Ok(ControlMessage::Presence {
287                        scope_key,
288                        kind: None,
289                        actor_id: None,
290                        client_id: None,
291                        doc,
292                        error: None,
293                        timestamp,
294                    })
295                }
296            }
297        }
298        _ => Ok(ControlMessage::Unknown(value.clone())),
299    }
300}
301
302/// §8.6.2 serialize a client→server presence publish/leave.
303pub fn encode_presence_publish(scope_key: &str, doc: Option<&Value>) -> String {
304    let mut d = Map::new();
305    d.insert("scopeKey".to_string(), Value::from(scope_key));
306    d.insert("doc".to_string(), doc.cloned().unwrap_or(Value::Null));
307    let mut root = Map::new();
308    root.insert("event".to_string(), Value::from("presence"));
309    root.insert("data".to_string(), Value::Object(d));
310    Value::Object(root).to_string()
311}
312
313/// Render a control message back to its JSON form.
314pub fn render_control(msg: &ControlMessage) -> Value {
315    fn envelope(event: &str, data: Vec<(&str, Value)>) -> Value {
316        let mut d = Map::new();
317        for (k, v) in data {
318            d.insert(k.to_string(), v);
319        }
320        let mut root = Map::new();
321        root.insert("event".to_string(), Value::from(event));
322        root.insert("data".to_string(), Value::Object(d));
323        Value::Object(root)
324    }
325    match msg {
326        ControlMessage::Hello {
327            protocol_version,
328            session_id,
329            actor_id,
330            client_id,
331            cursor,
332            latest_cursor,
333            requires_sync,
334            timestamp,
335        } => envelope(
336            "hello",
337            vec![
338                ("protocolVersion", Value::from(*protocol_version)),
339                ("sessionId", Value::from(session_id.clone())),
340                ("actorId", Value::from(actor_id.clone())),
341                ("clientId", Value::from(client_id.clone())),
342                ("cursor", Value::from(*cursor)),
343                ("latestCursor", Value::from(*latest_cursor)),
344                ("requiresSync", Value::from(*requires_sync)),
345                ("timestamp", Value::from(*timestamp)),
346            ],
347        ),
348        ControlMessage::Wake {
349            cursor,
350            requires_pull,
351            reason,
352            timestamp,
353        } => envelope(
354            "sync",
355            vec![
356                ("cursor", Value::from(*cursor)),
357                ("requiresPull", Value::from(*requires_pull)),
358                ("reason", Value::from(reason.as_str())),
359                ("timestamp", Value::from(*timestamp)),
360            ],
361        ),
362        ControlMessage::Heartbeat { timestamp } => {
363            envelope("heartbeat", vec![("timestamp", Value::from(*timestamp))])
364        }
365        ControlMessage::Ack { cursor } => {
366            let mut root = Map::new();
367            root.insert("type".to_string(), Value::from("ack"));
368            root.insert("cursor".to_string(), Value::from(*cursor));
369            Value::Object(root)
370        }
371        ControlMessage::Presence {
372            scope_key,
373            kind,
374            actor_id,
375            client_id,
376            doc,
377            error,
378            timestamp,
379        } => {
380            // Field order matches the §8.6.2 wire shape so re-encoding a
381            // parsed fanout round-trips byte-for-byte with the vectors.
382            let mut d = Map::new();
383            d.insert("scopeKey".to_string(), Value::from(scope_key.clone()));
384            if let Some(error) = error {
385                d.insert("error".to_string(), Value::from(error.clone()));
386            } else if let Some(kind) = kind {
387                d.insert("kind".to_string(), Value::from(kind.as_str()));
388                if let Some(actor_id) = actor_id {
389                    d.insert("actorId".to_string(), Value::from(actor_id.clone()));
390                }
391                if let Some(client_id) = client_id {
392                    d.insert("clientId".to_string(), Value::from(client_id.clone()));
393                }
394                d.insert("doc".to_string(), doc.clone().unwrap_or(Value::Null));
395            } else {
396                // Client→server publish/leave.
397                d.insert("doc".to_string(), doc.clone().unwrap_or(Value::Null));
398            }
399            if let Some(timestamp) = timestamp {
400                d.insert("timestamp".to_string(), Value::from(*timestamp));
401            }
402            let mut root = Map::new();
403            root.insert("event".to_string(), Value::from("presence"));
404            root.insert("data".to_string(), Value::Object(d));
405            Value::Object(root)
406        }
407        ControlMessage::Unknown(v) => v.clone(),
408    }
409}