Skip to main content

arete_server/websocket/
subscription.rs

1use std::collections::BTreeMap;
2
3use serde::{Deserialize, Serialize};
4use serde_json::Value;
5
6use crate::websocket::auth::AuthDeny;
7
8pub const PROTOCOL_VERSION: u8 = 2;
9pub const MAX_SUBSCRIPTION_ID_BYTES: usize = 128;
10
11/// Client message types for subscription management.
12#[derive(Debug, Clone, Serialize, Deserialize)]
13#[serde(tag = "type", rename_all = "snake_case")]
14pub enum ClientMessage {
15    Subscribe(Subscription),
16    Unsubscribe(Unsubscription),
17    Ping,
18    RefreshAuth(RefreshAuthRequest),
19}
20
21#[derive(Debug, Clone, Serialize, Deserialize)]
22#[serde(deny_unknown_fields)]
23pub struct RefreshAuthRequest {
24    pub token: String,
25}
26
27#[derive(Debug, Clone, Serialize, Deserialize)]
28#[serde(rename_all = "camelCase")]
29pub struct RefreshAuthResponse {
30    pub success: bool,
31    #[serde(skip_serializing_if = "Option::is_none")]
32    pub error: Option<String>,
33    #[serde(skip_serializing_if = "Option::is_none")]
34    pub expires_at: Option<u64>,
35}
36
37/// Server-sent protocol, authorization, or quota error.
38#[derive(Debug, Clone, Serialize, Deserialize)]
39#[serde(rename_all = "camelCase")]
40pub struct SocketIssueMessage {
41    #[serde(rename = "type")]
42    pub kind: String,
43    pub protocol_version: u8,
44    /// Null only when a request did not contain a usable subscription ID.
45    pub subscription_id: Option<String>,
46    pub error: String,
47    pub message: String,
48    pub code: String,
49    pub retryable: bool,
50    #[serde(skip_serializing_if = "Option::is_none")]
51    pub retry_after: Option<u64>,
52    #[serde(skip_serializing_if = "Option::is_none")]
53    pub suggested_action: Option<String>,
54    #[serde(skip_serializing_if = "Option::is_none")]
55    pub docs_url: Option<String>,
56    /// Present on `cursor-expired` and `invalid-cursor`: the offsets the view
57    /// can still serve.
58    #[serde(skip_serializing_if = "Option::is_none")]
59    pub replay_window: Option<crate::journal::ReplayWindow>,
60    /// Present on `replay-lagged`: the cursor for the last record delivered
61    /// *before* the gap. Resubscribing with `after` set to this replays the
62    /// skipped records, provided they are still retained.
63    #[serde(skip_serializing_if = "Option::is_none")]
64    pub recover_from: Option<String>,
65    pub fatal: bool,
66}
67
68impl SocketIssueMessage {
69    pub fn from_auth_deny(deny: &AuthDeny, fatal: bool, subscription_id: Option<String>) -> Self {
70        let response = deny.to_error_response();
71        Self {
72            kind: "error".to_string(),
73            protocol_version: PROTOCOL_VERSION,
74            subscription_id,
75            error: response.error,
76            message: response.message,
77            code: response.code,
78            retryable: response.retryable,
79            retry_after: response.retry_after,
80            suggested_action: response.suggested_action,
81            docs_url: response.docs_url,
82            replay_window: None,
83            recover_from: None,
84            fatal,
85        }
86    }
87
88    pub fn protocol(
89        subscription_id: Option<String>,
90        code: impl Into<String>,
91        message: impl Into<String>,
92    ) -> Self {
93        let code = code.into();
94        Self {
95            kind: "error".to_string(),
96            protocol_version: PROTOCOL_VERSION,
97            subscription_id,
98            error: code.clone(),
99            message: message.into(),
100            code,
101            retryable: false,
102            retry_after: None,
103            suggested_action: None,
104            docs_url: None,
105            replay_window: None,
106            recover_from: None,
107            fatal: false,
108        }
109    }
110
111    /// Delivery fell behind the server's fan-out buffer and records were
112    /// skipped.
113    ///
114    /// `recover_from` is the cursor for the last record delivered *before*
115    /// the gap, not the latest offset seen: resubscribing after the newest
116    /// frame would step over the skipped records permanently. Delivery on
117    /// this subscription stops here, so nothing arrives that could advance
118    /// the consumer's checkpoint past the gap.
119    pub fn replay_lagged(
120        subscription_id: Option<String>,
121        skipped: u64,
122        recover_from: Option<crate::journal::Cursor>,
123    ) -> Self {
124        let mut issue = Self::protocol(
125            subscription_id,
126            "replay-lagged",
127            format!("delivery fell behind by {skipped} records and this subscription has stopped"),
128        );
129        issue.suggested_action = Some(match &recover_from {
130            Some(cursor) => format!(
131                "unsubscribe, then resubscribe with after set to {cursor} to replay the skipped records"
132            ),
133            None => "unsubscribe, then resubscribe without `after` to replay the retained window"
134                .to_string(),
135        });
136        issue.recover_from = recover_from.map(|cursor| cursor.to_string());
137        issue
138    }
139
140    /// A latest-state collection subscription fell behind and cannot recover
141    /// because the client disabled authoritative snapshots.
142    pub fn subscription_lagged(subscription_id: String, skipped: u64) -> Self {
143        let mut issue = Self::protocol(
144            Some(subscription_id),
145            "subscription-lagged",
146            format!("delivery fell behind by {skipped} updates and this subscription has stopped"),
147        );
148        issue.retryable = true;
149        issue.suggested_action = Some(
150            "unsubscribe, then resubscribe with snapshots enabled so delivery can recover"
151                .to_string(),
152        );
153        issue.fatal = true;
154        issue
155    }
156
157    /// A non-replayable append subscription cannot reconstruct records lost
158    /// after falling behind its bounded live bus.
159    pub fn append_subscription_lagged(subscription_id: String, skipped: u64) -> Self {
160        let mut issue = Self::protocol(
161            Some(subscription_id),
162            "subscription-lagged",
163            format!(
164                "delivery fell behind by {skipped} events and this append subscription has stopped"
165            ),
166        );
167        issue.retryable = true;
168        issue.suggested_action = Some(
169            "reconnect to continue live; enable retained replay on the view for lossless recovery"
170                .to_string(),
171        );
172        issue.fatal = true;
173        issue
174    }
175
176    /// A replay the journal refused, rendered for the client.
177    ///
178    /// Each reason gets its own code: they have different remediations, and
179    /// an epoch mismatch in particular means the consumer's whole notion of
180    /// the stream is stale rather than merely out of date.
181    pub fn replay_refused(
182        subscription_id: Option<String>,
183        error: &crate::journal::ReplayError,
184    ) -> Self {
185        use crate::journal::ReplayError;
186
187        let window = error.window().clone();
188        let (code, message, action) = match error {
189            ReplayError::EpochMismatch(_) => (
190                "cursor-epoch-changed",
191                "cursor was issued by a previous journal lifetime and its offsets do not apply here"
192                    .to_string(),
193                "discard the cursor and resubscribe without `after`",
194            ),
195            ReplayError::CursorExpired(window) => (
196                "cursor-expired",
197                format!(
198                    "cursor is older than the retained replay window; the oldest retained record is at offset {}",
199                    window.earliest
200                ),
201                "resubscribe without `after` to replay the whole retained window",
202            ),
203            ReplayError::CursorBeyondWindow(window) => (
204                "cursor-unknown",
205                format!(
206                    "cursor is beyond this view's latest offset; the next record will be at offset {}",
207                    window.next
208                ),
209                "resubscribe without `after`, or with a cursor this view has issued",
210            ),
211            ReplayError::GapCrossed(window) => (
212                "replay-gap",
213                format!(
214                    "records after offset {} were lost when the stream restarted live; replaying across the hole would present it as continuous",
215                    window.gap_after.unwrap_or(window.earliest)
216                ),
217                "resubscribe without `after`, accepting the gap, or from a cursor after it",
218            ),
219        };
220
221        let mut issue = Self::protocol(subscription_id, code, message);
222        issue.suggested_action = Some(action.to_string());
223        issue.replay_window = Some(window);
224        issue
225    }
226}
227
228/// Canonical query identity and behavior for protocol v2.
229#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
230#[serde(rename_all = "camelCase", deny_unknown_fields)]
231pub struct SubscriptionQuery {
232    pub view: String,
233    #[serde(skip_serializing_if = "Option::is_none")]
234    pub key: Option<String>,
235    #[serde(skip_serializing_if = "Option::is_none")]
236    pub partition: Option<String>,
237    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
238    pub filters: BTreeMap<String, Value>,
239    #[serde(skip_serializing_if = "Option::is_none")]
240    pub take: Option<usize>,
241    #[serde(skip_serializing_if = "Option::is_none")]
242    pub skip: Option<usize>,
243    #[serde(skip_serializing_if = "Option::is_none")]
244    pub after: Option<String>,
245    #[serde(skip_serializing_if = "Option::is_none")]
246    pub snapshot_limit: Option<usize>,
247}
248
249impl SubscriptionQuery {
250    pub fn matches_key(&self, key: &str) -> bool {
251        self.key.as_ref().is_none_or(|expected| expected == key)
252    }
253
254    pub fn validate(&self) -> Result<(), &'static str> {
255        if self.view.trim().is_empty() {
256            return Err("query.view must not be empty");
257        }
258        if self.take == Some(0) {
259            return Err("query.take must be greater than zero");
260        }
261        if self.snapshot_limit == Some(0) {
262            return Err("query.snapshotLimit must be greater than zero");
263        }
264        if self
265            .filters
266            .keys()
267            .any(|path| path.is_empty() || path.split('.').any(|segment| segment.is_empty()))
268        {
269            return Err("query filter paths must contain non-empty dot-path segments");
270        }
271        Ok(())
272    }
273}
274
275#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
276#[serde(rename_all = "camelCase", deny_unknown_fields)]
277pub struct SnapshotOptions {
278    #[serde(default = "default_snapshot_enabled")]
279    pub enabled: bool,
280}
281
282impl Default for SnapshotOptions {
283    fn default() -> Self {
284        Self { enabled: true }
285    }
286}
287
288fn default_snapshot_enabled() -> bool {
289    true
290}
291
292#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
293#[serde(rename_all = "camelCase", deny_unknown_fields)]
294pub struct Subscription {
295    pub protocol_version: u8,
296    pub subscription_id: String,
297    pub query: SubscriptionQuery,
298    #[serde(default)]
299    pub snapshot: SnapshotOptions,
300}
301
302impl Subscription {
303    pub fn validate(&self) -> Result<(), &'static str> {
304        validate_protocol(self.protocol_version)?;
305        validate_subscription_id(&self.subscription_id)?;
306        self.query.validate()
307    }
308}
309
310#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
311#[serde(rename_all = "camelCase", deny_unknown_fields)]
312pub struct Unsubscription {
313    pub protocol_version: u8,
314    pub subscription_id: String,
315}
316
317impl Unsubscription {
318    pub fn validate(&self) -> Result<(), &'static str> {
319        validate_protocol(self.protocol_version)?;
320        validate_subscription_id(&self.subscription_id)
321    }
322}
323
324pub fn validate_protocol(version: u8) -> Result<(), &'static str> {
325    if version == PROTOCOL_VERSION {
326        Ok(())
327    } else {
328        Err("protocolVersion must be 2")
329    }
330}
331
332pub fn validate_subscription_id(subscription_id: &str) -> Result<(), &'static str> {
333    if subscription_id.is_empty() {
334        return Err("subscriptionId must not be empty");
335    }
336    if subscription_id.len() > MAX_SUBSCRIPTION_ID_BYTES {
337        return Err("subscriptionId exceeds 128 bytes");
338    }
339    if subscription_id.trim() != subscription_id || subscription_id.chars().any(char::is_control) {
340        return Err("subscriptionId must be opaque non-whitespace text without control characters");
341    }
342    Ok(())
343}
344
345#[cfg(test)]
346mod tests {
347    use super::*;
348    use crate::websocket::auth::{AuthDeny, AuthErrorCode};
349    use serde_json::json;
350
351    fn valid_subscription() -> Value {
352        json!({
353            "type": "subscribe",
354            "protocolVersion": 2,
355            "subscriptionId": "rounds:page-1",
356            "query": {
357                "view": "OreRound/latest",
358                "filters": {"state.status": "open"},
359                "take": 10,
360                "skip": 0,
361                "after": "100:000000000001",
362                "snapshotLimit": 10
363            },
364            "snapshot": {"enabled": true}
365        })
366    }
367
368    #[test]
369    fn parses_canonical_v2_subscription() {
370        let message: ClientMessage = serde_json::from_value(valid_subscription()).unwrap();
371        let ClientMessage::Subscribe(subscription) = message else {
372            panic!("expected subscribe");
373        };
374
375        assert!(subscription.validate().is_ok());
376        assert_eq!(subscription.subscription_id, "rounds:page-1");
377        assert_eq!(subscription.query.filters["state.status"], "open");
378        assert!(subscription.snapshot.enabled);
379    }
380
381    #[test]
382    fn rejects_legacy_and_unknown_subscription_fields() {
383        assert!(serde_json::from_value::<ClientMessage>(json!({
384            "type": "subscribe",
385            "view": "OreRound/latest"
386        }))
387        .is_err());
388
389        let mut value = valid_subscription();
390        value["withSnapshot"] = json!(true);
391        assert!(serde_json::from_value::<ClientMessage>(value).is_err());
392    }
393
394    #[test]
395    fn validates_protocol_and_opaque_id() {
396        let mut value = valid_subscription();
397        value["protocolVersion"] = json!(1);
398        let ClientMessage::Subscribe(subscription) =
399            serde_json::from_value::<ClientMessage>(value).unwrap()
400        else {
401            panic!("expected subscribe");
402        };
403        assert_eq!(subscription.validate(), Err("protocolVersion must be 2"));
404
405        assert!(validate_subscription_id("client-selected.id:1").is_ok());
406        assert!(validate_subscription_id("").is_err());
407        assert!(validate_subscription_id(" leading").is_err());
408        assert!(validate_subscription_id(&"x".repeat(129)).is_err());
409    }
410
411    #[test]
412    fn parses_id_only_unsubscribe() {
413        let message: ClientMessage = serde_json::from_value(json!({
414            "type": "unsubscribe",
415            "protocolVersion": 2,
416            "subscriptionId": "rounds:page-1"
417        }))
418        .unwrap();
419        let ClientMessage::Unsubscribe(unsubscription) = message else {
420            panic!("expected unsubscribe");
421        };
422        assert!(unsubscription.validate().is_ok());
423    }
424
425    #[test]
426    fn socket_issue_carries_protocol_and_subscription_identity() {
427        let deny = AuthDeny::new(
428            AuthErrorCode::SubscriptionLimitExceeded,
429            "subscription limit exceeded",
430        );
431        let issue =
432            SocketIssueMessage::from_auth_deny(&deny, false, Some("rounds:page-1".to_string()));
433        assert_eq!(issue.protocol_version, PROTOCOL_VERSION);
434        assert_eq!(issue.subscription_id.as_deref(), Some("rounds:page-1"));
435        assert_eq!(issue.code, "subscription-limit-exceeded");
436    }
437}