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}