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    /// The session's row scopes changed on a token refresh and this
158    /// subscription could not be resnapshotted in place (a replayable tape, or
159    /// a failed re-attach). It has stopped; the registration stays until the
160    /// client unsubscribes.
161    pub fn subscription_rescoped(subscription_id: String) -> Self {
162        let mut issue = Self::protocol(
163            Some(subscription_id),
164            "subscription-rescoped",
165            "the session's row scopes changed and this subscription has stopped".to_string(),
166        );
167        issue.retryable = true;
168        issue.suggested_action = Some(
169            "unsubscribe, then resubscribe (from your last cursor for a replayable view) to receive the rows the new scopes cover"
170                .to_string(),
171        );
172        issue.fatal = true;
173        issue
174    }
175
176    /// A non-replayable append subscription cannot reconstruct records lost
177    /// after falling behind its bounded live bus.
178    pub fn append_subscription_lagged(subscription_id: String, skipped: u64) -> Self {
179        let mut issue = Self::protocol(
180            Some(subscription_id),
181            "subscription-lagged",
182            format!(
183                "delivery fell behind by {skipped} events and this append subscription has stopped"
184            ),
185        );
186        issue.retryable = true;
187        issue.suggested_action = Some(
188            "reconnect to continue live; enable retained replay on the view for lossless recovery"
189                .to_string(),
190        );
191        issue.fatal = true;
192        issue
193    }
194
195    /// A replay the journal refused, rendered for the client.
196    ///
197    /// Each reason gets its own code: they have different remediations, and
198    /// an epoch mismatch in particular means the consumer's whole notion of
199    /// the stream is stale rather than merely out of date.
200    pub fn replay_refused(
201        subscription_id: Option<String>,
202        error: &crate::journal::ReplayError,
203    ) -> Self {
204        use crate::journal::ReplayError;
205
206        let window = error.window().clone();
207        let (code, message, action) = match error {
208            ReplayError::EpochMismatch(_) => (
209                "cursor-epoch-changed",
210                "cursor was issued by a previous journal lifetime and its offsets do not apply here"
211                    .to_string(),
212                "discard the cursor and resubscribe without `after`",
213            ),
214            ReplayError::CursorExpired(window) => (
215                "cursor-expired",
216                format!(
217                    "cursor is older than the retained replay window; the oldest retained record is at offset {}",
218                    window.earliest
219                ),
220                "resubscribe without `after` to replay the whole retained window",
221            ),
222            ReplayError::CursorBeyondWindow(window) => (
223                "cursor-unknown",
224                format!(
225                    "cursor is beyond this view's latest offset; the next record will be at offset {}",
226                    window.next
227                ),
228                "resubscribe without `after`, or with a cursor this view has issued",
229            ),
230            ReplayError::GapCrossed(window) => (
231                "replay-gap",
232                format!(
233                    "records after offset {} were lost when the stream restarted live; replaying across the hole would present it as continuous",
234                    window.gap_after.unwrap_or(window.earliest)
235                ),
236                "resubscribe without `after`, accepting the gap, or from a cursor after it",
237            ),
238        };
239
240        let mut issue = Self::protocol(subscription_id, code, message);
241        issue.suggested_action = Some(action.to_string());
242        issue.replay_window = Some(window);
243        issue
244    }
245}
246
247/// Canonical query identity and behavior for protocol v2.
248#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
249#[serde(rename_all = "camelCase", deny_unknown_fields)]
250pub struct SubscriptionQuery {
251    pub view: String,
252    #[serde(skip_serializing_if = "Option::is_none")]
253    pub key: Option<String>,
254    #[serde(skip_serializing_if = "Option::is_none")]
255    pub partition: Option<String>,
256    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
257    pub filters: BTreeMap<String, Value>,
258    #[serde(skip_serializing_if = "Option::is_none")]
259    pub take: Option<usize>,
260    #[serde(skip_serializing_if = "Option::is_none")]
261    pub skip: Option<usize>,
262    #[serde(skip_serializing_if = "Option::is_none")]
263    pub after: Option<String>,
264    #[serde(skip_serializing_if = "Option::is_none")]
265    pub snapshot_limit: Option<usize>,
266}
267
268impl SubscriptionQuery {
269    pub fn matches_key(&self, key: &str) -> bool {
270        self.key.as_ref().is_none_or(|expected| expected == key)
271    }
272
273    pub fn validate(&self) -> Result<(), &'static str> {
274        if self.view.trim().is_empty() {
275            return Err("query.view must not be empty");
276        }
277        if self.take == Some(0) {
278            return Err("query.take must be greater than zero");
279        }
280        if self.snapshot_limit == Some(0) {
281            return Err("query.snapshotLimit must be greater than zero");
282        }
283        if self
284            .filters
285            .keys()
286            .any(|path| path.is_empty() || path.split('.').any(|segment| segment.is_empty()))
287        {
288            return Err("query filter paths must contain non-empty dot-path segments");
289        }
290        Ok(())
291    }
292}
293
294#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
295#[serde(rename_all = "camelCase", deny_unknown_fields)]
296pub struct SnapshotOptions {
297    #[serde(default = "default_snapshot_enabled")]
298    pub enabled: bool,
299}
300
301impl Default for SnapshotOptions {
302    fn default() -> Self {
303        Self { enabled: true }
304    }
305}
306
307fn default_snapshot_enabled() -> bool {
308    true
309}
310
311#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
312#[serde(rename_all = "camelCase", deny_unknown_fields)]
313pub struct Subscription {
314    pub protocol_version: u8,
315    pub subscription_id: String,
316    pub query: SubscriptionQuery,
317    #[serde(default)]
318    pub snapshot: SnapshotOptions,
319}
320
321impl Subscription {
322    pub fn validate(&self) -> Result<(), &'static str> {
323        validate_protocol(self.protocol_version)?;
324        validate_subscription_id(&self.subscription_id)?;
325        self.query.validate()
326    }
327}
328
329#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
330#[serde(rename_all = "camelCase", deny_unknown_fields)]
331pub struct Unsubscription {
332    pub protocol_version: u8,
333    pub subscription_id: String,
334}
335
336impl Unsubscription {
337    pub fn validate(&self) -> Result<(), &'static str> {
338        validate_protocol(self.protocol_version)?;
339        validate_subscription_id(&self.subscription_id)
340    }
341}
342
343pub fn validate_protocol(version: u8) -> Result<(), &'static str> {
344    if version == PROTOCOL_VERSION {
345        Ok(())
346    } else {
347        Err("protocolVersion must be 2")
348    }
349}
350
351pub fn validate_subscription_id(subscription_id: &str) -> Result<(), &'static str> {
352    if subscription_id.is_empty() {
353        return Err("subscriptionId must not be empty");
354    }
355    if subscription_id.len() > MAX_SUBSCRIPTION_ID_BYTES {
356        return Err("subscriptionId exceeds 128 bytes");
357    }
358    if subscription_id.trim() != subscription_id || subscription_id.chars().any(char::is_control) {
359        return Err("subscriptionId must be opaque non-whitespace text without control characters");
360    }
361    Ok(())
362}
363
364#[cfg(test)]
365mod tests {
366    use super::*;
367    use crate::websocket::auth::{AuthDeny, AuthErrorCode};
368    use serde_json::json;
369
370    fn valid_subscription() -> Value {
371        json!({
372            "type": "subscribe",
373            "protocolVersion": 2,
374            "subscriptionId": "rounds:page-1",
375            "query": {
376                "view": "OreRound/latest",
377                "filters": {"state.status": "open"},
378                "take": 10,
379                "skip": 0,
380                "after": "100:000000000001",
381                "snapshotLimit": 10
382            },
383            "snapshot": {"enabled": true}
384        })
385    }
386
387    #[test]
388    fn parses_canonical_v2_subscription() {
389        let message: ClientMessage = serde_json::from_value(valid_subscription()).unwrap();
390        let ClientMessage::Subscribe(subscription) = message else {
391            panic!("expected subscribe");
392        };
393
394        assert!(subscription.validate().is_ok());
395        assert_eq!(subscription.subscription_id, "rounds:page-1");
396        assert_eq!(subscription.query.filters["state.status"], "open");
397        assert!(subscription.snapshot.enabled);
398    }
399
400    #[test]
401    fn rejects_legacy_and_unknown_subscription_fields() {
402        assert!(serde_json::from_value::<ClientMessage>(json!({
403            "type": "subscribe",
404            "view": "OreRound/latest"
405        }))
406        .is_err());
407
408        let mut value = valid_subscription();
409        value["withSnapshot"] = json!(true);
410        assert!(serde_json::from_value::<ClientMessage>(value).is_err());
411    }
412
413    #[test]
414    fn validates_protocol_and_opaque_id() {
415        let mut value = valid_subscription();
416        value["protocolVersion"] = json!(1);
417        let ClientMessage::Subscribe(subscription) =
418            serde_json::from_value::<ClientMessage>(value).unwrap()
419        else {
420            panic!("expected subscribe");
421        };
422        assert_eq!(subscription.validate(), Err("protocolVersion must be 2"));
423
424        assert!(validate_subscription_id("client-selected.id:1").is_ok());
425        assert!(validate_subscription_id("").is_err());
426        assert!(validate_subscription_id(" leading").is_err());
427        assert!(validate_subscription_id(&"x".repeat(129)).is_err());
428    }
429
430    #[test]
431    fn parses_id_only_unsubscribe() {
432        let message: ClientMessage = serde_json::from_value(json!({
433            "type": "unsubscribe",
434            "protocolVersion": 2,
435            "subscriptionId": "rounds:page-1"
436        }))
437        .unwrap();
438        let ClientMessage::Unsubscribe(unsubscription) = message else {
439            panic!("expected unsubscribe");
440        };
441        assert!(unsubscription.validate().is_ok());
442    }
443
444    #[test]
445    fn socket_issue_carries_protocol_and_subscription_identity() {
446        let deny = AuthDeny::new(
447            AuthErrorCode::SubscriptionLimitExceeded,
448            "subscription limit exceeded",
449        );
450        let issue =
451            SocketIssueMessage::from_auth_deny(&deny, false, Some("rounds:page-1".to_string()));
452        assert_eq!(issue.protocol_version, PROTOCOL_VERSION);
453        assert_eq!(issue.subscription_id.as_deref(), Some("rounds:page-1"));
454        assert_eq!(issue.code, "subscription-limit-exceeded");
455    }
456}