room_event.rs
In apps/repo-worker/src/ · At 619cbadCopy the commit ID
Preview
Code · 10 KiB// SPDX-License-Identifier: MIT OR Apache-2.0
//
// The canonical `/watch` wire encoding — a `RoomEvent` proto-JSON payload —
// shared by the RefStore DO (producer, `worker_impl/refstore.rs::broadcast`)
// and the WatchRefs Connect-streaming bridge (consumer,
// `worker_impl/service.rs::bridge_watch_socket`). Declared once, host+wasm
// target-independent (like `envelope.rs`/`refs.rs`/`chat.rs`), so a field
// rename can't silently desync the two sides, and the hex<->bytes
// translation this module owns is exercised by this module's tests on the
// host — no wasm32 target or running Worker required.
//
// Replaces the old ad hoc, hand-tagged `WatchFrame` enum + separate untyped
// `PresenceJson` struct: every event kind (commit/chat/reaction/presence)
// the DO broadcasts is now ONE proto-JSON `RoomEvent`, so the raw
// `/watch/<room>` WebSocket fallback and the Connect `WatchRefs` stream see
// the exact same schema instead of two hand-parsed dialects — see mkit#705.
// `bytes` fields (object/message ids, pubkeys) are base64 on the wire (proto3
// JSON canonical mapping via buffa's `json_helpers::opt_bytes`), NOT the hex
// the old `WatchFrame` used — callers on both sides go through this module's
// hex<->bytes conversions rather than assuming either encoding.
use crate::proto::mkit::repo::v1::room_event::Event;
use crate::proto::mkit::repo::v1::{
ChatMessage, PresenceEvent, PresenceMember, ReactionEvent, RefEvent, RoomEvent,
};
/// Build the `commit` variant of `RoomEvent` from the DO's hex-string fields
/// (SQLite stores ref values as hex; the wire proto field is raw `bytes`).
/// `object_id_hex`/`author_pubkey_hex` that fail to decode become an absent
/// field rather than failing the whole event — a client seeing a ref name
/// with no object id is a smaller failure than dropping the event entirely.
#[must_use]
pub fn commit_event(
name: String,
object_id_hex: &str,
author_pubkey_hex: Option<&str>,
) -> RoomEvent {
RoomEvent {
event: Some(Event::Commit(Box::new(RefEvent {
name: Some(name),
object_id: hex::decode(object_id_hex).ok(),
author_pubkey: author_pubkey_hex.and_then(|s| hex::decode(s).ok()),
..Default::default()
}))),
..Default::default()
}
}
/// Build the `chat` variant of `RoomEvent`. `created_at` is epoch-ms; populates
/// both the deprecated `created_at` field and its unambiguous
/// `created_at_unix_ms` sibling (mkit#795) so old and new clients alike
/// decode the right value during the migration.
#[must_use]
pub fn chat_event(
message_id_hex: &str,
author_pubkey_hex: &str,
text: String,
created_at: i64,
seq: u64,
) -> RoomEvent {
RoomEvent {
event: Some(Event::Chat(Box::new(ChatMessage {
message_id: hex::decode(message_id_hex).ok(),
author_pubkey: hex::decode(author_pubkey_hex).ok(),
text: Some(text),
created_at: Some(created_at),
seq: Some(seq),
created_at_unix_ms: Some(created_at),
..Default::default()
}))),
..Default::default()
}
}
/// Build the `reaction` variant of `RoomEvent` — the live on/off toggle, NOT
/// the stored-row shape `ListReactions` returns.
#[must_use]
pub fn reaction_event(
target_id: String,
emoji: String,
author_pubkey_hex: &str,
active: bool,
count: u32,
) -> RoomEvent {
RoomEvent {
event: Some(Event::Reaction(Box::new(ReactionEvent {
target_id: Some(target_id),
emoji: Some(emoji),
author_pubkey: hex::decode(author_pubkey_hex).ok(),
active: Some(active),
count: Some(count),
..Default::default()
}))),
..Default::default()
}
}
/// Build the `presence` variant of `RoomEvent`. `members` is `(pubkey_hex,
/// since_epoch_ms)`; a member whose pubkey fails to decode is dropped from
/// the roster rather than failing the whole presence broadcast. Populates
/// both the deprecated `since` field and its unambiguous `since_unix_ms`
/// sibling (mkit#795) so old and new clients alike decode the right value
/// during the migration.
#[must_use]
pub fn presence_event(members: Vec<(String, i64)>, viewers: u32) -> RoomEvent {
RoomEvent {
event: Some(Event::Presence(Box::new(PresenceEvent {
members: members
.into_iter()
.filter_map(|(pubkey_hex, since)| {
Some(PresenceMember {
author_pubkey: Some(hex::decode(&pubkey_hex).ok()?),
since: Some(since),
since_unix_ms: Some(since),
..Default::default()
})
})
.collect(),
viewers: Some(viewers),
..Default::default()
}))),
..Default::default()
}
}
/// Serialize a `RoomEvent` to its canonical proto-JSON wire form. `None` on a
/// (practically unreachable, since every field is plain owned data)
/// serialize failure — callers must skip the frame rather than fan out an
/// empty/partial payload.
#[must_use]
pub fn to_json(event: &RoomEvent) -> Option<String> {
serde_json::to_string(event).ok()
}
/// Decode one raw `/watch` text frame into a `RoomEvent`. Returns `None` (not
/// an error) for malformed JSON — the caller (the WatchRefs Connect bridge)
/// must skip an unparseable frame rather than tear down the whole stream.
#[must_use]
pub fn decode(raw: &str) -> Option<RoomEvent> {
serde_json::from_str(raw).ok()
}
#[cfg(test)]
mod tests {
use super::*;
fn hex32(byte: u8) -> String {
hex::encode([byte; 32])
}
#[test]
fn commit_event_round_trips_through_json() {
let oid = hex32(0xab);
let author = hex32(0x11);
let event = commit_event("refs/heads/main".to_owned(), &oid, Some(&author));
let json = to_json(&event).expect("serializes");
let decoded = decode(&json).expect("decodes");
let Some(Event::Commit(commit)) = decoded.event else {
panic!("expected Commit variant, got {:?}", decoded.event);
};
assert_eq!(commit.name.as_deref(), Some("refs/heads/main"));
assert_eq!(commit.object_id, Some(hex::decode(&oid).unwrap()));
assert_eq!(commit.author_pubkey, Some(hex::decode(&author).unwrap()));
}
#[test]
fn commit_event_with_absent_author() {
let oid = hex32(0x02);
let event = commit_event("refs/heads/x".to_owned(), &oid, None);
let Some(Event::Commit(commit)) = event.event else {
panic!("expected Commit variant");
};
assert_eq!(commit.author_pubkey, None);
}
#[test]
fn commit_event_malformed_hex_degrades_the_field_not_the_event() {
// A corrupt hex field degrades to `None` on that field rather than
// failing the whole event.
let event = commit_event("refs/heads/main".to_owned(), "not-hex", None);
let Some(Event::Commit(commit)) = event.event else {
panic!("expected Commit variant");
};
assert_eq!(commit.object_id, None);
}
#[test]
fn chat_event_round_trips_through_json() {
let msg_id = hex32(0xcc);
let author = hex32(0x22);
let event = chat_event(&msg_id, &author, "hi room".to_owned(), 1_700_000_000_000, 7);
let json = to_json(&event).expect("serializes");
let decoded = decode(&json).expect("decodes");
let Some(Event::Chat(chat)) = decoded.event else {
panic!("expected Chat variant, got {:?}", decoded.event);
};
assert_eq!(chat.message_id, Some(hex::decode(&msg_id).unwrap()));
assert_eq!(chat.author_pubkey, Some(hex::decode(&author).unwrap()));
assert_eq!(chat.text.as_deref(), Some("hi room"));
assert_eq!(chat.created_at, Some(1_700_000_000_000));
assert_eq!(chat.created_at_unix_ms, Some(1_700_000_000_000));
assert_eq!(chat.seq, Some(7));
}
#[test]
fn reaction_event_round_trips_through_json() {
let author = hex32(0x33);
let event = reaction_event("targethex".to_owned(), "👍".to_owned(), &author, true, 3);
let json = to_json(&event).expect("serializes");
let decoded = decode(&json).expect("decodes");
let Some(Event::Reaction(reaction)) = decoded.event else {
panic!("expected Reaction variant, got {:?}", decoded.event);
};
assert_eq!(reaction.target_id.as_deref(), Some("targethex"));
assert_eq!(reaction.emoji.as_deref(), Some("👍"));
assert_eq!(reaction.author_pubkey, Some(hex::decode(&author).unwrap()));
assert_eq!(reaction.active, Some(true));
assert_eq!(reaction.count, Some(3));
}
#[test]
fn presence_event_round_trips_through_json() {
let a = hex32(0x44);
let b = hex32(0x55);
let event = presence_event(vec![(a.clone(), 100), (b.clone(), 200)], 4);
let json = to_json(&event).expect("serializes");
let decoded = decode(&json).expect("decodes");
let Some(Event::Presence(presence)) = decoded.event else {
panic!("expected Presence variant, got {:?}", decoded.event);
};
assert_eq!(presence.members.len(), 2);
assert_eq!(
presence.members[0].author_pubkey,
Some(hex::decode(&a).unwrap())
);
assert_eq!(presence.members[0].since, Some(100));
assert_eq!(presence.members[0].since_unix_ms, Some(100));
assert_eq!(
presence.members[1].author_pubkey,
Some(hex::decode(&b).unwrap())
);
assert_eq!(presence.viewers, Some(4));
}
#[test]
fn presence_event_drops_members_with_malformed_pubkey() {
let event = presence_event(vec![("not-hex".to_owned(), 1)], 0);
let Some(Event::Presence(presence)) = event.event else {
panic!("expected Presence variant");
};
assert!(presence.members.is_empty());
}
#[test]
fn decode_rejects_malformed_json() {
assert!(decode("not json").is_none());
}
}