Skip to main content

helix_im/
increment_hydration.rs

1//! UC-4.5 陌生 channel 单频道 hydration 编排。
2//!
3//! HTTP `incrementByChannelId` 仍保留通用 read relay,但其 `data=*IncrementChannel` 还会复用
4//! WS increment 解析/应用规则。channel/member 确认落库后才启动既有 sync;terminal sync commit
5//! 后从本地 channel/message 表读回并复用 render-ready query 投影,前端不参与业务计算。
6
7use 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
24/// Classify a missing-data reply using fixed values; never log remote payloads or messages.
25fn 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    /// Preserve the actionable failure category without exporting arbitrary server text.
50    #[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    /// B-rest 测试 / driver 入口:直接喂一个解析好的 `increment_channel` 帧。
128    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    /// 记录 increment 应用在内存中写入的临时壳,供持久化失败时恢复旧状态。
166    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    /// 回滚尚未通过 durable channel/member barrier 的本地临时事实。
177    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    /// 从 driver 的 rows JSON 回包解出对象列表,畸形回包 fail closed。
205    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    /// 从 Go G09d raw snapshot 冻结有序全角色 roster,并校验 memberCount 与唯一 user id。
217    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    /// 将 durable member rows 按冻结位序重建完整 channel roster,并拒绝租户/集合漂移。
259    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            // member.teamId 是成员自身公司,允许与频道/当前 UI 公司不同。
277            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    /// 发送 channel row 读回,后续阶段均由 matching corr 串联。
342    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    /// channel row 读回成功后,扫描完整 durable roster 并在下一阶段验证 viewer。
364    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    /// member row 读回成功后,读取频道消息窗口。
425    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    /// 消息窗口读回成功后,读取 durable cursor。
497    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    /// cursor 读回成功后只释放一个 ChannelHydrationResult。
547    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    /// 失败终态统一清理 hydration bookkeeping 并只回灌一次 error Result。
638    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        // 尚未加入同步的独立 persist 失败,不能清掉另一请求正在等待的终态。
646        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    /// 以 channel 取回 hydration reqId 并结算一次失败,供 sync caller 处理解析/网络错误。
674    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    /// Decode hydration replies; diagnostics do not change the existing Result contract.
687    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                    // needSync 是服务端按其会话水位给的提示,不能覆盖本端持久化事实。独立窗口
768                    // 首次见到频道时本地 cursor=0;只要仍落后 lastEventSeq,就必须从本地 cursor
769                    // 拉 sync,把 events/messages 与 cursor 在同一 commit 链落稳后再做终态投影。
770                    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    /// 已关闭频道不能再同步;通过持久化屏障后直接读回归档投影,结算补水。
874    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        // needSync 缺失默认 true,但 scheduler 永远跳过 terminal;不能等待不存在的 sync 回执。
901        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    /// 同步终态或归档快照落库后,从本地表读回该频道投影与 messages。
924    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}