feat(sync): add typed broadcast primitive

req: sync/004
This commit is contained in:
slhx agent
2026-07-13 22:54:12 +02:00
parent 98b00920b9
commit 87296632bc
5 changed files with 162 additions and 3 deletions
+92 -1
View File
@@ -1,4 +1,4 @@
use hemx_core::{Effect, IntoEffect};
use hemx_core::{Effect, EffectBatch, IntoEffect};
use serde::{de, Deserialize, Deserializer, Serialize};
use std::{error::Error, fmt};
@@ -7,6 +7,73 @@ pub const PATCH_EVENT: &str = "hemx:sync-patch";
const INTERACTION_ID: &str = "$hemx-interaction";
pub const BROWSER_RUNTIME: &str = include_str!("../runtime/hemx-sync.js");
#[derive(Clone, Debug, Eq, Hash, PartialEq)]
pub struct Channel(String);
impl Channel {
pub fn new(value: impl Into<String>) -> Result<Self, ChannelError> {
let value = value.into();
if value.is_empty() {
return Err(ChannelError::Empty);
}
if value.len() > 128 {
return Err(ChannelError::TooLong);
}
if !value
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b':' | b'_' | b'-' | b'.'))
{
return Err(ChannelError::InvalidCharacter);
}
Ok(Self(value))
}
pub fn as_str(&self) -> &str {
&self.0
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum ChannelError {
Empty,
TooLong,
InvalidCharacter,
}
impl fmt::Display for ChannelError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Empty => formatter.write_str("sync channel must not be empty"),
Self::TooLong => formatter.write_str("sync channel is too long"),
Self::InvalidCharacter => {
formatter.write_str("sync channel contains an invalid character")
}
}
}
}
impl Error for ChannelError {}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct Broadcast {
channel: Channel,
effect_batch: EffectBatch,
}
impl Broadcast {
pub fn channel(&self) -> &Channel {
&self.channel
}
pub fn effect_batch(&self) -> &EffectBatch {
&self.effect_batch
}
pub fn into_parts(self) -> (Channel, EffectBatch) {
(self.channel, self.effect_batch)
}
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
#[serde(untagged)]
pub enum PatchValue {
@@ -222,6 +289,14 @@ fn validate_key(key: &str) -> Result<(), PatchError> {
pub struct SyncEffect(Effect); // req: sync/002
impl SyncEffect {
// req: sync/004
pub fn broadcast(channel: Channel, effect_batch: EffectBatch) -> Broadcast {
Broadcast {
channel,
effect_batch,
}
}
pub fn send_patch(patch: FlatPatch) -> Self {
patch.validate().expect("FlatPatch must remain valid");
Self(Effect::Emit {
@@ -241,6 +316,22 @@ impl IntoEffect for SyncEffect {
mod tests {
use super::*;
#[test]
fn broadcast_preserves_typed_channel_and_ordinary_batch() {
let channel = Channel::new("board:alpha").unwrap();
let batch = EffectBatch {
abi_version: 1,
fingerprint: hemx_core::BuildFingerprint(7),
ops: vec![],
};
let broadcast = SyncEffect::broadcast(channel.clone(), batch.clone());
assert_eq!(broadcast.into_parts(), (channel, batch));
assert_eq!(
Channel::new("board alpha"),
Err(ChannelError::InvalidCharacter)
);
}
#[test]
fn schema_is_flat_and_rejects_reserved_keys() {
let patch = FlatPatch::new(