use std::collections::HashMap;
use crate::module::ImModule;
use crate::state::{ChannelId, CorrelationContext, ImState};
use helix_core::effect::{Effect, EffectSink};
#[derive(Debug, Clone, PartialEq, Eq)]
struct PendingScopeHydration {
req_id: String,
auth_user_id: String,
company_id: String,
}
#[derive(Debug, Default)]
pub(crate) struct ScopeHydration {
pending: HashMap<ChannelId, PendingScopeHydration>,
}
impl ScopeHydration {
pub(crate) fn reserve(
&mut self,
channel_id: ChannelId,
req_id: String,
auth_user_id: &str,
company_id: &str,
) -> bool {
if self.pending.get(&channel_id).is_some_and(|pending| {
pending.auth_user_id == auth_user_id && pending.company_id == company_id
}) {
return false;
}
self.pending.insert(
channel_id,
PendingScopeHydration {
req_id,
auth_user_id: auth_user_id.to_string(),
company_id: company_id.to_string(),
},
);
true
}
pub(crate) fn is_active(&self, channel_id: ChannelId, req_id: &str) -> bool {
self.pending
.get(&channel_id)
.is_some_and(|pending| pending.req_id == req_id)
}
pub(crate) fn clear(&mut self, channel_id: ChannelId) {
self.pending.remove(&channel_id);
}
pub(crate) fn reset(&mut self) {
self.pending.clear();
}
}
impl ImModule {
pub(crate) fn start_scope_hydration(&mut self, channel_id: ChannelId, out: &mut EffectSink) {
if self.config.auth_user_id.is_empty()
|| self.config.company_id.is_empty()
|| self
.render_scope
.has_trusted_channel_scope(&self.config, channel_id.as_str())
{
return;
}
let corr = self.alloc_corr_internal();
let req_id = format!("scope-hydration-{}", corr.raw());
if !self.scope_hydration.reserve(
channel_id,
req_id.clone(),
self.config.auth_user_id.as_str(),
self.config.company_id.as_str(),
) {
return;
}
let payload = match serde_json::to_vec(&serde_json::json!({
"channel_id": channel_id.as_str(),
"req_id": req_id,
})) {
Ok(payload) => payload,
Err(error) => {
self.scope_hydration.clear(channel_id);
tracing::warn!(
channel_id = channel_id.as_str(),
error = ?error,
"scope hydration request payload failed"
);
return;
}
};
let effects = match crate::commands::handle_outbound(
"im_channel_load_increment_by_channel_id",
&payload,
self.config.api_base_url.as_str(),
self.config.default_api_base_url.as_str(),
self.state.connection_id.as_deref(),
corr,
) {
Ok(effects) => effects,
Err(error) => {
self.scope_hydration.clear(channel_id);
tracing::warn!(
channel_id = channel_id.as_str(),
error = ?error,
"scope hydration request build failed"
);
return;
}
};
if !effects
.iter()
.any(|effect| matches!(effect, Effect::Http { .. }))
{
self.scope_hydration.clear(channel_id);
tracing::warn!(
channel_id = channel_id.as_str(),
"scope hydration request produced no HTTP effect"
);
return;
}
self.state.corr_map.insert(
corr,
CorrelationContext::OutboundIncrementHydration {
req_id,
emit_channel_increment: true,
scope_channel: Some(channel_id),
},
);
for effect in effects {
out.push(effect);
}
}
}
pub(crate) fn has_increment_persist(state: &ImState, channel_id: ChannelId, req_id: &str) -> bool {
state.corr_map.values().any(|context| {
matches!(
context,
CorrelationContext::IncrementHydrationPersist {
channel_id: pending_channel,
req_id: pending_req,
..
} if *pending_channel == channel_id && pending_req == req_id
)
})
}
pub(crate) fn has_hydration_stage(state: &ImState, channel_id: ChannelId, req_id: &str) -> bool {
if state
.hydration_req_ids
.get(&channel_id)
.is_some_and(|pending_req| pending_req == req_id)
{
return true;
}
state.corr_map.values().any(|context| {
matches!(
context,
CorrelationContext::HydrationChannelReadback {
req_id: pending_req,
channel_id: pending_channel,
}
| CorrelationContext::HydrationMemberReadback {
req_id: pending_req,
channel_id: pending_channel,
..
}
| CorrelationContext::HydrationMessagesReadback {
req_id: pending_req,
channel_id: pending_channel,
..
}
| CorrelationContext::HydrationCursorReadback {
req_id: pending_req,
channel_id: pending_channel,
..
} if *pending_channel == channel_id && pending_req == req_id
)
})
}
pub(crate) fn drop_auto_read_emits(out: &mut EffectSink, start: usize, req_id: &str) {
out.retain_mut_from(start, |effect| {
let Effect::Emit { event } = effect else {
return true;
};
let Ok(value) = serde_json::from_slice::<serde_json::Value>(event.0.as_ref()) else {
return true;
};
value["event"] != "im:read:result" || value["data"]["req_id"].as_str() != Some(req_id)
});
}
#[cfg(test)]
mod tests {
use super::drop_auto_read_emits;
use helix_core::effect::{DomainEventBytes, Effect};
use helix_core::EffectSink;
#[test]
fn internal_read_filter_only_removes_matching_request() {
let mut sink = EffectSink::new();
for req_id in ["external-1", "scope-hydration-1"] {
sink.push(Effect::Emit {
event: DomainEventBytes(bytes::Bytes::from(
serde_json::to_vec(&serde_json::json!({
"event": "im:read:result",
"data": { "req_id": req_id, "body": null }
}))
.expect("test event must serialize"),
)),
});
}
drop_auto_read_emits(&mut sink, 0, "scope-hydration-1");
assert_eq!(sink.as_slice().len(), 1);
let Effect::Emit { event } = &sink.as_slice()[0] else {
panic!("read relay event should remain");
};
assert!(event
.0
.as_ref()
.windows(b"external-1".len())
.any(|window| { window == b"external-1" }));
}
}