feat(sync): add typed presence projection
req: sync/001 req: sync/005
This commit is contained in:
@@ -5,6 +5,7 @@ edition.workspace = true
|
||||
|
||||
[dependencies]
|
||||
hemx-core = { path = "../hemx-core" }
|
||||
hemx-sync-macros = { path = "../hemx-sync-macros" }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
|
||||
[dev-dependencies]
|
||||
|
||||
+149
-1
@@ -1,6 +1,14 @@
|
||||
use hemx_core::{Effect, EffectBatch, IntoEffect};
|
||||
use serde::{de, Deserialize, Deserializer, Serialize};
|
||||
use std::{error::Error, fmt};
|
||||
use std::{
|
||||
collections::{HashMap, HashSet},
|
||||
error::Error,
|
||||
fmt,
|
||||
hash::Hash,
|
||||
};
|
||||
|
||||
extern crate self as hemx_sync;
|
||||
pub use hemx_sync_macros::presence;
|
||||
|
||||
pub const PATCH_SCHEMA_VERSION: u16 = 1;
|
||||
pub const PATCH_EVENT: &str = "hemx:sync-patch";
|
||||
@@ -54,6 +62,99 @@ impl fmt::Display for ChannelError {
|
||||
|
||||
impl Error for ChannelError {}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct PresenceChange {
|
||||
pub changed: bool,
|
||||
pub count: usize,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct PresenceTracker<Member> {
|
||||
members: HashMap<Channel, HashSet<Member>>,
|
||||
}
|
||||
|
||||
impl<Member> Default for PresenceTracker<Member> {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
members: HashMap::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<Member> PresenceTracker<Member>
|
||||
where
|
||||
Member: Eq + Hash,
|
||||
{
|
||||
pub fn join(&mut self, channel: Channel, member: Member) -> PresenceChange {
|
||||
let members = self.members.entry(channel).or_default();
|
||||
PresenceChange {
|
||||
changed: members.insert(member),
|
||||
count: members.len(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn leave(&mut self, channel: &Channel, member: &Member) -> PresenceChange {
|
||||
let Some(members) = self.members.get_mut(channel) else {
|
||||
return PresenceChange {
|
||||
changed: false,
|
||||
count: 0,
|
||||
};
|
||||
};
|
||||
let changed = members.remove(member);
|
||||
let count = members.len();
|
||||
if members.is_empty() {
|
||||
self.members.remove(channel);
|
||||
}
|
||||
PresenceChange { changed, count }
|
||||
}
|
||||
|
||||
pub fn count(&self, channel: &Channel) -> usize {
|
||||
self.members.get(channel).map_or(0, HashSet::len)
|
||||
}
|
||||
}
|
||||
|
||||
pub trait PresenceScope {
|
||||
fn presence_channel(&self) -> Channel;
|
||||
}
|
||||
|
||||
pub struct PresenceProjection<Effect> {
|
||||
channel: Channel,
|
||||
effect: Effect,
|
||||
}
|
||||
|
||||
impl<Effect> PresenceProjection<Effect> {
|
||||
pub fn new(channel: Channel, effect: Effect) -> Self {
|
||||
Self { channel, effect }
|
||||
}
|
||||
}
|
||||
|
||||
pub trait PresenceUpdate: IntoEffect + Sized {
|
||||
fn presence_channel(&self) -> &Channel;
|
||||
fn into_broadcast(self, fingerprint: hemx_core::BuildFingerprint) -> Broadcast;
|
||||
}
|
||||
|
||||
impl<Effect> PresenceUpdate for PresenceProjection<Effect>
|
||||
where
|
||||
Effect: IntoEffect,
|
||||
{
|
||||
fn presence_channel(&self) -> &Channel {
|
||||
&self.channel
|
||||
}
|
||||
|
||||
fn into_broadcast(self, fingerprint: hemx_core::BuildFingerprint) -> Broadcast {
|
||||
SyncEffect::broadcast(self.channel, self.effect.into_batch(fingerprint))
|
||||
}
|
||||
}
|
||||
|
||||
impl<Effect> IntoEffect for PresenceProjection<Effect>
|
||||
where
|
||||
Effect: IntoEffect,
|
||||
{
|
||||
fn append_to(self, ops: &mut Vec<hemx_core::Effect>) {
|
||||
self.effect.append_to(ops);
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct Broadcast {
|
||||
channel: Channel,
|
||||
@@ -316,6 +417,53 @@ impl IntoEffect for SyncEffect {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn presence_macro_projects_an_ordinary_effect_on_its_channel() {
|
||||
struct Signal(Channel);
|
||||
impl PresenceScope for Signal {
|
||||
fn presence_channel(&self) -> Channel {
|
||||
self.0.clone()
|
||||
}
|
||||
}
|
||||
#[presence]
|
||||
fn project(signal: Signal) -> impl IntoEffect {
|
||||
Effect::Emit {
|
||||
name: "presence".to_owned(),
|
||||
payload: signal.0.as_str().to_owned(),
|
||||
}
|
||||
}
|
||||
|
||||
let update = project(Signal(Channel::new("board").unwrap()));
|
||||
assert_eq!(update.presence_channel().as_str(), "board");
|
||||
let broadcast = update.into_broadcast(hemx_core::BuildFingerprint(9));
|
||||
assert_eq!(broadcast.channel().as_str(), "board");
|
||||
assert_eq!(broadcast.effect_batch().ops.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn presence_tracker_is_idempotent_and_channel_scoped() {
|
||||
let alpha = Channel::new("board:alpha").unwrap();
|
||||
let beta = Channel::new("board:beta").unwrap();
|
||||
let mut tracker = PresenceTracker::default();
|
||||
assert_eq!(
|
||||
tracker.join(alpha.clone(), "ada"),
|
||||
PresenceChange {
|
||||
changed: true,
|
||||
count: 1
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
tracker.join(alpha.clone(), "ada"),
|
||||
PresenceChange {
|
||||
changed: false,
|
||||
count: 1
|
||||
}
|
||||
);
|
||||
assert_eq!(tracker.join(beta.clone(), "ada").count, 1);
|
||||
assert_eq!(tracker.leave(&alpha, &"ada").count, 0);
|
||||
assert_eq!(tracker.count(&beta), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn broadcast_preserves_typed_channel_and_ordinary_batch() {
|
||||
let channel = Channel::new("board:alpha").unwrap();
|
||||
|
||||
Reference in New Issue
Block a user