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#[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#[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 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 #[serde(skip_serializing_if = "Option::is_none")]
59 pub replay_window: Option<crate::journal::ReplayWindow>,
60 #[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 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 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 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 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#[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}