1use crate::error::ImError;
8use crate::http_envelope::unwrap_sync_envelope;
9use crate::module::ImModule;
10use crate::state::{ChannelId, CorrelationContext, HydrationPersistSnapshot};
11use helix_core::effect::{Effect, GetSpec, ScanOrder, ScanSpec, SqlValue, StorageOp};
12use helix_core::tick::PortOutcome;
13use helix_core::{Correlation, EffectSink};
14
15const HYDRATION_MESSAGE_ORDER: &[ScanOrder] = &[
16 ScanOrder::desc("create_at"),
17 ScanOrder::desc("temporary_id"),
18];
19
20#[cfg(test)]
21#[path = "closed_hydration_tests.rs"]
22mod closed_hydration_tests;
23
24fn hydration_reply_diagnostic(body: &serde_json::Value) -> (&'static str, &'static str) {
26 match body.get("status").and_then(serde_json::Value::as_str) {
27 Some("failed") => (
28 "failed",
29 if body.get("message").and_then(serde_json::Value::as_str)
30 == Some("user not member of channel")
31 {
32 "remote_not_member"
33 } else {
34 "remote_business_failure"
35 },
36 ),
37 Some("SUCCESS") => ("SUCCESS", "missing_data"),
38 Some("FINISH") => ("FINISH", "missing_data"),
39 Some(_) => ("unknown", "invalid_business_status"),
40 None => ("missing_or_invalid", "invalid_business_status"),
41 }
42}
43
44#[cfg(test)]
45mod diagnostic_tests {
46 use super::hydration_reply_diagnostic;
47 use serde_json::json;
48
49 #[test]
51 fn hydration_diagnostic_classifies_business_failure_without_remote_text() {
52 for (body, expected) in [
53 (
54 json!({"status":"failed","message":"user not member of channel"}),
55 ("failed", "remote_not_member"),
56 ),
57 (
58 json!({"status":"failed","message":"secret=private\nraw payload"}),
59 ("failed", "remote_business_failure"),
60 ),
61 (json!({"status":"SUCCESS"}), ("SUCCESS", "missing_data")),
62 (json!({"status":"FINISH"}), ("FINISH", "missing_data")),
63 (
64 json!({"status":"private-value"}),
65 ("unknown", "invalid_business_status"),
66 ),
67 (
68 json!({"status":200}),
69 ("missing_or_invalid", "invalid_business_status"),
70 ),
71 (json!({}), ("missing_or_invalid", "invalid_business_status")),
72 ] {
73 assert_eq!(hydration_reply_diagnostic(&body), expected);
74 }
75 }
76}
77
78#[cfg(test)]
79mod hydration_roster_tests {
80 use crate::module::{ImConfig, ImModule};
81 use serde_json::json;
82
83 #[test]
84 fn hydration_emits_disjoint_role_projection_with_total_member_count() {
85 let module = ImModule::new(ImConfig::default());
86 let ordered = vec![
87 json!({"userId":"owner-1", "teamId":"team-1"}),
88 json!({"userId":"admin-1", "teamId":"team-1"}),
89 json!({"userId":"boss-1", "teamId":"team-1"}),
90 json!({"userId":"member-1", "teamId":"team-1"}),
91 ];
92 let durable_rows = vec![
93 json!({"user_id":"owner-1", "team_id":"team-1", "role":"OWNER", "nick_name":"owner"}),
94 json!({"user_id":"admin-1", "team_id":"team-1", "role":"MANAGER", "nick_name":"admin"}),
95 json!({"user_id":"boss-1", "team_id":"team-1", "role":"BOSS", "nick_name":"boss"}),
96 json!({"user_id":"member-1", "team_id":"team-1", "role":"MEMBER", "nick_name":"member"}),
97 ];
98 let mut channel = json!({"id":"channel-1"});
99
100 module
101 .attach_hydration_roster(&mut channel, &durable_rows, &ordered)
102 .expect("hydration roster should project");
103
104 assert_eq!(channel["memberCount"], json!(4));
105 assert_eq!(
106 channel["members"],
107 json!([{"userId":"member-1", "teamId":"team-1", "role":"MEMBER", "nickName":"member"}])
108 );
109 assert_eq!(channel["adminUsers"][0]["userId"], "admin-1");
110 assert_eq!(channel["boss"][0]["userId"], "boss-1");
111 assert_eq!(channel["owner"]["userId"], "owner-1");
112
113 let ids = [
114 channel["members"][0]["userId"].as_str().unwrap(),
115 channel["adminUsers"][0]["userId"].as_str().unwrap(),
116 channel["boss"][0]["userId"].as_str().unwrap(),
117 channel["owner"]["userId"].as_str().unwrap(),
118 ];
119 assert_eq!(
120 ids.len(),
121 ids.iter().collect::<std::collections::HashSet<_>>().len()
122 );
123 }
124}
125
126impl ImModule {
127 pub fn ingest_increment(
129 &mut self,
130 inc: &crate::sync_session::IncrementChannel,
131 out: &mut EffectSink,
132 ) {
133 let start = out.as_slice().len();
134 let api_base_url = self.config.api_base_url.clone();
135 let auth_user_id = self.config.auth_user_id.clone();
136 self.with_state_and_corr_allocator(|state, alloc| {
137 let mut ctx =
138 crate::ws::ImWsContext::new(state, 0, &api_base_url, &auth_user_id, alloc);
139 crate::ws::handlers::increment_channel::apply_increment(&mut ctx, inc, out);
140 });
141 self.render_scope.guard_effects(&self.config, start, out);
142 }
143
144 fn ingest_increment_hydration(
145 &mut self,
146 inc: &crate::sync_session::IncrementChannel,
147 persist_corr: Correlation,
148 now_ms: u64,
149 out: &mut EffectSink,
150 ) -> bool {
151 let api_base_url = self.config.api_base_url.clone();
152 let auth_user_id = self.config.auth_user_id.clone();
153 self.with_state_and_corr_allocator(|state, alloc| {
154 let mut ctx =
155 crate::ws::ImWsContext::new(state, now_ms, &api_base_url, &auth_user_id, alloc);
156 crate::ws::handlers::increment_channel::apply_increment_hydration(
157 &mut ctx,
158 inc,
159 persist_corr,
160 out,
161 )
162 })
163 }
164
165 fn hydration_snapshot(&self, channel_id: ChannelId) -> HydrationPersistSnapshot {
167 HydrationPersistSnapshot {
168 had_channel: self.state.channels.contains_key(&channel_id),
169 previous_target: self.state.increment_target.get(&channel_id).copied(),
170 was_increment_fetched: self.state.increment_fetched.contains(&channel_id),
171 was_need_sync_skip: self.state.need_sync_skip.contains(&channel_id),
172 previous_about_me_len: self.state.about_me_post_ids.len(),
173 }
174 }
175
176 fn restore_hydration_snapshot(
178 &mut self,
179 channel_id: ChannelId,
180 snapshot: &HydrationPersistSnapshot,
181 ) {
182 if !snapshot.had_channel {
183 self.state.channels.remove(&channel_id);
184 }
185 if let Some(target) = snapshot.previous_target {
186 self.state.increment_target.insert(channel_id, target);
187 } else {
188 self.state.increment_target.remove(&channel_id);
189 }
190 if !snapshot.was_increment_fetched {
191 self.state.increment_fetched.remove(&channel_id);
192 self.state.increment_order.retain(|id| *id != channel_id);
193 }
194 if snapshot.was_need_sync_skip {
195 self.state.need_sync_skip.insert(channel_id);
196 } else {
197 self.state.need_sync_skip.remove(&channel_id);
198 }
199 self.state
200 .about_me_post_ids
201 .truncate(snapshot.previous_about_me_len);
202 }
203
204 fn hydration_rows(
206 reply: &helix_core::tick::ReplyBytes,
207 ) -> Result<Vec<serde_json::Value>, ImError> {
208 let value = serde_json::from_slice::<serde_json::Value>(reply.0.as_ref())
209 .map_err(|error| ImError::Parse(format!("hydration read-back rows: {error}")))?;
210 value
211 .as_array()
212 .cloned()
213 .ok_or_else(|| ImError::Parse("hydration read-back must be an array".to_string()))
214 }
215
216 fn ordered_hydration_roster(raw_increment: &[u8]) -> Result<Vec<serde_json::Value>, ImError> {
218 let data: serde_json::Value = serde_json::from_slice(raw_increment)
219 .map_err(|error| ImError::Parse(format!("hydration roster parse: {error}")))?;
220 let members = data
221 .get("members")
222 .and_then(serde_json::Value::as_array)
223 .ok_or_else(|| ImError::Parse("hydration roster members missing".to_string()))?;
224 let member_count = data
225 .get("memberCount")
226 .or_else(|| data.get("member_count"))
227 .and_then(serde_json::Value::as_u64)
228 .ok_or_else(|| ImError::Parse("hydration roster memberCount missing".to_string()))?;
229 if member_count as usize != members.len() || members.is_empty() {
230 return Err(ImError::Parse(
231 "hydration roster count mismatch or empty".to_string(),
232 ));
233 }
234 let mut seen = std::collections::HashSet::with_capacity(members.len());
235 let mut ordered = Vec::with_capacity(members.len());
236 for member in members {
237 let user_id = member
238 .get("userId")
239 .or_else(|| member.get("id"))
240 .and_then(serde_json::Value::as_str)
241 .filter(|value| !value.is_empty())
242 .ok_or_else(|| ImError::Parse("hydration roster member id missing".to_string()))?;
243 if !seen.insert(user_id.to_string()) {
244 return Err(ImError::Parse(
245 "hydration roster contains duplicate member".to_string(),
246 ));
247 }
248 ordered.push(serde_json::json!({
249 "userId": user_id,
250 "teamId": member.get("teamId").and_then(serde_json::Value::as_str).unwrap_or_default(),
251 "role": member.get("role").and_then(serde_json::Value::as_str).unwrap_or("MEMBER"),
252 "nickName": member.get("nickName").and_then(serde_json::Value::as_str).unwrap_or_default(),
253 }));
254 }
255 Ok(ordered)
256 }
257
258 fn attach_hydration_roster(
260 &self,
261 channel: &mut serde_json::Value,
262 durable_rows: &[serde_json::Value],
263 ordered: &[serde_json::Value],
264 ) -> Result<(), ImError> {
265 let mut durable = std::collections::HashMap::with_capacity(durable_rows.len());
266 let expected_teams: std::collections::HashMap<_, _> = ordered
267 .iter()
268 .filter_map(|member| Some((member["userId"].as_str()?, member["teamId"].as_str()?)))
269 .collect();
270 for row in durable_rows {
271 let user_id = row
272 .get("user_id")
273 .and_then(serde_json::Value::as_str)
274 .filter(|value| !value.is_empty())
275 .ok_or_else(|| ImError::Parse("hydration durable member id missing".to_string()))?;
276 let expected_team = expected_teams.get(user_id).copied();
278 if expected_team
279 .filter(|team| !team.is_empty())
280 .is_some_and(|team| {
281 row.get("team_id").and_then(serde_json::Value::as_str) != Some(team)
282 })
283 {
284 return Err(ImError::Parse(
285 "hydration durable member tenant mismatch".to_string(),
286 ));
287 }
288 if durable.insert(user_id, row).is_some() {
289 return Err(ImError::Parse(
290 "hydration durable member duplicate".to_string(),
291 ));
292 }
293 }
294 if durable.len() != ordered.len() {
295 return Err(ImError::Parse(
296 "hydration durable roster count mismatch".to_string(),
297 ));
298 }
299 let object = channel
300 .as_object_mut()
301 .ok_or_else(|| ImError::Parse("hydration channel shape invalid".to_string()))?;
302 let mut admins = Vec::new();
303 let mut bosses = Vec::new();
304 let mut owner = serde_json::Value::Null;
305 let mut projected_members = Vec::with_capacity(ordered.len());
306 for member in ordered {
307 let user_id = member["userId"]
308 .as_str()
309 .ok_or_else(|| ImError::Parse("hydration ordered member invalid".to_string()))?;
310 let row = durable.get(user_id).ok_or_else(|| {
311 ImError::Parse("hydration durable roster set mismatch".to_string())
312 })?;
313 let role = row
314 .get("role")
315 .and_then(serde_json::Value::as_str)
316 .unwrap_or("MEMBER");
317 let projected = serde_json::json!({
318 "userId": user_id,
319 "teamId": row.get("team_id").and_then(serde_json::Value::as_str).unwrap_or_default(),
320 "role": role,
321 "nickName": row.get("nick_name").and_then(serde_json::Value::as_str).unwrap_or_default(),
322 });
323 match role {
324 "ADMIN" | "MANAGER" | "MANGER" => admins.push(projected.clone()),
325 "BOSS" => bosses.push(projected.clone()),
326 "OWNER" | "CREATOR" => owner = projected.clone(),
327 _ => projected_members.push(projected.clone()),
328 }
329 }
330 object.insert(
331 "members".to_string(),
332 serde_json::Value::Array(projected_members),
333 );
334 object.insert("adminUsers".to_string(), serde_json::Value::Array(admins));
335 object.insert("boss".to_string(), serde_json::Value::Array(bosses));
336 object.insert("owner".to_string(), owner);
337 object.insert("memberCount".to_string(), serde_json::json!(ordered.len()));
338 Ok(())
339 }
340
341 fn start_hydration_channel_readback(
343 &mut self,
344 req_id: String,
345 channel_id: ChannelId,
346 out: &mut EffectSink,
347 ) {
348 let corr = self.alloc_corr_internal();
349 out.push(Effect::Persist {
350 corr,
351 ops: vec![StorageOp::Get(GetSpec {
352 table: "channel",
353 key_col: "id",
354 key_val: SqlValue::Text(channel_id.as_str().to_string()),
355 })],
356 });
357 self.state.corr_map.insert(
358 corr,
359 CorrelationContext::HydrationChannelReadback { req_id, channel_id },
360 );
361 }
362
363 pub(crate) fn handle_hydration_channel_readback(
365 &mut self,
366 req_id: String,
367 channel_id: ChannelId,
368 outcome: &PortOutcome,
369 out: &mut EffectSink,
370 ) -> Result<(), ImError> {
371 let rows = match outcome {
372 PortOutcome::Ok(reply) => Self::hydration_rows(reply),
373 PortOutcome::Err(error) => Err(ImError::Parse(format!(
374 "channel read-back failed: {error:?}"
375 ))),
376 };
377 let channel = match rows {
378 Ok(mut rows) => rows.pop(),
379 Err(error) => {
380 self.finish_hydration_error(&req_id, channel_id, &error.to_string(), out);
381 return Ok(());
382 }
383 };
384 let Some(channel) = channel.filter(|row| {
385 row.get("id").and_then(serde_json::Value::as_str) == Some(channel_id.as_str())
386 && row.get("team_id").and_then(serde_json::Value::as_str)
387 == self.render_scope.company_for(channel_id.as_str())
388 && row.get("user_id").and_then(serde_json::Value::as_str)
389 == Some(self.config.auth_user_id.as_str())
390 }) else {
391 self.finish_hydration_error(
392 &req_id,
393 channel_id,
394 "channel read-back scope mismatch",
395 out,
396 );
397 return Ok(());
398 };
399 let channel = crate::query::render_ready::channel::shape_channel_row(&channel);
400 let corr = self.alloc_corr_internal();
401 out.push(Effect::Persist {
402 corr,
403 ops: vec![StorageOp::Scan(ScanSpec {
404 table: "channel_member",
405 limit: None,
406 filter: Some((
407 "channel_id",
408 SqlValue::Text(channel_id.as_str().to_string()),
409 )),
410 order_by: &[],
411 })],
412 });
413 self.state.corr_map.insert(
414 corr,
415 CorrelationContext::HydrationMemberReadback {
416 req_id,
417 channel_id,
418 channel: Box::new(channel),
419 },
420 );
421 Ok(())
422 }
423
424 pub(crate) fn handle_hydration_member_readback(
426 &mut self,
427 req_id: String,
428 channel_id: ChannelId,
429 channel: Box<serde_json::Value>,
430 outcome: &PortOutcome,
431 out: &mut EffectSink,
432 ) -> Result<(), ImError> {
433 let rows = match outcome {
434 PortOutcome::Ok(reply) => Self::hydration_rows(reply),
435 PortOutcome::Err(error) => Err(ImError::Parse(format!(
436 "member read-back failed: {error:?}"
437 ))),
438 };
439 let durable_rows = match rows {
440 Ok(rows) => rows,
441 Err(error) => {
442 self.finish_hydration_error(&req_id, channel_id, &error.to_string(), out);
443 return Ok(());
444 }
445 };
446 let Some(member) = durable_rows
447 .iter()
448 .find(|row| {
449 row.get("user_id").and_then(serde_json::Value::as_str)
450 == Some(self.config.auth_user_id.as_str())
451 })
452 .cloned()
453 else {
454 self.finish_hydration_error(&req_id, channel_id, "member read-back missing", out);
455 return Ok(());
456 };
457 let Some(ordered) = self
458 .state
459 .hydration_ordered_rosters
460 .get(&channel_id)
461 .cloned()
462 else {
463 self.finish_hydration_error(&req_id, channel_id, "ordered roster missing", out);
464 return Ok(());
465 };
466 let mut channel = *channel;
467 if let Err(error) = self.attach_hydration_roster(&mut channel, &durable_rows, &ordered) {
468 self.finish_hydration_error(&req_id, channel_id, &error.to_string(), out);
469 return Ok(());
470 }
471 let corr = self.alloc_corr_internal();
472 out.push(Effect::Persist {
473 corr,
474 ops: vec![StorageOp::Scan(ScanSpec {
475 table: "message",
476 limit: Some(50),
477 filter: Some((
478 "channel_id",
479 SqlValue::Text(channel_id.as_str().to_string()),
480 )),
481 order_by: HYDRATION_MESSAGE_ORDER,
482 })],
483 });
484 self.state.corr_map.insert(
485 corr,
486 CorrelationContext::HydrationMessagesReadback {
487 req_id,
488 channel_id,
489 channel: Box::new(channel),
490 member: Box::new(member),
491 },
492 );
493 Ok(())
494 }
495
496 pub(crate) fn handle_hydration_messages_readback(
498 &mut self,
499 req_id: String,
500 channel_id: ChannelId,
501 channel: Box<serde_json::Value>,
502 member: Box<serde_json::Value>,
503 outcome: &PortOutcome,
504 out: &mut EffectSink,
505 ) -> Result<(), ImError> {
506 let messages = match outcome {
507 PortOutcome::Ok(reply) => match Self::hydration_rows(reply) {
508 Ok(rows) => serde_json::Value::Array(rows),
509 Err(error) => {
510 self.finish_hydration_error(&req_id, channel_id, &error.to_string(), out);
511 return Ok(());
512 }
513 },
514 PortOutcome::Err(error) => {
515 self.finish_hydration_error(
516 &req_id,
517 channel_id,
518 &format!("message read-back failed: {error:?}"),
519 out,
520 );
521 return Ok(());
522 }
523 };
524 let corr = self.alloc_corr_internal();
525 out.push(Effect::Persist {
526 corr,
527 ops: vec![StorageOp::Get(GetSpec {
528 table: "channel_event_cursor",
529 key_col: "channel_id",
530 key_val: SqlValue::Text(channel_id.as_str().to_string()),
531 })],
532 });
533 self.state.corr_map.insert(
534 corr,
535 CorrelationContext::HydrationCursorReadback {
536 req_id,
537 channel_id,
538 channel,
539 member,
540 messages: Box::new(messages),
541 },
542 );
543 Ok(())
544 }
545
546 pub(crate) fn handle_hydration_cursor_readback(
548 &mut self,
549 req_id: String,
550 channel_id: ChannelId,
551 channel: Box<serde_json::Value>,
552 member: Box<serde_json::Value>,
553 messages: Box<serde_json::Value>,
554 outcome: &PortOutcome,
555 out: &mut EffectSink,
556 ) -> Result<(), ImError> {
557 let cursor = match outcome {
558 PortOutcome::Ok(reply) => match Self::hydration_rows(reply) {
559 Ok(mut rows) => rows
560 .pop()
561 .and_then(|row| {
562 row.get("last_event_seq")
563 .and_then(serde_json::Value::as_i64)
564 })
565 .unwrap_or_else(|| {
566 self.state
567 .channels
568 .get(&channel_id)
569 .map(|channel| channel.cursor.value().0 as i64)
570 .unwrap_or(0)
571 }),
572 Err(error) => {
573 self.finish_hydration_error(&req_id, channel_id, &error.to_string(), out);
574 return Ok(());
575 }
576 },
577 PortOutcome::Err(error) => {
578 self.finish_hydration_error(
579 &req_id,
580 channel_id,
581 &format!("cursor read-back failed: {error:?}"),
582 out,
583 );
584 return Ok(());
585 }
586 };
587 let emit_channel_increment = self
588 .state
589 .hydration_emit_channel_increment
590 .remove(&channel_id);
591 self.state.hydration_req_ids.remove(&channel_id);
592 self.state.hydration_ordered_rosters.remove(&channel_id);
593 let unread_reconcile = self
594 .state
595 .hydration_authority_unreads
596 .remove(&channel_id)
597 .and_then(|authority| {
598 member
599 .get("unread_count")
600 .or_else(|| member.get("unreadCount"))
601 .and_then(serde_json::Value::as_i64)
602 .map(|local| {
603 serde_json::json!({
604 "authority": authority,
605 "local": local,
606 "status": if authority == local { "match" } else { "mismatch" },
607 })
608 })
609 });
610 if emit_channel_increment {
611 out.push(
612 crate::event::MessageV3Event::new("im:channel:increment", (*channel).clone())?
613 .into_effect(),
614 );
615 }
616 let body = serde_json::json!({
617 "channelId": channel_id.as_str(),
618 "channel": *channel,
619 "member": *member,
620 "messages": *messages,
621 "cursor": cursor,
622 "completion": "hydrated",
623 "unreadReconcile": unread_reconcile,
624 });
625 out.push(crate::read_relay::emit_read_body(&req_id, body.clone()));
626 for waiter in self
627 .state
628 .hydration_waiters
629 .remove(&channel_id)
630 .unwrap_or_default()
631 {
632 out.push(crate::read_relay::emit_read_body(&waiter, body.clone()));
633 }
634 Ok(())
635 }
636
637 pub(crate) fn finish_hydration_error(
639 &mut self,
640 req_id: &str,
641 channel_id: ChannelId,
642 reason: &str,
643 out: &mut EffectSink,
644 ) {
645 if self
647 .state
648 .hydration_req_ids
649 .get(&channel_id)
650 .is_some_and(|primary| primary != req_id)
651 {
652 out.push(crate::read_relay::emit_read_error(req_id, reason));
653 return;
654 }
655 self.state.hydration_pending.remove(&channel_id);
656 self.state.hydration_req_ids.remove(&channel_id);
657 self.state
658 .hydration_emit_channel_increment
659 .remove(&channel_id);
660 self.state.hydration_ordered_rosters.remove(&channel_id);
661 self.state.hydration_authority_unreads.remove(&channel_id);
662 out.push(crate::read_relay::emit_read_error(req_id, reason));
663 for waiter in self
664 .state
665 .hydration_waiters
666 .remove(&channel_id)
667 .unwrap_or_default()
668 {
669 out.push(crate::read_relay::emit_read_error(&waiter, reason));
670 }
671 }
672
673 pub(crate) fn fail_hydration_for_channel(
675 &mut self,
676 channel_id: ChannelId,
677 reason: &str,
678 out: &mut EffectSink,
679 ) {
680 let Some(req_id) = self.state.hydration_req_ids.get(&channel_id).cloned() else {
681 return;
682 };
683 self.finish_hydration_error(&req_id, channel_id, reason, out);
684 }
685
686 pub(crate) fn handle_increment_hydration_reply(
688 &mut self,
689 req_id: &str,
690 emit_channel_increment: bool,
691 outcome: &PortOutcome,
692 now_ms: u64,
693 out: &mut EffectSink,
694 ) -> Result<(), ImError> {
695 match outcome {
696 PortOutcome::Ok(reply) => match unwrap_sync_envelope(reply.0.as_ref()) {
697 Ok(raw_body) => {
698 let body: serde_json::Value = match serde_json::from_slice(&raw_body) {
699 Ok(body) => body,
700 Err(e) => {
701 tracing::warn!(req_id, error = ?e, "increment hydration body is not json");
702 out.push(crate::read_relay::emit_read_error(
703 req_id,
704 "increment hydration body is not json",
705 ));
706 return Ok(());
707 }
708 };
709 let Some(data) = body.get("data") else {
710 let (business_status, reason) = hydration_reply_diagnostic(&body);
711 tracing::warn!(
712 req_id,
713 operation = "channel_hydration",
714 phase = "http_reply",
715 business_status,
716 reason,
717 "hydration failed"
718 );
719 out.push(crate::read_relay::emit_read_error(
720 req_id,
721 "increment hydration reply missing data",
722 ));
723 return Ok(());
724 };
725 if data.is_null() {
726 out.push(crate::read_relay::emit_read_body(
727 req_id,
728 serde_json::Value::Null,
729 ));
730 return Ok(());
731 }
732 let remote_company = data
733 .get("teamId")
734 .or_else(|| data.get("team_id"))
735 .and_then(serde_json::Value::as_str);
736 if remote_company.is_none_or(str::is_empty) {
737 out.push(crate::read_relay::emit_read_error(
738 req_id,
739 "increment hydration missing channel company",
740 ));
741 return Ok(());
742 }
743 let Some(increment) = crate::ws::parser::parse_increment_channel(data) else {
744 tracing::warn!(
745 req_id,
746 "increment hydration reply has invalid IncrementChannel"
747 );
748 out.push(crate::read_relay::emit_read_error(
749 req_id,
750 "increment hydration reply has invalid channel",
751 ));
752 return Ok(());
753 };
754 if let Some(authority_unread) = data
755 .get("unreadCount")
756 .or_else(|| data.get("unread_count"))
757 .and_then(serde_json::Value::as_i64)
758 {
759 self.state
760 .hydration_authority_unreads
761 .insert(increment.channel_id, authority_unread);
762 }
763 let snapshot = self.hydration_snapshot(increment.channel_id);
764 let persist_corr = self.alloc_corr_internal();
765 let has_persist =
766 self.ingest_increment_hydration(&increment, persist_corr, now_ms, out);
767 let local_cursor = self
771 .state
772 .channels
773 .get(&increment.channel_id)
774 .map(|channel| channel.cursor.value())
775 .unwrap_or(crate::state::Seq(0));
776 let requires_sync =
777 increment.need_sync || local_cursor < increment.last_event_seq;
778 if requires_sync {
779 self.state.need_sync_skip.remove(&increment.channel_id);
780 }
781 if has_persist {
782 self.state.corr_map.insert(
783 persist_corr,
784 CorrelationContext::IncrementHydrationPersist {
785 channel_id: increment.channel_id,
786 req_id: req_id.to_string(),
787 need_sync: requires_sync,
788 raw_increment: increment.raw.as_ref().to_vec(),
789 snapshot,
790 emit_channel_increment,
791 },
792 );
793 } else {
794 tracing::warn!(
795 channel_id = increment.channel_id.as_str(),
796 "increment hydration produced no durable writes; final projection suppressed"
797 );
798 out.push(crate::read_relay::emit_read_error(
799 req_id,
800 "increment hydration produced no durable writes",
801 ));
802 }
803 }
804 Err(e) => {
805 tracing::warn!(req_id, error = ?e, "increment hydration envelope decode failed");
806 out.push(crate::read_relay::emit_read_error(
807 req_id,
808 "response envelope decode failed",
809 ));
810 }
811 },
812 PortOutcome::Err(e) => {
813 tracing::warn!(req_id, error = ?e, "increment hydration http failed");
814 out.push(crate::read_relay::emit_read_error(
815 req_id,
816 "http request failed",
817 ));
818 }
819 }
820 Ok(())
821 }
822
823 pub(crate) fn handle_increment_hydration_persist(
824 &mut self,
825 channel_id: ChannelId,
826 req_id: String,
827 need_sync: bool,
828 raw_increment: Vec<u8>,
829 snapshot: HydrationPersistSnapshot,
830 emit_channel_increment: bool,
831 outcome: &PortOutcome,
832 out: &mut EffectSink,
833 ) -> Result<(), ImError> {
834 match outcome {
835 PortOutcome::Ok(_) => {
836 let ordered_roster = match Self::ordered_hydration_roster(&raw_increment) {
837 Ok(roster) => roster,
838 Err(error) => {
839 self.restore_hydration_snapshot(channel_id, &snapshot);
840 self.finish_hydration_error(&req_id, channel_id, &error.to_string(), out);
841 return Ok(());
842 }
843 };
844 self.state
845 .hydration_ordered_rosters
846 .insert(channel_id, ordered_roster);
847 self.after_increment_persist(
848 channel_id,
849 req_id,
850 need_sync,
851 emit_channel_increment,
852 out,
853 )?;
854 }
855 PortOutcome::Err(e) => {
856 self.restore_hydration_snapshot(channel_id, &snapshot);
857 tracing::warn!(
858 channel_id = channel_id.as_str(),
859 error = ?e,
860 "increment hydration channel/member persist failed"
861 );
862 self.finish_hydration_error(
863 &req_id,
864 channel_id,
865 "channel/member persist failed",
866 out,
867 );
868 }
869 }
870 Ok(())
871 }
872
873 fn after_increment_persist(
875 &mut self,
876 channel_id: ChannelId,
877 req_id: String,
878 need_sync: bool,
879 emit_channel_increment: bool,
880 out: &mut EffectSink,
881 ) -> Result<(), ImError> {
882 if emit_channel_increment {
883 self.state
884 .hydration_emit_channel_increment
885 .insert(channel_id);
886 }
887 if let Some(primary) = self.state.hydration_req_ids.get(&channel_id) {
888 if primary != &req_id {
889 let waiters = self.state.hydration_waiters.entry(channel_id).or_default();
890 if !waiters.contains(&req_id) {
891 waiters.push(req_id);
892 }
893 }
894 return Ok(());
895 }
896 self.state.hydration_pending.insert(channel_id);
897 self.state
898 .hydration_req_ids
899 .insert(channel_id, req_id.clone());
900 let terminal = self
902 .state
903 .channels
904 .get(&channel_id)
905 .is_some_and(|channel| channel.is_terminal());
906 if !need_sync || terminal {
907 return self.finish_increment_hydration(channel_id, out);
908 }
909 let api_base_url = self.config.api_base_url.clone();
910 self.with_state_and_corr_allocator(|state, alloc| {
911 crate::sync_scheduler::enqueue_and_drain_with_trigger(
912 state,
913 &api_base_url,
914 &[channel_id],
915 crate::state::SyncTrigger::Hydration,
916 alloc,
917 out,
918 );
919 });
920 Ok(())
921 }
922
923 pub(crate) fn finish_increment_hydration(
925 &mut self,
926 channel_id: ChannelId,
927 out: &mut EffectSink,
928 ) -> Result<(), ImError> {
929 if !self.state.hydration_pending.remove(&channel_id) {
930 return Ok(());
931 }
932 let Some(req_id) = self.state.hydration_req_ids.get(&channel_id).cloned() else {
933 return Ok(());
934 };
935 self.start_hydration_channel_readback(req_id, channel_id, out);
936 Ok(())
937 }
938}