feat(sync): add typed presence projection

req: sync/001

req: sync/005
This commit is contained in:
slhx agent
2026-07-13 23:03:46 +02:00
parent 87296632bc
commit 88598f181f
9 changed files with 327 additions and 25 deletions
+1
View File
@@ -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
View File
@@ -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();