use super::client::FirestoreClient;
use super::models::{
int_val, str_val, FirestoreDocument, FirestoreValue, RoomAccessFields, RoomDocFields,
RoomMemberFields,
};
use super::schema::AppNamespace;
use anyhow::Result;
use async_trait::async_trait;
use futures::stream::BoxStream;
use serde::{Deserialize, Serialize};
#[cfg(target_arch = "wasm32")]
fn log_info(message: &str) {
web_sys::console::log_1(&wasm_bindgen::JsValue::from_str(message));
}
#[cfg(not(target_arch = "wasm32"))]
fn log_info(message: &str) {
println!("{}", message);
}
#[cfg(target_arch = "wasm32")]
fn log_error(message: &str) {
web_sys::console::error_1(&wasm_bindgen::JsValue::from_str(message));
}
#[cfg(not(target_arch = "wasm32"))]
fn log_error(message: &str) {
eprintln!("{}", message);
}
const MEMBER_TTL_MS: i64 = 600_000;
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
pub struct RoomMemberSnapshot {
#[serde(rename = "nodeId")]
pub node_id: String,
#[serde(rename = "userId")]
pub user_id: String,
pub ticket: String,
#[serde(rename = "joinedAt")]
pub joined_at: i64,
#[serde(rename = "lastSeenAt")]
pub last_seen_at: i64,
#[serde(rename = "expiresAt", skip_serializing_if = "Option::is_none")]
pub expires_at: Option<i64>,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(tag = "type", rename_all = "lowercase")]
pub enum RoomMemberEvent {
Modified {
member: RoomMemberSnapshot,
},
Removed {
#[serde(rename = "memberId")]
member_id: String,
},
}
#[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
#[cfg_attr(not(target_arch = "wasm32"), async_trait)]
pub trait RoomBackend: crate::signaling::SendSyncBound {
async fn create_room(
&self,
room_id: &str,
user_id: &str,
ticket_str: &str,
my_node_id: &str,
app_tag: &str,
max_members: Option<u32>,
) -> Result<bool>;
async fn get_members(
&self,
room_id: &str,
my_node_id: &str,
app_tag: &str,
) -> Result<Vec<FirestoreDocument<RoomMemberFields>>>;
async fn join_room(
&self,
room_id: &str,
user_id: &str,
ticket_str: &str,
my_node_id: &str,
app_tag: &str,
) -> Result<()>;
async fn leave_room(&self, room_id: &str, my_node_id: &str, app_tag: &str) -> Result<()>;
async fn heartbeat_tick(&self, room_id: &str, member_id: &str, app_tag: &str) -> Result<()>;
async fn subscribe_members(
&self,
room_id: &str,
my_node_id: &str,
app_tag: &str,
) -> Result<BoxStream<'static, Result<Vec<RoomMemberEvent>>>>;
}
#[derive(Clone)]
pub struct FirebaseRoomBackend {
client: FirestoreClient,
namespace: AppNamespace,
}
impl FirebaseRoomBackend {
pub fn new(client: FirestoreClient, app_tag: String) -> Self {
Self {
client,
namespace: AppNamespace::new(app_tag),
}
}
fn effective_max_members(max_members: Option<u32>) -> i64 {
max_members.unwrap_or(5) as i64
}
fn build_room_create_payload(
&self,
user_id: &str,
max_members: Option<u32>,
) -> serde_json::Value {
serde_json::json!({
"appTag": str_val(self.namespace.app_tag()),
"hostId": str_val(user_id),
"memberCount": int_val(0),
"maxMembers": int_val(Self::effective_max_members(max_members)),
"status": str_val("open"),
})
}
fn build_member_fields(&self, user_id: &str, ticket_str: &str, now: i64) -> RoomMemberFields {
RoomMemberFields {
app_tag: Some(str_val(self.namespace.app_tag())),
node_id: None,
user_id: Some(str_val(user_id)),
ticket: Some(str_val(ticket_str)),
joined_at: Some(super::models::int_val(now)),
last_seen_at: Some(super::models::int_val(now)),
expires_at: Some(super::models::int_val(now.saturating_add(MEMBER_TTL_MS))),
}
}
fn build_leave_room_member_count_decrement() -> serde_json::Value {
let count_str = (-1_i64).to_string();
serde_json::json!({
"memberCount": { "integerValue": count_str }
})
}
fn build_heartbeat_update_data(now: i64) -> serde_json::Value {
let expires_at = now.saturating_add(MEMBER_TTL_MS);
let now_str = now.to_string();
let expires_str = expires_at.to_string();
serde_json::json!({
"lastSeenAt": { "integerValue": now_str },
"expiresAt": { "integerValue": expires_str }
})
}
fn is_room_full(member_count: i64, max_members: i64) -> bool {
max_members >= 0 && member_count >= max_members
}
fn should_include_member_doc(
doc_name: &str,
my_node_id: &str,
fields: &RoomMemberFields,
now_ms: i64,
) -> bool {
!doc_name.ends_with(my_node_id) && !Self::is_member_expired(fields, now_ms)
}
fn room_access_fields(&self, room_id: &str, user_id: &str, now: i64) -> RoomAccessFields {
RoomAccessFields {
app_tag: Some(str_val(self.namespace.app_tag())),
room_id: Some(str_val(room_id)),
user_id: Some(str_val(user_id)),
created_at: Some(int_val(now)),
updated_at: Some(int_val(now)),
}
}
async fn ensure_room_access(&self, room_id: &str, user_id: &str) -> Result<()> {
let collection = self.namespace.room_access_collection(room_id);
let now = super::now_millis_u64() as i64;
let fields = self.room_access_fields(room_id, user_id, now);
self.client
.update_document(&collection, user_id, &fields, None)
.await?;
Ok(())
}
async fn upsert_room_member(
&self,
member_collection: &str,
my_node_id: &str,
member_data: &RoomMemberFields,
) -> Result<()> {
match self
.client
.create_document(member_collection, Some(my_node_id), member_data)
.await
{
Ok(_) => Ok(()),
Err(error) if Self::is_already_exists_error(&error) => {
self.client
.update_document(member_collection, my_node_id, member_data, None)
.await
}
Err(error) => Err(error),
}
}
fn is_already_exists_error(error: &anyhow::Error) -> bool {
let message = error.to_string();
message.contains("ALREADY_EXISTS") || message.contains("already exists")
}
async fn check_room_capacity(&self, room_id: &str) -> Result<()> {
let room_collection = self.namespace.rooms_collection();
let room_doc = self
.client
.get_document::<RoomDocFields>(&room_collection, room_id)
.await?;
let member_count =
Self::parse_i64_field(room_doc.fields.member_count.as_ref()).unwrap_or(0);
let max_members = Self::parse_i64_field(room_doc.fields.max_members.as_ref()).unwrap_or(5);
if Self::is_room_full(member_count, max_members) {
return Err(anyhow::anyhow!(
"Room is full ({}/{} members)",
member_count,
max_members
));
}
Ok(())
}
fn parse_i64_field(value: Option<&FirestoreValue>) -> Option<i64> {
value
.and_then(|field| field.integer_value.as_ref())
.and_then(|raw| raw.parse::<i64>().ok())
}
fn is_member_expired(fields: &RoomMemberFields, now_ms: i64) -> bool {
if let Some(expires_at) = Self::parse_i64_field(fields.expires_at.as_ref()) {
return expires_at <= now_ms;
}
if let Some(last_seen_at) = Self::parse_i64_field(fields.last_seen_at.as_ref()) {
return last_seen_at.saturating_add(MEMBER_TTL_MS) <= now_ms;
}
if let Some(joined_at) = Self::parse_i64_field(fields.joined_at.as_ref()) {
return joined_at.saturating_add(MEMBER_TTL_MS) <= now_ms;
}
false
}
fn to_room_member_snapshot(doc_id: &str, fields: RoomMemberFields) -> RoomMemberSnapshot {
RoomMemberSnapshot {
node_id: fields
.node_id
.and_then(|value| value.string_value)
.unwrap_or_else(|| doc_id.to_string()),
user_id: fields
.user_id
.and_then(|value| value.string_value)
.unwrap_or_default(),
ticket: fields
.ticket
.and_then(|value| value.string_value)
.unwrap_or_default(),
joined_at: Self::parse_i64_field(fields.joined_at.as_ref()).unwrap_or_default(),
last_seen_at: Self::parse_i64_field(fields.last_seen_at.as_ref()).unwrap_or_default(),
expires_at: Self::parse_i64_field(fields.expires_at.as_ref()),
}
}
fn split_collection_path(collection: &str) -> (String, String) {
let parts: Vec<&str> = collection.rsplitn(2, '/').collect();
if parts.len() == 2 {
(parts[1].to_string(), parts[0].to_string())
} else {
(String::new(), collection.to_string())
}
}
fn parse_member_change(
document: &serde_json::Value,
my_node_id: &str,
) -> Option<RoomMemberEvent> {
let doc_name = document.get("name")?.as_str()?;
let doc_id = doc_name.split('/').last()?.to_string();
if doc_id == my_node_id {
return None;
}
let fields: RoomMemberFields =
serde_json::from_value(document.get("fields")?.clone()).ok()?;
let now = super::now_millis_u64() as i64;
if Self::is_member_expired(&fields, now) {
return Some(RoomMemberEvent::Removed { member_id: doc_id });
}
Some(RoomMemberEvent::Modified {
member: Self::to_room_member_snapshot(&doc_id, fields),
})
}
fn spawn_room_member_listener(
tx: tokio::sync::mpsc::UnboundedSender<Result<Vec<RoomMemberEvent>>>,
collection: String,
my_node_id: String,
project_id: String,
token_provider: std::sync::Arc<
std::sync::Mutex<Box<dyn Fn() -> Option<String> + Send + Sync>>,
>,
) {
let (parent_path, collection_id) = Self::split_collection_path(&collection);
let spawn_body = async move {
let mut listener = super::listener::FirestoreListener::new_with_label(
project_id,
token_provider,
format!("room-members:{}", collection),
);
listener.add_target(11, &parent_path, &collection_id);
loop {
if tx.is_closed() {
break;
}
match listener.poll().await {
super::listener::ListenOutcome::Events(events) => {
let mut member_events = Vec::new();
for event in events {
match event {
super::webchannel::ListenEvent::DocumentChange {
document, ..
} => {
if let Some(member_event) =
Self::parse_member_change(&document, &my_node_id)
{
member_events.push(member_event);
}
}
super::webchannel::ListenEvent::DocumentDelete {
document_path,
..
}
| super::webchannel::ListenEvent::DocumentRemove {
document_path,
..
} => {
let member_id = document_path
.split('/')
.last()
.unwrap_or(&document_path)
.to_string();
if member_id != my_node_id {
member_events.push(RoomMemberEvent::Removed { member_id });
}
}
super::webchannel::ListenEvent::TargetChange { .. }
| super::webchannel::ListenEvent::Filter { .. } => {}
}
}
if !member_events.is_empty() && tx.send(Ok(member_events)).is_err() {
break;
}
}
super::listener::ListenOutcome::AuthError => {
log_info(&format!(
"[OPENRTC][ROOM][{}] auth error, reconnecting...",
listener.debug_label()
));
listener.reset_errors();
Self::sleep_ms(1_000).await;
}
super::listener::ListenOutcome::FatalError(message) => {
log_error(&format!(
"[OPENRTC][ROOM][{}] fatal error: {}",
listener.debug_label(),
message
));
let _ = tx.send(Err(anyhow::anyhow!(message)));
break;
}
super::listener::ListenOutcome::TransientError(message) => {
let backoff = listener.backoff_duration();
log_info(&format!(
"[OPENRTC][ROOM][{}] transient error: {}, backing off {:?}",
listener.debug_label(),
message,
backoff
));
Self::sleep_ms(backoff.as_millis() as u64).await;
}
}
}
};
#[cfg(target_arch = "wasm32")]
wasm_bindgen_futures::spawn_local(spawn_body);
#[cfg(not(target_arch = "wasm32"))]
tokio::spawn(spawn_body);
}
async fn sleep_ms(ms: u64) {
#[cfg(target_arch = "wasm32")]
{
let _ = gloo_timers::future::sleep(std::time::Duration::from_millis(ms)).await;
}
#[cfg(not(target_arch = "wasm32"))]
{
tokio::time::sleep(std::time::Duration::from_millis(ms)).await;
}
}
}
#[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
#[cfg_attr(not(target_arch = "wasm32"), async_trait)]
impl RoomBackend for FirebaseRoomBackend {
async fn create_room(
&self,
room_id: &str,
user_id: &str,
ticket_str: &str,
my_node_id: &str,
_app_tag: &str,
max_members: Option<u32>,
) -> Result<bool> {
#[cfg(target_arch = "wasm32")]
web_sys::console::log_1(&wasm_bindgen::JsValue::from_str(&format!(
"[OPENRTC][ROOM] create_room start room_id={} user_id={} node_id={} app_tag={}",
room_id,
user_id,
my_node_id,
self.namespace.app_tag()
)));
let room_collection = self.namespace.rooms_collection();
let room_data = self.build_room_create_payload(user_id, max_members);
if let Err(error) = self
.client
.create_document(&room_collection, Some(room_id), &room_data)
.await
{
if Self::is_already_exists_error(&error) {
return Ok(false);
}
return Err(error);
}
let member_collection = self.namespace.room_members_collection(room_id);
let now = super::now_millis_u64() as i64;
let member_data = self.build_member_fields(user_id, ticket_str, now);
self.upsert_room_member(&member_collection, my_node_id, &member_data)
.await?;
self.ensure_room_access(room_id, user_id).await?;
Ok(true)
}
async fn get_members(
&self,
room_id: &str,
my_node_id: &str,
_app_tag: &str,
) -> Result<Vec<FirestoreDocument<RoomMemberFields>>> {
let member_collection = self.namespace.room_members_collection(room_id);
let docs = self
.client
.list_documents::<RoomMemberFields>(&member_collection)
.await?;
let now = super::now_millis_u64() as i64;
Ok(docs
.into_iter()
.filter(|doc| Self::should_include_member_doc(&doc.name, my_node_id, &doc.fields, now))
.collect())
}
async fn join_room(
&self,
room_id: &str,
user_id: &str,
ticket_str: &str,
my_node_id: &str,
_app_tag: &str,
) -> Result<()> {
#[cfg(target_arch = "wasm32")]
web_sys::console::log_1(&wasm_bindgen::JsValue::from_str(&format!(
"[OPENRTC][ROOM] join_room start room_id={} user_id={} node_id={} app_tag={}",
room_id,
user_id,
my_node_id,
self.namespace.app_tag()
)));
self.check_room_capacity(room_id).await?;
let member_collection = self.namespace.room_members_collection(room_id);
let now = super::now_millis_u64() as i64;
let member_data = self.build_member_fields(user_id, ticket_str, now);
self.upsert_room_member(&member_collection, my_node_id, &member_data)
.await?;
self.ensure_room_access(room_id, user_id).await?;
Ok(())
}
async fn leave_room(&self, room_id: &str, my_node_id: &str, _app_tag: &str) -> Result<()> {
let member_collection = self.namespace.room_members_collection(room_id);
let doc_path = format!("{}/{}", member_collection, my_node_id);
self.client.delete_document(&doc_path).await?;
if !self.namespace.is_space() {
let room_collection = self.namespace.rooms_collection();
let decrement = Self::build_leave_room_member_count_decrement();
let _ = self
.client
.update_document(
&room_collection,
room_id,
&decrement,
Some(vec!["memberCount"]),
)
.await;
}
Ok(())
}
async fn heartbeat_tick(&self, room_id: &str, member_id: &str, _app_tag: &str) -> Result<()> {
let collection = self.namespace.room_members_collection(room_id);
let now = super::now_millis_u64() as i64;
let update_data = Self::build_heartbeat_update_data(now);
self.client
.update_document(
&collection,
member_id,
&update_data,
Some(vec!["lastSeenAt", "expiresAt"]),
)
.await?;
Ok(())
}
async fn subscribe_members(
&self,
room_id: &str,
my_node_id: &str,
_app_tag: &str,
) -> Result<BoxStream<'static, Result<Vec<RoomMemberEvent>>>> {
let collection = self.namespace.room_members_collection(room_id);
let project_id = self.client.project_id().to_string();
let token_provider = self.client.token_provider();
let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
Self::spawn_room_member_listener(
tx,
collection,
my_node_id.to_string(),
project_id,
token_provider,
);
use futures::StreamExt;
use tokio_stream::wrappers::UnboundedReceiverStream;
Ok(UnboundedReceiverStream::new(rx).boxed())
}
}
#[cfg(test)]
mod tests {
use super::*;
const TEST_APP_TAG: &str = "app_room_semantics_test";
fn make_backend() -> FirebaseRoomBackend {
let client = FirestoreClient::new(
"test-project".to_string(),
Box::new(|| Some("test-token".to_string())),
);
FirebaseRoomBackend::new(client, TEST_APP_TAG.to_string())
}
fn make_member_fields(
user_id: &str,
ticket: &str,
node_id: Option<&str>,
joined_at: i64,
last_seen_at: i64,
expires_at: Option<i64>,
) -> RoomMemberFields {
RoomMemberFields {
app_tag: Some(str_val(TEST_APP_TAG)),
node_id: node_id.map(str_val),
user_id: Some(str_val(user_id)),
ticket: Some(str_val(ticket)),
joined_at: Some(int_val(joined_at)),
last_seen_at: Some(int_val(last_seen_at)),
expires_at: expires_at.map(int_val),
}
}
#[test]
fn room_create_payload_uses_free_tier_default_and_respects_override() {
let backend = make_backend();
let default_payload = backend.build_room_create_payload("host-user", None);
assert_eq!(
default_payload["appTag"]["stringValue"].as_str(),
Some(TEST_APP_TAG)
);
assert_eq!(
default_payload["hostId"]["stringValue"].as_str(),
Some("host-user")
);
assert_eq!(
default_payload["memberCount"]["integerValue"].as_str(),
Some("0")
);
assert_eq!(
default_payload["maxMembers"]["integerValue"].as_str(),
Some("5")
);
assert_eq!(
default_payload["status"]["stringValue"].as_str(),
Some("open")
);
let overridden_payload = backend.build_room_create_payload("host-user", Some(42));
assert_eq!(
overridden_payload["maxMembers"]["integerValue"].as_str(),
Some("42")
);
}
#[test]
fn member_fields_capture_join_semantics_for_create_and_join_paths() {
let backend = make_backend();
let now = 1_000_i64;
let fields = backend.build_member_fields("user-1", "ticket-1", now);
assert_eq!(
fields
.app_tag
.as_ref()
.and_then(|value| value.string_value.as_deref()),
Some(TEST_APP_TAG)
);
assert_eq!(
fields
.user_id
.as_ref()
.and_then(|value| value.string_value.as_deref()),
Some("user-1")
);
assert_eq!(
fields
.ticket
.as_ref()
.and_then(|value| value.string_value.as_deref()),
Some("ticket-1")
);
assert_eq!(
fields
.joined_at
.as_ref()
.and_then(|value| value.integer_value.as_deref()),
Some("1000")
);
assert_eq!(
fields
.last_seen_at
.as_ref()
.and_then(|value| value.integer_value.as_deref()),
Some("1000")
);
assert_eq!(
fields
.expires_at
.as_ref()
.and_then(|value| value.integer_value.as_deref()),
Some((now + MEMBER_TTL_MS).to_string().as_str())
);
}
#[test]
fn leave_room_decrements_member_count_with_firestore_integer_patch() {
let payload = FirebaseRoomBackend::build_leave_room_member_count_decrement();
assert_eq!(payload["memberCount"]["integerValue"].as_str(), Some("-1"));
}
#[test]
fn heartbeat_updates_last_seen_and_extends_expiry() {
let now = 9_000_i64;
let payload = FirebaseRoomBackend::build_heartbeat_update_data(now);
assert_eq!(payload["lastSeenAt"]["integerValue"].as_str(), Some("9000"));
assert_eq!(
payload["expiresAt"]["integerValue"].as_str(),
Some((now + MEMBER_TTL_MS).to_string().as_str())
);
}
#[test]
fn room_capacity_enforcement_honors_limits_and_unlimited_tier() {
assert!(FirebaseRoomBackend::is_room_full(5, 5));
assert!(FirebaseRoomBackend::is_room_full(9, 5));
assert!(!FirebaseRoomBackend::is_room_full(4, 5));
assert!(!FirebaseRoomBackend::is_room_full(999, -1));
}
#[test]
fn member_expiry_checks_support_expires_last_seen_and_joined_at_fallbacks() {
let now = 50_000_i64;
let expired_by_expires_at = make_member_fields(
"user-a",
"ticket-a",
Some("node-a"),
now - 100,
now - 100,
Some(now - 1),
);
assert!(FirebaseRoomBackend::is_member_expired(
&expired_by_expires_at,
now,
));
let expired_by_last_seen = RoomMemberFields {
app_tag: Some(str_val(TEST_APP_TAG)),
node_id: Some(str_val("node-b")),
user_id: Some(str_val("user-b")),
ticket: Some(str_val("ticket-b")),
joined_at: None,
last_seen_at: Some(int_val(now - MEMBER_TTL_MS - 1)),
expires_at: None,
};
assert!(FirebaseRoomBackend::is_member_expired(
&expired_by_last_seen,
now,
));
let expired_by_joined = RoomMemberFields {
app_tag: Some(str_val(TEST_APP_TAG)),
node_id: Some(str_val("node-c")),
user_id: Some(str_val("user-c")),
ticket: Some(str_val("ticket-c")),
joined_at: Some(int_val(now - MEMBER_TTL_MS - 1)),
last_seen_at: None,
expires_at: None,
};
assert!(FirebaseRoomBackend::is_member_expired(
&expired_by_joined,
now,
));
let active_member = make_member_fields(
"user-d",
"ticket-d",
Some("node-d"),
now - 100,
now - 10,
Some(now + 5_000),
);
assert!(!FirebaseRoomBackend::is_member_expired(&active_member, now));
}
#[test]
fn member_visibility_and_cleanup_rules_filter_self_and_expired_docs() {
let now = 75_000_i64;
let active_other = make_member_fields(
"user-other",
"ticket-other",
Some("node-other"),
now - 100,
now - 50,
Some(now + 5_000),
);
let expired_other = make_member_fields(
"user-expired",
"ticket-expired",
Some("node-expired"),
now - 100,
now - 100,
Some(now - 1),
);
assert!(FirebaseRoomBackend::should_include_member_doc(
"rooms/room-a/members/member-other",
"member-self",
&active_other,
now,
));
assert!(!FirebaseRoomBackend::should_include_member_doc(
"rooms/room-a/members/member-self",
"member-self",
&active_other,
now,
));
assert!(!FirebaseRoomBackend::should_include_member_doc(
"rooms/room-a/members/member-expired",
"member-self",
&expired_other,
now,
));
}
#[test]
fn parse_member_change_maps_modified_removed_and_self_events() {
let active_fields = make_member_fields(
"user-active",
"ticket-active",
Some("node-active"),
10,
20,
Some(i64::MAX),
);
let active_document = serde_json::json!({
"name": "projects/p/databases/(default)/documents/apps/app/rooms/room-1/members/member-active",
"fields": serde_json::to_value(active_fields).unwrap(),
});
let active_event =
FirebaseRoomBackend::parse_member_change(&active_document, "member-self")
.expect("active member should parse");
match active_event {
RoomMemberEvent::Modified { member } => {
assert_eq!(member.node_id, "node-active");
assert_eq!(member.user_id, "user-active");
assert_eq!(member.ticket, "ticket-active");
}
RoomMemberEvent::Removed { .. } => panic!("expected modified event"),
}
let expired_fields = make_member_fields(
"user-expired",
"ticket-expired",
Some("node-expired"),
10,
20,
Some(0),
);
let expired_document = serde_json::json!({
"name": "projects/p/databases/(default)/documents/apps/app/rooms/room-1/members/member-expired",
"fields": serde_json::to_value(expired_fields).unwrap(),
});
let expired_event =
FirebaseRoomBackend::parse_member_change(&expired_document, "member-self")
.expect("expired member should map to removed");
match expired_event {
RoomMemberEvent::Removed { member_id } => {
assert_eq!(member_id, "member-expired");
}
RoomMemberEvent::Modified { .. } => panic!("expected removed event"),
}
let self_document = serde_json::json!({
"name": "projects/p/databases/(default)/documents/apps/app/rooms/room-1/members/member-self",
"fields": serde_json::to_value(make_member_fields(
"user-self",
"ticket-self",
Some("node-self"),
10,
20,
Some(i64::MAX),
))
.unwrap(),
});
assert!(FirebaseRoomBackend::parse_member_change(&self_document, "member-self").is_none());
}
}