Skip to main content

jmap_base_client/
push.rs

1//! Canonical push notification types shared by SSE and WebSocket transports.
2//! Spec: RFC 8620 §7.1 (Push Subscriptions)
3
4use std::collections::HashMap;
5
6use serde::{Deserialize, Serialize};
7
8use jmap_types::{Id, State};
9
10/// A state change push notification (RFC 8620 §7.1).
11///
12/// Sent over both SSE (as a push event) and WebSocket (as a frame type).
13///
14/// # `extra` equality is feature-flag-dependent (bd:JMAP-6r7c.43)
15///
16/// The derived `PartialEq` / `Eq` impl's behaviour on the `extra` field
17/// depends on the global `serde_json/preserve_order` feature flag — see
18/// the [crate-level note](crate#extra-field-equality-and-the-serde_jsonpreserve_order-feature-bdjmap-6r7c43)
19/// for the canonical statement.
20#[non_exhaustive]
21#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
22pub struct StateChange {
23    /// For each account that changed: maps data-type name to the new [`State`] token.
24    ///
25    /// Outer key: account [`Id`].  Inner key: JMAP data-type name (e.g. `"Email"`).
26    /// Inner value: new opaque state string; pass to `Email/changes` etc. as `sinceState`.
27    pub changed: HashMap<Id, HashMap<String, State>>,
28
29    /// Catch-all for vendor / site / private extension fields not covered
30    /// by the typed fields above. Preserves unknown fields across
31    /// deserialize/serialize round-trip per workspace extras-preservation
32    /// policy (see workspace AGENTS.md).
33    #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
34    pub extra: serde_json::Map<String, serde_json::Value>,
35}
36
37#[cfg(test)]
38mod tests {
39    use super::*;
40    use serde_json::json;
41
42    /// `StateChange.extra` captures unknown fields on deserialize and
43    /// flattens them on serialize (round-trip).
44    #[test]
45    fn state_change_preserves_vendor_extras() {
46        let raw = json!({
47            "changed": {
48                "acc1": { "Email": "s42" }
49            },
50            "acmeCorpSequence": 17
51        });
52        let obj: StateChange = serde_json::from_value(raw).expect("StateChange must deserialize");
53        assert_eq!(
54            obj.extra.get("acmeCorpSequence").and_then(|v| v.as_u64()),
55            Some(17)
56        );
57
58        // Round-trip: serializing back must reproduce the vendor field.
59        let v = serde_json::to_value(&obj).expect("StateChange must serialize");
60        assert_eq!(v["acmeCorpSequence"], json!(17));
61    }
62
63    /// bd:JMAP-6r7c.43 — regression-guard for the workspace's
64    /// serde_json/preserve_order posture. Two StateChange values with the
65    /// same `extra` keys inserted in different orders MUST compare equal
66    /// under the workspace's default configuration (BTreeMap-backed
67    /// serde_json::Map). If a future Cargo.lock or workspace-level feature
68    /// change accidentally enables `preserve_order`, this assertion fails
69    /// loudly and surfaces the SemVer-policy break before downstream
70    /// consumers hit silently-different equality semantics.
71    ///
72    /// Both values are deserialized from JSON (the wire path) so the test
73    /// exercises the same construction code path consumers use.
74    #[test]
75    fn extra_equality_is_order_insensitive_under_workspace_flags() {
76        // Two equivalent JSON payloads with different key-insertion orders
77        // for the vendor extras. Under BTreeMap-backed Map the keys are
78        // re-sorted lexicographically on deserialize, so the resulting
79        // structures compare equal.
80        let raw_a = json!({
81            "changed": {"acc1": {"Email": "s1"}},
82            "vendorA": 1,
83            "vendorB": 2
84        });
85        let raw_b = json!({
86            "changed": {"acc1": {"Email": "s1"}},
87            "vendorB": 2,
88            "vendorA": 1
89        });
90
91        let a: StateChange = serde_json::from_value(raw_a).expect("a must deserialize");
92        let b: StateChange = serde_json::from_value(raw_b).expect("b must deserialize");
93
94        assert_eq!(
95            a, b,
96            "extra-map equality is order-insensitive under the workspace's \
97             default serde_json::Map (BTreeMap-backed); if this fails, \
98             check whether preserve_order has been enabled in the dep graph"
99        );
100    }
101}