//! What the server sends down the stream. //! //! Deliberately four variants. Presence, typing, join, and leave are all absent //! by decision: typing in particular is the largest driver of event volume, and //! both consumers run under a hard 512M cgroup cap where an OOM restarts the //! whole site. use serde::{Deserialize, Serialize}; use crate::ids::{MessageId, UserId}; use crate::message::Message; /// A single frame on the stream. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(tag = "type", rename_all = "snake_case")] pub enum ChatEvent { /// A new message. Message(Message), /// One message removed, by its author or by a moderator. Delete { id: MessageId }, /// Every message from one author removed at once, following a ban. /// /// One event rather than N `Delete`s: a ban is issued on a hot path and the /// deletion is a single statement keyed by (room, author), so fanning it out /// per message would put an unbounded burst through every open connection /// for no gain. Purge { author_id: UserId }, /// The connection fell behind and messages were dropped from the broadcast /// buffer. The client re-fetches from its cursor. /// /// Never silently swallowed. SyncKit's SSE can ignore lag because its clients /// re-pull on their own schedule; a chat client that ignored it would show a /// room with a hole in it and no indication anything was missing. Gap, } impl ChatEvent { /// The SSE event name for this frame. pub fn name(&self) -> &'static str { match self { Self::Message(_) => "message", Self::Delete { .. } => "delete", Self::Purge { .. } => "purge", Self::Gap => "gap", } } /// The cursor a client should record after handling this frame, if any. /// /// Only `Message` advances the cursor. A deletion is not a position in the /// room, and reconnecting past a `Gap` is exactly what the cursor is for. pub fn cursor(&self) -> Option { match self { Self::Message(m) => Some(m.id), _ => None, } } } #[cfg(test)] mod tests { use super::*; use crate::ids::RoomId; use uuid::Uuid; fn message(id: i64) -> Message { Message { id: MessageId(id), room_id: RoomId(Uuid::nil()), author_id: UserId(Uuid::nil()), body_html: "hi".into(), created_at: 0, nonce: None, } } #[test] fn only_messages_advance_the_cursor() { assert_eq!(ChatEvent::Message(message(7)).cursor(), Some(MessageId(7))); assert_eq!(ChatEvent::Delete { id: MessageId(7) }.cursor(), None); assert_eq!( ChatEvent::Purge { author_id: UserId(Uuid::nil()) } .cursor(), None ); assert_eq!(ChatEvent::Gap.cursor(), None); } #[test] fn serializes_tagged_for_the_client() { let json = serde_json::to_string(&ChatEvent::Delete { id: MessageId(3) }).unwrap(); assert!(json.contains(r#""type":"delete""#), "{json}"); } #[test] fn nonce_is_omitted_when_absent() { let json = serde_json::to_string(&message(1)).unwrap(); assert!(!json.contains("nonce"), "{json}"); } }