use std::collections::{BTreeMap, HashMap};
use std::sync::{Arc, Mutex, Weak};
use serde::{Deserialize, Serialize};
use crate::channels::{Channels, Subscriber};
use crate::presence::{Presence, PresenceHandle};
use super::text::{
CollabEditError, CollabElement, CollabOp, CollabText, MAX_ACTOR_LEN, MAX_WIRE_ELEMENTS,
MAX_WIRE_PENDING, OpId,
};
#[must_use]
pub fn topic_for(key: &str) -> String {
format!("collab:{key}")
}
#[must_use]
pub fn doc_key(table: &str, pk: impl std::fmt::Display, column: &str) -> String {
format!("{table}:{pk}:{column}")
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct CollabLimits {
pub max_insert_chars: usize,
pub max_document_chars: usize,
pub max_delete_ids: usize,
pub max_documents: usize,
}
impl CollabLimits {
#[must_use]
pub fn clamped(self) -> Self {
Self {
max_document_chars: self.max_document_chars.min(MAX_WIRE_ELEMENTS),
..self
}
}
}
impl Default for CollabLimits {
fn default() -> Self {
Self {
max_insert_chars: 10_000,
max_document_chars: MAX_WIRE_ELEMENTS,
max_delete_ids: 10_000,
max_documents: 10_000,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum CollabError {
#[error("insert of {got} characters exceeds the limit of {limit}")]
InsertTooLarge {
got: usize,
limit: usize,
},
#[error("document holds {got} characters, at the limit of {limit}")]
DocumentFull {
got: usize,
limit: usize,
},
#[error("delete of {got} ids exceeds the limit of {limit}")]
DeleteTooLarge {
got: usize,
limit: usize,
},
#[error("the document registry holds {open} documents, at the limit of {limit}")]
RegistryFull {
open: usize,
limit: usize,
},
#[error("the causal buffer holds {got} operations, at the limit of {limit}")]
CausalBufferFull {
got: usize,
limit: usize,
},
#[error("document {key} has been released; re-open it to edit")]
DocumentReleased {
key: String,
},
#[error(transparent)]
Edit(#[from] CollabEditError),
#[error("unknown character id {id}")]
UnknownCharacter {
id: String,
},
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum CollabClientMessage {
Insert {
#[serde(default)]
after: Option<OpId>,
text: String,
},
Delete {
ids: Vec<OpId>,
},
Replace {
ids: Vec<OpId>,
#[serde(default)]
after: Option<OpId>,
text: String,
},
Cursor {
index: usize,
},
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum CollabServerMessage {
Snapshot {
elems: Vec<CollabElement>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pending: Vec<CollabOp>,
participants: Vec<CollabParticipant>,
#[serde(default, skip_serializing_if = "Option::is_none")]
actor: Option<String>,
},
Ops {
ops: Vec<CollabOp>,
},
Presence {
participants: Vec<CollabParticipant>,
},
Error {
message: String,
},
}
#[derive(Serialize)]
#[serde(tag = "type", rename_all = "snake_case")]
enum OpsFrame<'a> {
Ops { ops: &'a [CollabOp] },
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct CollabParticipant {
pub actor: String,
pub label: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cursor: Option<usize>,
}
struct DocState {
doc: CollabText,
cursors: BTreeMap<String, usize>,
sessions: usize,
close_claim: Weak<()>,
released: bool,
revision: u64,
}
impl DocState {
const fn new(doc: CollabText) -> Self {
Self {
doc,
cursors: BTreeMap::new(),
sessions: 0,
close_claim: Weak::new(),
released: false,
revision: 0,
}
}
fn edit<T>(&mut self, change: impl FnOnce(&mut CollabText) -> T) -> T {
let out = change(&mut self.doc);
self.revision = self.revision.wrapping_add(1);
out
}
fn try_edit<T, E>(
&mut self,
change: impl FnOnce(&mut CollabText) -> Result<T, E>,
) -> Result<T, E> {
let out = change(&mut self.doc)?;
self.revision = self.revision.wrapping_add(1);
Ok(out)
}
}
static NEXT_SESSION: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
fn seated_actor(name: &str, seat: u64) -> String {
let suffix = format!("#{seat}");
let budget = MAX_ACTOR_LEN.saturating_sub(suffix.len());
let mut cut = name.len().min(budget);
while cut > 0 && !name.is_char_boundary(cut) {
cut -= 1;
}
format!("{}{suffix}", &name[..cut])
}
fn set_cursor(cursors: &mut BTreeMap<String, usize>, actor: &str, index: usize) {
if let Some(caret) = cursors.get_mut(actor) {
*caret = index;
return;
}
cursors.insert(actor.to_owned(), index);
}
const PRESENCE_REFRESH: std::time::Duration = std::time::Duration::from_secs(10);
const MAX_LAG_RESENDS: u32 = 3;
#[derive(Clone)]
pub struct CollabHub {
inner: Arc<HubInner>,
}
struct HubInner {
docs: Arc<Mutex<HashMap<String, Weak<Mutex<DocState>>>>>,
channels: Channels,
presence: Presence,
limits: CollabLimits,
}
impl CollabHub {
#[must_use]
pub fn new(channels: Channels, presence: Presence) -> Self {
Self {
inner: Arc::new(HubInner {
docs: Arc::new(Mutex::new(HashMap::new())),
channels,
presence,
limits: CollabLimits::default(),
}),
}
}
#[must_use]
pub fn with_limits(self, limits: CollabLimits) -> Self {
Self {
inner: Arc::new(HubInner {
docs: Arc::clone(&self.inner.docs),
channels: self.inner.channels.clone(),
presence: self.inner.presence.clone(),
limits: limits.clamped(),
}),
}
}
#[must_use]
pub fn limits(&self) -> CollabLimits {
self.inner.limits
}
pub fn document(&self, key: &str) -> Result<CollabDoc, CollabError> {
self.open_with(key, CollabText::new)
}
pub fn open_with(
&self,
key: &str,
seed: impl FnOnce() -> CollabText,
) -> Result<CollabDoc, CollabError> {
if let Some(state) = self.live(key) {
return Ok(self.handle_for(key, state));
}
let seeded = seed();
let mut docs = self
.inner
.docs
.lock()
.expect("collab registry lock poisoned");
if let Some(state) = docs.get(key).and_then(Weak::upgrade) {
drop(docs);
return Ok(self.handle_for(key, state));
}
docs.retain(|_, weak| weak.strong_count() > 0);
if docs.len() >= self.inner.limits.max_documents {
let open = docs.len();
drop(docs);
drop(seeded);
tracing::warn!(
key,
open,
limit = self.inner.limits.max_documents,
"collab: document registry is full; refusing to open"
);
return Err(CollabError::RegistryFull {
open,
limit: self.inner.limits.max_documents,
});
}
let held = seeded.element_count() + seeded.pending_len();
if held > self.inner.limits.max_document_chars {
drop(docs);
tracing::warn!(
key,
held,
limit = self.inner.limits.max_document_chars,
"collab: seed is past the document limit; refusing to open"
);
return Err(CollabError::DocumentFull {
got: held,
limit: self.inner.limits.max_document_chars,
});
}
if seeded.pending_len() > MAX_WIRE_PENDING {
drop(docs);
tracing::warn!(
key,
pending = seeded.pending_len(),
limit = MAX_WIRE_PENDING,
"collab: seed is past the causal buffer limit; refusing to open"
);
return Err(CollabError::CausalBufferFull {
got: seeded.pending_len(),
limit: MAX_WIRE_PENDING,
});
}
let state = Arc::new(Mutex::new(DocState::new(seeded)));
docs.insert(key.to_owned(), Arc::downgrade(&state));
drop(docs);
Ok(self.handle_for(key, state))
}
fn live(&self, key: &str) -> Option<Arc<Mutex<DocState>>> {
self.inner
.docs
.lock()
.expect("collab registry lock poisoned")
.get(key)
.and_then(Weak::upgrade)
}
fn handle_for(&self, key: &str, state: Arc<Mutex<DocState>>) -> CollabDoc {
CollabDoc {
key: key.to_owned(),
topic: std::sync::OnceLock::new(),
state,
channels: self.inner.channels.clone(),
presence: self.inner.presence.clone(),
limits: self.inner.limits,
}
}
pub fn close(&self, key: &str) -> Option<CollabClose> {
let docs = self
.inner
.docs
.lock()
.expect("collab registry lock poisoned");
let state = docs.get(key).and_then(Weak::upgrade)?;
let mut guard = state.lock().expect("collab document lock poisoned");
if guard.sessions > 0 {
return None;
}
if guard.close_claim.upgrade().is_some() {
return None;
}
let claim = Arc::new(());
guard.close_claim = Arc::downgrade(&claim);
let text = guard.doc.clone();
let revision = guard.revision;
drop(guard);
drop(docs);
Some(CollabClose {
hub: self.clone(),
key: key.to_owned(),
state,
text,
revision,
claim,
})
}
#[must_use]
pub fn open_keys(&self) -> Vec<String> {
let mut docs = self
.inner
.docs
.lock()
.expect("collab registry lock poisoned");
docs.retain(|_, weak| weak.strong_count() > 0);
let mut keys: Vec<String> = docs.keys().cloned().collect();
drop(docs);
keys.sort();
keys
}
}
#[must_use = "the document stays open until this guard is finalized or dropped"]
pub struct CollabClose {
hub: CollabHub,
key: String,
state: Arc<Mutex<DocState>>,
text: CollabText,
revision: u64,
claim: Arc<()>,
}
impl CollabClose {
#[must_use]
pub const fn text(&self) -> &CollabText {
&self.text
}
#[must_use]
pub fn key(&self) -> &str {
&self.key
}
#[must_use = "a returned guard means the document moved on and still needs persisting"]
pub fn finalize(self) -> Option<Self> {
let mut docs = self
.hub
.inner
.docs
.lock()
.expect("collab registry lock poisoned");
let live = docs.get(&self.key).and_then(Weak::upgrade)?;
if !Arc::ptr_eq(&live, &self.state) {
return None;
}
let mut state = live.lock().expect("collab document lock poisoned");
if state.sessions > 0 {
return None;
}
if state.revision != self.revision {
let text = state.doc.clone();
let revision = state.revision;
drop(state);
drop(docs);
return Some(Self {
hub: self.hub,
key: self.key,
state: self.state,
text,
revision,
claim: self.claim,
});
}
state.released = true;
drop(state);
docs.remove(&self.key);
None
}
}
impl std::fmt::Debug for CollabClose {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("CollabClose")
.field("key", &self.key)
.field("chars", &self.text.len())
.finish_non_exhaustive()
}
}
impl std::fmt::Debug for CollabHub {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("CollabHub")
.field("open", &self.open_keys().len())
.field("limits", &self.inner.limits)
.finish_non_exhaustive()
}
}
impl axum::extract::FromRequestParts<crate::state::AppState> for CollabHub {
type Rejection = std::convert::Infallible;
async fn from_request_parts(
_parts: &mut http::request::Parts,
state: &crate::state::AppState,
) -> Result<Self, Self::Rejection> {
Ok(state.collab().clone())
}
}
#[derive(Clone)]
pub struct CollabDoc {
key: String,
topic: std::sync::OnceLock<Arc<str>>,
state: Arc<Mutex<DocState>>,
channels: Channels,
presence: Presence,
limits: CollabLimits,
}
impl CollabDoc {
fn editable(&self) -> Result<std::sync::MutexGuard<'_, DocState>, CollabError> {
let state = self.state.lock().expect("collab document lock poisoned");
if state.released {
return Err(CollabError::DocumentReleased {
key: self.key.clone(),
});
}
Ok(state)
}
#[must_use]
pub const fn key(&self) -> &str {
self.key.as_str()
}
#[must_use]
pub fn topic(&self) -> String {
String::from(self.topic_name())
}
fn topic_name(&self) -> &str {
self.topic.get_or_init(|| topic_for(&self.key).into())
}
#[must_use]
pub fn text(&self) -> String {
self.with_doc(CollabText::text)
}
#[must_use]
pub fn document(&self) -> CollabText {
self.with_doc(Clone::clone)
}
#[must_use]
pub fn subscribe(&self) -> Subscriber {
self.channels.subscribe(self.topic_name())
}
#[must_use]
pub fn snapshot(&self) -> CollabServerMessage {
let (elems, pending) = self.with_doc(|doc| (doc.elements(), doc.pending_ops().to_vec()));
CollabServerMessage::Snapshot {
elems,
pending,
participants: self.participants(),
actor: None,
}
}
#[must_use = "dropping the session immediately ends the editor's presence lease"]
pub fn join(&self, actor: impl Into<String>, label: impl Into<String>) -> CollabSession {
let seat = NEXT_SESSION.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
let actor = seated_actor(&actor.into(), seat);
let label = label.into();
{
let mut state = self.state.lock().expect("collab document lock poisoned");
state.sessions += 1;
}
let presence = self.presence.track(
self.topic(),
actor.clone(),
serde_json::json!({ "label": label }),
);
self.broadcast_presence();
CollabSession {
doc: self.clone(),
actor,
presence: Some(presence),
}
}
#[must_use]
pub fn participants(&self) -> Vec<CollabParticipant> {
let entries = self.presence.list(self.topic_name());
let state = self
.state
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let participants: Vec<CollabParticipant> = entries
.into_iter()
.map(|entry| {
let label = entry
.metas
.first()
.and_then(|m| m.get("label"))
.and_then(serde_json::Value::as_str)
.unwrap_or(entry.key.as_str())
.to_owned();
CollabParticipant {
cursor: state.cursors.get(&entry.key).copied(),
actor: entry.key,
label,
}
})
.collect();
drop(state);
participants
}
pub fn handle(
&self,
actor: &str,
message: CollabClientMessage,
) -> Result<Vec<CollabOp>, CollabError> {
match message {
CollabClientMessage::Insert { after, text } => {
let added = text.chars().count();
if added > self.limits.max_insert_chars {
return Err(CollabError::InsertTooLarge {
got: added,
limit: self.limits.max_insert_chars,
});
}
let ops = {
let mut state = self.editable()?;
if let Some(anchor) = after.as_ref()
&& !state.doc.knows(anchor)
{
return Err(CollabError::UnknownCharacter {
id: anchor.to_string(),
});
}
let held = state.doc.element_count() + state.doc.pending_len();
if held + added > self.limits.max_document_chars {
return Err(CollabError::DocumentFull {
got: held,
limit: self.limits.max_document_chars,
});
}
state.try_edit(|doc| doc.insert_after(actor, after.as_ref(), &text))?
};
self.broadcast_ops(&ops);
Ok(ops)
}
CollabClientMessage::Delete { ids } => {
if ids.len() > self.limits.max_delete_ids {
return Err(CollabError::DeleteTooLarge {
got: ids.len(),
limit: self.limits.max_delete_ids,
});
}
let ops = {
let mut state = self.editable()?;
state.edit(|doc| doc.remove_known(&ids))
};
self.broadcast_ops(&ops);
Ok(ops)
}
CollabClientMessage::Replace { ids, after, text } => {
let added = text.chars().count();
if added > self.limits.max_insert_chars {
return Err(CollabError::InsertTooLarge {
got: added,
limit: self.limits.max_insert_chars,
});
}
if ids.len() > self.limits.max_delete_ids {
return Err(CollabError::DeleteTooLarge {
got: ids.len(),
limit: self.limits.max_delete_ids,
});
}
let ops = {
let mut state = self.editable()?;
if let Some(anchor) = after.as_ref()
&& !state.doc.knows(anchor)
{
return Err(CollabError::UnknownCharacter {
id: anchor.to_string(),
});
}
let held = state.doc.element_count() + state.doc.pending_len();
if held + added > self.limits.max_document_chars {
return Err(CollabError::DocumentFull {
got: held,
limit: self.limits.max_document_chars,
});
}
CollabText::preflight_insert(actor, state.doc.clock(), added)?;
let mut ops = state.edit(|doc| doc.remove_known(&ids));
ops.extend(
state.try_edit(|doc| doc.insert_after(actor, after.as_ref(), &text))?,
);
ops
};
self.broadcast_ops(&ops);
Ok(ops)
}
CollabClientMessage::Cursor { index } => {
{
let mut state = self.state.lock().expect("collab document lock poisoned");
set_cursor(&mut state.cursors, actor, index);
}
self.broadcast_presence();
Ok(Vec::new())
}
}
}
pub fn apply_remote(&self, ops: &[CollabOp]) -> Result<usize, CollabError> {
let (accepted, waiting) = {
let mut state = self.editable()?;
let held = state.doc.element_count() + state.doc.pending_len();
let novel = state.doc.novel_count(ops.iter());
if held + novel > self.limits.max_document_chars {
return Err(CollabError::DocumentFull {
got: held,
limit: self.limits.max_document_chars,
});
}
Self::check_pending(&state.doc, ops, novel)?;
let accepted: Vec<CollabOp> = ops
.iter()
.filter(|op| {
if state.doc.already_applied(op) || state.doc.holds_pending(op) {
return false;
}
let integrated = state.edit(|doc| doc.apply_ref(op));
integrated || state.doc.holds_pending(op)
})
.cloned()
.collect();
let waiting = state.doc.pending_len();
drop(state);
(accepted, waiting)
};
self.broadcast_ops(&accepted);
Ok(waiting)
}
pub fn merge_delivered(&self, ops: &[CollabOp]) -> Result<(), CollabError> {
let mut state = self.editable()?;
let held = state.doc.element_count() + state.doc.pending_len();
let novel = state.doc.novel_count(ops.iter());
if held + novel > self.limits.max_document_chars {
return Err(CollabError::DocumentFull {
got: held,
limit: self.limits.max_document_chars,
});
}
Self::check_pending(&state.doc, ops, novel)?;
state.edit(|doc| {
for op in ops {
doc.apply_ref(op);
}
});
drop(state);
Ok(())
}
fn check_pending(doc: &CollabText, ops: &[CollabOp], novel: usize) -> Result<(), CollabError> {
if doc.pending_len() + novel <= MAX_WIRE_PENDING {
return Ok(());
}
let mut trial = doc.clone();
trial.apply_all(ops.iter().cloned());
let got = trial.pending_len();
if got > MAX_WIRE_PENDING {
return Err(CollabError::CausalBufferFull {
got,
limit: MAX_WIRE_PENDING,
});
}
Ok(())
}
fn with_doc<T>(&self, f: impl FnOnce(&CollabText) -> T) -> T {
let state = self.state.lock().expect("collab document lock poisoned");
f(&state.doc)
}
fn broadcast_ops(&self, ops: &[CollabOp]) {
if ops.is_empty() {
return;
}
self.publish(&OpsFrame::Ops { ops });
}
fn broadcast_presence(&self) {
self.publish(&CollabServerMessage::Presence {
participants: self.participants(),
});
}
fn publish(&self, message: &impl Serialize) {
let Ok(json) = serde_json::to_string(message) else {
tracing::warn!(key = %self.key, "collab: failed to encode server message");
return;
};
let topic = self.topic_name();
if let Err(error) = self.channels.publish(topic, json) {
let delivered = serde_json::to_string(message).map_or(0, |json| {
self.channels
.backend()
.ensure_topic(topic)
.send(json.into())
.unwrap_or(0)
});
tracing::warn!(
key = %self.key,
?error,
delivered,
"collab: publish failed; delivered to this node's editors only"
);
}
}
}
impl std::fmt::Debug for CollabDoc {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("CollabDoc")
.field("key", &self.key)
.field("text", &self.text())
.finish_non_exhaustive()
}
}
pub struct CollabSession {
doc: CollabDoc,
actor: String,
presence: Option<PresenceHandle>,
}
impl CollabSession {
#[must_use]
pub const fn actor(&self) -> &str {
self.actor.as_str()
}
#[must_use]
pub const fn document(&self) -> &CollabDoc {
&self.doc
}
pub fn handle(&self, message: CollabClientMessage) -> Result<Vec<CollabOp>, CollabError> {
self.doc.handle(&self.actor, message)
}
#[must_use]
pub fn snapshot(&self) -> CollabServerMessage {
match self.doc.snapshot() {
CollabServerMessage::Snapshot {
elems,
pending,
participants,
..
} => CollabServerMessage::Snapshot {
elems,
pending,
participants,
actor: Some(self.actor.clone()),
},
other => other,
}
}
pub fn refresh(&self) {
if let Some(presence) = &self.presence {
presence.refresh();
}
}
}
impl Drop for CollabSession {
fn drop(&mut self) {
{
let mut state = self
.doc
.state
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
state.cursors.remove(&self.actor);
state.sessions = state.sessions.saturating_sub(1);
}
drop(self.presence.take());
self.doc.broadcast_presence();
}
}
pub async fn serve_socket(
doc: &CollabDoc,
actor: impl Into<String>,
label: impl Into<String>,
mut socket: crate::ws::WebSocket,
) {
use crate::ws::Message;
let mut updates = doc.subscribe();
let session = doc.join(actor, label);
if send_json(&mut socket, &session.snapshot()).await.is_err() {
return;
}
let mut heartbeat = tokio::time::interval(PRESENCE_REFRESH);
heartbeat.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
let mut lags = 0u32;
loop {
tokio::select! {
_ = heartbeat.tick() => {
session.refresh();
}
incoming = socket.recv() => {
let Some(Ok(message)) = incoming else { break };
let Message::Text(text) = message else {
if matches!(message, Message::Close(_)) {
break;
}
continue;
};
match serde_json::from_str::<CollabClientMessage>(&text) {
Ok(client_message) => {
if let Err(error) = session.handle(client_message) {
let refusal = CollabServerMessage::Error {
message: error.to_string(),
};
if send_json(&mut socket, &refusal).await.is_err() {
break;
}
}
}
Err(error) => {
let refusal = CollabServerMessage::Error {
message: format!("unreadable message: {error}"),
};
if send_json(&mut socket, &refusal).await.is_err() {
break;
}
}
}
}
update = updates.recv() => {
match update {
Ok(update) => {
let text = update.into_string();
let refused = if may_be_ops_frame(&text)
&& let Ok(CollabServerMessage::Ops { ops }) =
serde_json::from_str::<CollabServerMessage>(&text)
{
doc.merge_delivered(&ops)
.inspect_err(|error| {
tracing::warn!(
key = %doc.key(),
?error,
"collab: refused a delivered batch; \
this replica is behind"
);
})
.is_err()
} else {
false
};
if refused {
if send_json(&mut socket, &session.snapshot()).await.is_err() {
break;
}
continue;
}
if socket.send(Message::Text(text.into())).await.is_err() {
break;
}
}
Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => {
lags += 1;
if lags > MAX_LAG_RESENDS {
tracing::warn!(
key = %doc.key(),
"collab: editor cannot keep up; closing the socket"
);
break;
}
if send_json(&mut socket, &session.snapshot()).await.is_err() {
break;
}
}
Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
}
}
}
}
drop(session);
}
fn may_be_ops_frame(text: &str) -> bool {
#[derive(Deserialize)]
struct Tag<'a> {
#[serde(borrow, rename = "type")]
kind: std::borrow::Cow<'a, str>,
}
!serde_json::from_str::<Tag<'_>>(text).is_ok_and(|tag| tag.kind != "ops")
}
async fn send_json(
socket: &mut crate::ws::WebSocket,
message: &CollabServerMessage,
) -> Result<(), ()> {
let json = serde_json::to_string(message).map_err(|_| ())?;
socket
.send(crate::ws::Message::Text(json.into()))
.await
.map_err(|_| ())
}
#[cfg(test)]
mod tests {
use super::*;
fn hub() -> CollabHub {
let channels = Channels::new(64);
let presence = Presence::new(channels.clone());
CollabHub::new(channels, presence)
}
#[test]
fn an_ops_frame_encodes_like_the_server_message() {
let mut doc = CollabText::new();
let mut ops = doc.insert("ada#1", 0, "hi").expect("collab edit refused");
ops.extend(doc.remove(0, 1));
let borrowed = serde_json::to_string(&OpsFrame::Ops { ops: &ops }).expect("encode");
let owned = serde_json::to_string(&CollabServerMessage::Ops { ops }).expect("encode");
assert_eq!(borrowed, owned);
}
#[test]
fn an_edit_broadcasts_exactly_the_operations_it_returns() {
let hub = hub();
let doc = hub
.open_with("notes:40:body", || {
CollabText::from_text("seed", "hi").expect("collab edit refused")
})
.expect("open the document");
let mut watcher = doc.subscribe();
let ops = doc
.handle(
"ada",
CollabClientMessage::Insert {
after: doc.document().id_at(1),
text: "!".to_owned(),
},
)
.expect("insert");
let frame = watcher.try_recv().expect("the edit was broadcast");
assert_eq!(
frame.as_str(),
serde_json::to_string(&CollabServerMessage::Ops { ops }).expect("encode")
);
}
#[test]
fn every_handle_broadcasts_on_the_documents_topic() {
let channels = Channels::new(64);
let presence = Presence::new(channels.clone());
let hub = CollabHub::new(channels.clone(), presence);
let first = hub.document("notes:41:body").expect("open the document");
let second = first.clone();
let mut rx = channels.subscribe(&topic_for("notes:41:body"));
second
.handle(
"ada",
CollabClientMessage::Insert {
after: None,
text: "x".to_owned(),
},
)
.expect("insert");
assert!(
rx.try_recv().is_ok(),
"the clone publishes on the document's topic"
);
assert_eq!(first.topic(), "collab:notes:41:body");
assert_eq!(second.topic(), first.topic());
let third = second.clone();
assert_eq!(third.topic_name(), "collab:notes:41:body");
assert_eq!(third.topic_name(), second.topic_name());
}
#[test]
fn only_a_frame_tagged_as_something_else_skips_the_full_decode() {
let ops =
serde_json::to_string(&CollabServerMessage::Ops { ops: Vec::new() }).expect("encode");
let presence = serde_json::to_string(&CollabServerMessage::Presence {
participants: vec![CollabParticipant {
actor: "ada#1".to_owned(),
label: "Ada".to_owned(),
cursor: Some(2),
}],
})
.expect("encode");
assert!(may_be_ops_frame(&ops));
assert!(!may_be_ops_frame(&presence));
assert!(!may_be_ops_frame(r#"{"type":"error","message":"no"}"#));
assert!(
may_be_ops_frame(r#"{"ops":[],"type":"ops"}"#),
"the tag need not come first"
);
assert!(
may_be_ops_frame(r#"{"type":"\u006fps","ops":[]}"#),
"an escaped tag reads as what it spells"
);
assert!(
!may_be_ops_frame(r#"{"type":"pr\u0065sence","participants":[]}"#),
"an escaped tag reads as what it spells"
);
assert!(may_be_ops_frame("not json"));
assert!(may_be_ops_frame(r#"{"ops":[]}"#));
assert!(may_be_ops_frame(r#"["ops",[]]"#));
for frame in [
ops.as_str(),
presence.as_str(),
r#"{"ops":[],"type":"ops"}"#,
r#"{"type":"\u006fps","ops":[]}"#,
r#"{"type":"pr\u0065sence","participants":[]}"#,
r#"["ops",[]]"#,
r#"{"ops":[]}"#,
"not json",
] {
let merged = matches!(
serde_json::from_str::<CollabServerMessage>(frame),
Ok(CollabServerMessage::Ops { .. })
);
assert!(
!merged || may_be_ops_frame(frame),
"skipped an ops frame: {frame}"
);
}
}
#[test]
fn set_cursor_overwrites_a_known_caret_and_adds_a_new_one() {
let mut cursors = BTreeMap::new();
set_cursor(&mut cursors, "ada#1", 1);
set_cursor(&mut cursors, "linus#2", 5);
set_cursor(&mut cursors, "ada#1", 4);
assert_eq!(
cursors.into_iter().collect::<Vec<_>>(),
vec![("ada#1".to_owned(), 4), ("linus#2".to_owned(), 5)]
);
}
#[test]
fn a_repeated_cursor_move_updates_the_caret_in_place() {
let hub = hub();
let doc = hub.document("notes:42:body").expect("open the document");
let ada = doc.join("ada", "Ada");
for index in [1, 4, 2] {
ada.handle(CollabClientMessage::Cursor { index })
.expect("cursor");
}
let cursors: Vec<Option<usize>> =
doc.participants().into_iter().map(|p| p.cursor).collect();
assert_eq!(cursors, vec![Some(2)]);
}
#[test]
fn participants_pair_each_editor_with_its_own_caret() {
let hub = hub();
let doc = hub.document("notes:43:body").expect("open the document");
let ada = doc.join("ada", "Ada");
let _linus = doc.join("linus", "Linus");
ada.handle(CollabClientMessage::Cursor { index: 3 })
.expect("cursor");
let participants = doc.participants();
let seen: Vec<(&str, Option<usize>)> = participants
.iter()
.map(|p| (p.label.as_str(), p.cursor))
.collect();
assert_eq!(seen, vec![("Ada", Some(3)), ("Linus", None)]);
}
}