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 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 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 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#[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}