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
+40 -6
View File
@@ -15,7 +15,7 @@ use hemx_axum::{
use hemx_kanban_example::ui::board::{self as board};
use hemx_kanban_example::ui::board_card as card_board;
use hemx_kanban_example::ui::{self, board as board_ui};
use hemx_sync::{Channel, SyncEffect};
use hemx_sync::{Channel, PresenceScope, PresenceTracker, PresenceUpdate};
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::convert::Infallible;
@@ -46,6 +46,7 @@ struct AppState {
sync_store: Option<SyncStore>,
sync_sessions: SyncSessionTokens,
acknowledgement_heartbeat_interval: Duration,
presence: Mutex<PresenceTracker<String>>,
}
#[derive(Default)]
@@ -494,6 +495,7 @@ async fn main() {
.filter(|milliseconds| *milliseconds > 0)
.map(Duration::from_millis)
.unwrap_or(ACKNOWLEDGEMENT_HEARTBEAT_INTERVAL),
presence: Mutex::new(PresenceTracker::default()),
});
let ordinary_routes = Router::new()
@@ -581,7 +583,28 @@ async fn interact(
}
// req: push/001 req: push/003 req: examples/001
async fn sync_broadcast(Query(params): Query<BTreeMap<String, String>>) -> Response {
struct PresenceSignal {
channel: Channel,
count: usize,
}
impl PresenceScope for PresenceSignal {
fn presence_channel(&self) -> Channel {
self.channel.clone()
}
}
#[hemx_sync::presence]
fn presence_changed(signal: PresenceSignal) -> impl hemx::IntoEffect {
board::presence.put(&Presence {
count: u64::try_from(signal.count).expect("presence count fits u64"),
})
}
async fn sync_broadcast(
State(state): State<Arc<AppState>>,
Query(params): Query<BTreeMap<String, String>>,
) -> Response {
let Some(channel) = params
.get("channel")
.and_then(|channel| Channel::new(channel).ok())
@@ -591,10 +614,21 @@ async fn sync_broadcast(Query(params): Query<BTreeMap<String, String>>) -> Respo
if channel.as_str() != "board" {
return StatusCode::NOT_FOUND.into_response();
}
let batch = board::presence
.put(&Presence { count: 7 })
.into_batch(ui::BUILD_FINGERPRINT);
let broadcast = SyncEffect::broadcast(channel, batch);
let member = params.get("member").cloned();
let count = {
let mut presence = state.presence.lock().unwrap();
match (params.get("action").map(String::as_str), member.as_ref()) {
(Some("join"), Some(member)) => presence.join(channel.clone(), member.clone()).count,
(Some("leave"), Some(member)) => presence.leave(&channel, member).count,
(None | Some("snapshot"), None) => presence.count(&channel),
_ => {
return (StatusCode::BAD_REQUEST, "invalid presence action or member")
.into_response();
}
}
};
let broadcast =
presence_changed(PresenceSignal { channel, count }).into_broadcast(ui::BUILD_FINGERPRINT);
let (_channel, effect_batch) = broadcast.into_parts();
sse(stream::iter([Ok::<_, Infallible>(effect_batch)]).boxed()).into_response()
}
+48 -16
View File
@@ -2201,8 +2201,8 @@ async fn adversarial_wire_inputs_are_rejected_before_partial_application() -> We
}
#[tokio::test]
async fn typed_broadcast_applies_generated_batch_over_sse() -> WebDriverResult<()> {
// test req: sync/004
async fn typed_presence_join_leave_updates_generated_atom_over_sse() -> WebDriverResult<()> {
// test req: sync/001 req: sync/004 req: sync/005
let app_port = available_port();
let app_addr = format!("127.0.0.1:{app_port}");
let mut app_command = Command::new(env!("CARGO_BIN_EXE_hemx-kanban-example"));
@@ -2222,25 +2222,57 @@ async fn typed_broadcast_applies_generated_batch_over_sse() -> WebDriverResult<(
let result = async {
driver.goto(&format!("http://{app_addr}/")).await?;
wait_until(
&driver,
"return document.body.textContent.includes('tick #7')",
)
.await?;
let resources = driver
.execute(
"return performance.getEntriesByType('resource').map(entry => entry.name).filter(name => name.includes('/sync/broadcast'))",
wait_until(&driver, "return document.body.textContent.includes('tick #0')").await?;
let proof = driver
.execute_async(
r#"
const done = arguments[arguments.length - 1];
(async () => {
const root = document.querySelector('[data-hemx-root]');
const apply = (url) => new Promise((resolve, reject) => {
const source = new EventSource(url);
const timeout = setTimeout(() => {
source.close();
reject(new Error(`presence event timed out: ${url}`));
}, 5000);
source.addEventListener('hemx', (event) => {
clearTimeout(timeout);
const normalized = event.data.replace(/-/g, '+').replace(/_/g, '/');
const padded = normalized + '='.repeat((4 - normalized.length % 4) % 4);
const raw = atob(padded);
const bytes = Uint8Array.from(raw, (character) => character.charCodeAt(0));
window.hemx.applyBatch(bytes.buffer, root);
source.close();
resolve(document.body.textContent);
});
source.onerror = () => {
clearTimeout(timeout);
source.close();
reject(new Error(`presence event failed: ${url}`));
};
});
const joinedAda = await apply('/sync/broadcast?channel=board&action=join&member=ada');
const duplicateAda = await apply('/sync/broadcast?channel=board&action=join&member=ada');
const joinedGrace = await apply('/sync/broadcast?channel=board&action=join&member=grace');
const leftAda = await apply('/sync/broadcast?channel=board&action=leave&member=ada');
done({
joinedAda: joinedAda.includes('tick #1'),
duplicateAda: duplicateAda.includes('tick #1'),
joinedGrace: joinedGrace.includes('tick #2'),
leftAda: leftAda.includes('tick #1'),
});
})().catch((error) => done({ error: String(error), stack: error?.stack }));
"#,
Vec::new(),
)
.await?
.json()
.clone();
assert!(
resources
.as_array()
.is_some_and(|resources| !resources.is_empty()),
"typed broadcast SSE request was not observed: {resources}"
);
assert!(proof["error"].is_null(), "typed presence failed: {proof}");
assert_eq!(proof["joinedAda"], true, "{proof}");
assert_eq!(proof["duplicateAda"], true, "{proof}");
assert_eq!(proof["joinedGrace"], true, "{proof}");
assert_eq!(proof["leftAda"], true, "{proof}");
Ok(())
}
.await;