feat(sync): add typed broadcast primitive
req: sync/004
This commit is contained in:
+92
-1
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user