Skip to main content

helix_im/query/
local_first.rs

1//! 最近消息 local-first 状态机。
2//!
3//! 这里只编排业务意图:本地覆盖判定、远端 `getLatestPost`、合并、缓存与统一投影。
4//! Storage/HTTP 的物理执行仍由平台 driver 兑现。
5
6use helix_core::effect::Effect;
7use helix_core::tick::PortOutcome;
8use helix_core::EffectSink;
9use serde_json::Value;
10
11use crate::error::ImError;
12use crate::module::ImModule;
13use crate::state::{ChannelId, CorrelationContext};
14
15use super::{MessageQueryRequest, SubtopicsQueryRequest};
16
17mod data;
18
19use data::{
20    classify_local_read, dedup_recent_rows, message_key, parse_latest_posts_reply, server_id,
21    stale_local_server_rows_delete_op,
22};
23pub(crate) use data::{parse_local_rows, sort_recent_rows_desc, visible_remote_rows_and_cache_ops};
24
25/// 解析 Go G11h initial-window 业务体,并保证非空窗口的首条就是请求目标。
26pub(crate) fn parse_initial_window_posts(
27    raw_body: &[u8],
28    target_post_id: &str,
29) -> Result<Vec<Value>, ImError> {
30    let root: Value = serde_json::from_slice(raw_body)
31        .map_err(|error| ImError::Parse(format!("getPostsAfterIndex body: {error}")))?;
32    let status = root
33        .get("status")
34        .and_then(Value::as_str)
35        .ok_or_else(|| ImError::Parse("getPostsAfterIndex body missing string status".into()))?;
36    if !status.eq_ignore_ascii_case("SUCCESS") {
37        return Err(ImError::Parse(format!(
38            "getPostsAfterIndex backend status {status}"
39        )));
40    }
41    let payload = root
42        .pointer("/data/posts")
43        .or_else(|| root.get("data"))
44        .ok_or_else(|| {
45            ImError::Parse("getPostsAfterIndex response missing posts array".to_string())
46        })?;
47    if payload.is_null() {
48        return Ok(Vec::new());
49    }
50    let rows = payload.as_array().ok_or_else(|| {
51        ImError::Parse("getPostsAfterIndex response posts must be array".to_string())
52    })?;
53    if rows.iter().any(|row| !row.is_object()) {
54        return Err(ImError::Parse(
55            "getPostsAfterIndex posts must be objects".to_string(),
56        ));
57    }
58    let rows = rows.clone();
59    if rows.is_empty() {
60        return Ok(rows);
61    }
62    if !post_matches_identity(&rows[0], target_post_id) {
63        return Err(ImError::Parse(
64            "getPostsAfterIndex target must be first row".to_string(),
65        ));
66    }
67    Ok(rows)
68}
69
70/// 比较 canonical server id 与 temporaryId,兼容只知任一 post identity 的 caller。
71pub(crate) fn post_matches_identity(row: &Value, target_post_id: &str) -> bool {
72    ["id", "postId", "temporaryId", "temporary_id"]
73        .iter()
74        .filter_map(|key| row.get(*key).and_then(Value::as_str))
75        .any(|value| value == target_post_id)
76}
77
78/// 平台提供的本地数据能力。它描述物理数据寿命,不承诺某次查询已经完整。
79#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
80pub enum LocalStoreMode {
81    #[default]
82    Durable,
83    Session,
84    Disabled,
85}
86
87/// 某次本地读相对“最近消息窗口”的业务覆盖结论。
88#[derive(Debug, Clone, Copy, PartialEq, Eq)]
89pub enum LocalReadCoverage {
90    Complete,
91    Partial,
92    Miss,
93    Unsupported,
94}
95
96/// `getLatestPost` 的权威基础窗口。服务端可能为同 segment 补回更多行,但不会因此证明更早窗口。
97const REMOTE_RECENT_WINDOW: usize = 20;
98
99/// 当前连接会话内、成功远端读取且成功缓存后的覆盖证明。
100///
101/// 只记消息身份,不拿“本地有 N 行”冒充完整。hello/disconnect 会清空;cache Persist 失败不记录。
102#[doc(hidden)]
103#[derive(Debug, Clone, PartialEq)]
104pub struct RecentMessageCoverage {
105    remote_keys_desc: Vec<String>,
106    remote_exhausted: bool,
107}
108
109impl RecentMessageCoverage {
110    /// 记录服务端已证明的最近窗口;是否到顶由本地与远端联合判断后显式传入。
111    fn from_remote(rows_desc: &[Value], remote_exhausted: bool) -> Option<Self> {
112        // getLatestPost 满 20 后可能把同 segment 一并补回;多出来的行可用于本次展示,
113        // 但不能把基础 20 窗口扩张成“服务端证明了 21+ 条完整”。
114        let proven_len = if remote_exhausted {
115            rows_desc.len()
116        } else {
117            rows_desc.len().min(REMOTE_RECENT_WINDOW)
118        };
119        let remote_keys_desc: Vec<String> = rows_desc
120            .iter()
121            .take(proven_len)
122            .filter_map(message_key)
123            .collect();
124        if remote_keys_desc.is_empty() {
125            return None;
126        }
127        Some(Self {
128            remote_keys_desc,
129            remote_exhausted,
130        })
131    }
132}
133
134/// 短远端窗口只有在本地不存在更早服务端消息时,才可证明全局历史到顶。
135fn recent_reply_proves_history_exhausted(
136    local_rows_desc: &[Value],
137    remote_rows_desc: &[Value],
138    received_count: usize,
139) -> bool {
140    if received_count >= REMOTE_RECENT_WINDOW {
141        return false;
142    }
143    let oldest_remote = remote_rows_desc.iter().filter_map(message_create_at).min();
144    !oldest_remote.is_some_and(|oldest| {
145        local_rows_desc.iter().any(|row| {
146            !server_id(row).is_empty()
147                && message_create_at(row).is_some_and(|create_at| create_at < oldest)
148        })
149    })
150}
151
152/// 兼容本地 snake_case 与 render-ready camelCase 行,统一提取消息时间。
153fn message_create_at(row: &Value) -> Option<i64> {
154    row.get("create_at")
155        .or_else(|| row.get("createAt"))
156        .or_else(|| row.get("createdAt"))
157        .and_then(Value::as_i64)
158}
159
160impl ImModule {
161    /// 用当前 RuntimeAuth 构造本地 dialog Scan,不把 company/user 接受为 payload 意图。
162    pub(crate) fn build_dialog_list_query_for_runtime(
163        &self,
164        payload: &[u8],
165        corr: helix_core::Correlation,
166    ) -> Result<Effect, ImError> {
167        let scope = super::DialogListScope::new(
168            self.config.auth_user_id.as_str(),
169            self.config.company_id.as_str(),
170        );
171        super::build_dialog_list_query_for_scope(payload, corr, &scope)
172    }
173
174    /// 将 dialog Scan 回报交给唯一 typed Result 通道,失败仍返回空 items。
175    pub(crate) fn emit_dialog_list_result_for_runtime(
176        &self,
177        req_id: Option<&str>,
178        reply_bytes: &[u8],
179    ) -> Effect {
180        let scope = super::DialogListScope::new(
181            self.config.auth_user_id.as_str(),
182            self.config.company_id.as_str(),
183        );
184        super::emit_dialog_list_result(req_id.unwrap_or_default(), reply_bytes, &scope)
185    }
186
187    /// 用当前 RuntimeAuth 构造本地 topic Scan,不把 parent 作用域交给 caller。
188    pub(crate) fn build_subtopics_query_for_runtime(
189        &self,
190        request: &SubtopicsQueryRequest,
191        corr: helix_core::Correlation,
192    ) -> Result<Effect, ImError> {
193        let scope = super::DialogListScope::new(
194            self.config.auth_user_id.as_str(),
195            self.config.company_id.as_str(),
196        );
197        super::build_subtopics_query_for_scope(request, corr, &scope)
198    }
199
200    /// 将 topic Scan 回报交给唯一 typed Result,失败与空 parent 都返回空 items。
201    pub(crate) fn emit_subtopics_result_for_runtime(
202        &self,
203        req_id: Option<&str>,
204        parent_channel_id: Option<&str>,
205        reply_bytes: &[u8],
206    ) -> Effect {
207        let scope = super::DialogListScope::new(
208            self.config.auth_user_id.as_str(),
209            self.config.company_id.as_str(),
210        );
211        super::emit_subtopics_result(
212            req_id.unwrap_or_default(),
213            reply_bytes,
214            &scope,
215            parent_channel_id,
216        )
217    }
218
219    /// A durable mutation may change an already attached latest timeline without
220    /// another renderer intent. Re-enter the existing local-first projector
221    /// after the write acknowledgement instead of leaking a legacy `im:post:*`
222    /// payload to a client-side reducer.
223    pub(crate) fn refresh_attached_latest_timeline(
224        &mut self,
225        channel_id: ChannelId,
226        causation_id: Option<String>,
227        out: &mut EffectSink,
228    ) -> Result<(), ImError> {
229        self.refresh_attached_latest_timeline_with_deferred_send(
230            channel_id,
231            causation_id,
232            None,
233            out,
234        )
235        .map(|_| ())
236    }
237
238    /// 对已附着的指定窗口执行权威读回。
239    pub(crate) fn refresh_attached_timeline(
240        &mut self,
241        channel_id: ChannelId,
242        window_token: &str,
243        causation_id: Option<String>,
244        out: &mut EffectSink,
245    ) -> Result<(), ImError> {
246        self.refresh_attached_timeline_window_with_deferred_send(
247            channel_id,
248            window_token,
249            causation_id,
250            None,
251            out,
252        )
253        .map(|_| ())
254    }
255
256    /// 已附着窗口先读回持久事实,再释放普通消息 HTTP。
257    pub(crate) fn refresh_attached_latest_timeline_with_deferred_send(
258        &mut self,
259        channel_id: ChannelId,
260        causation_id: Option<String>,
261        deferred_send_http: Option<crate::state::TemporaryId>,
262        out: &mut EffectSink,
263    ) -> Result<bool, ImError> {
264        let Some((window_token, _)) = self
265            .state
266            .timeline_state
267            .unique_attached_window_for_channel(channel_id.as_str())
268        else {
269            return Ok(false);
270        };
271        self.refresh_attached_timeline_window_with_deferred_send(
272            channel_id,
273            window_token.as_str(),
274            causation_id,
275            deferred_send_http,
276            out,
277        )
278    }
279
280    /// 写后回读为新事实预留一个槽位,避免短窗口用固定页长挤掉仍可见的旧消息。
281    fn refresh_attached_timeline_window_with_deferred_send(
282        &mut self,
283        channel_id: ChannelId,
284        window_token: &str,
285        causation_id: Option<String>,
286        deferred_send_http: Option<crate::state::TemporaryId>,
287        out: &mut EffectSink,
288    ) -> Result<bool, ImError> {
289        let scope = crate::timeline_state::TimelineScope {
290            channel_id: channel_id.as_str().to_string(),
291            window_token: window_token.to_string(),
292        };
293        if !self.state.timeline_state.is_attached(&scope) {
294            return Ok(false);
295        }
296        let visible_limit = self
297            .state
298            .timeline_state
299            .current_view(&scope)
300            .map(|view| view.items.len())
301            .filter(|visible| *visible > 0)
302            .and_then(|visible| u32::try_from(visible).ok())
303            .map(|visible| {
304                visible
305                    .saturating_add(1)
306                    .min(crate::timeline_state::MAX_TIMELINE_WINDOW_ITEMS as u32)
307            })
308            .unwrap_or(super::QUERY_MESSAGES_DEFAULT);
309        let payload = serde_json::json!({
310            "channel_id": channel_id.as_str(),
311            "window_token": window_token,
312            "limit": visible_limit,
313        });
314        let bytes =
315            serde_json::to_vec(&payload) // hot-path-audit: ignore - HTTP wire body,不写入单列。
316                .map_err(|error| {
317                    ImError::Serialize(format!("attached timeline refresh: {error}"))
318                })?;
319        self.dispatch_message_query_with_causation_and_deferred_send(
320            &bytes,
321            false,
322            causation_id,
323            deferred_send_http,
324            out,
325        )?;
326        Ok(true)
327    }
328
329    /// `im_query_messages_by_channel` 的唯一异步入口。
330    pub(crate) fn dispatch_message_query(
331        &mut self,
332        payload: &[u8],
333        out: &mut EffectSink,
334    ) -> Result<(), ImError> {
335        self.dispatch_message_query_with_causation_and_deferred_send(payload, true, None, None, out)
336    }
337
338    /// 执行 local-first 查询并保留内部因果键与发送 continuation。
339    fn dispatch_message_query_with_causation_and_deferred_send(
340        &mut self,
341        payload: &[u8],
342        allow_remote_fallback: bool,
343        causation_id: Option<String>,
344        deferred_send_http: Option<crate::state::TemporaryId>,
345        out: &mut EffectSink,
346    ) -> Result<(), ImError> {
347        let request = super::parse_message_query(payload)?;
348        let query_generation = self
349            .state
350            .begin_message_query_generation(request.channel_id, request.window_token.as_str());
351        let corr = self.alloc_corr_internal();
352        out.push(super::build_message_query_from_request(&request, corr));
353        self.state.corr_map.insert(
354            corr,
355            CorrelationContext::MessageQueryLocal {
356                request: Box::new(request),
357                query_session_epoch: self.state.query_session_epoch,
358                query_generation,
359                allow_remote_fallback,
360                causation_id,
361                deferred_send_http,
362            },
363        );
364        Ok(())
365    }
366
367    /// 消费本地读回;过期窗口只丢弃投影,已持久化发送仍按消息衔接。
368    pub(crate) fn handle_message_query_local_reply(
369        &mut self,
370        request: MessageQueryRequest,
371        query_session_epoch: u64,
372        query_generation: u64,
373        allow_remote_fallback: bool,
374        causation_id: Option<String>,
375        deferred_send_http: Option<crate::state::TemporaryId>,
376        now_ms: u64,
377        outcome: &PortOutcome,
378        out: &mut EffectSink,
379    ) -> Result<(), ImError> {
380        if !self.is_current_message_query(
381            request.channel_id,
382            &request.window_token,
383            query_session_epoch,
384            query_generation,
385        ) {
386            // 查询代际/WS 断线只撤销投影;身份切换已从 corr_map 删除此衔接。
387            // 仅查找当前 temporaryId,不扫描其它 pending sends 或处理废弃行。
388            self.emit_deferred_posts_create_after_timeline_event(deferred_send_http, out)?;
389            return Ok(());
390        }
391        let mut local_rows_desc = match outcome {
392            PortOutcome::Ok(reply) => match parse_local_rows(reply.0.as_ref()) {
393                Ok(rows) => rows,
394                Err(error) => {
395                    tracing::warn!(
396                        channel_id = request.channel_id.as_str(),
397                        error = ?error,
398                        allow_remote_fallback,
399                        "message query local scan reply malformed"
400                    );
401                    if !allow_remote_fallback {
402                        out.push(self.emit_timeline_failed(&request, now_ms, causation_id)?);
403                        self.emit_deferred_posts_create_after_timeline_event(
404                            deferred_send_http,
405                            out,
406                        )?;
407                        return Ok(());
408                    }
409                    Vec::new()
410                }
411            },
412            PortOutcome::Err(error) => {
413                tracing::warn!(
414                    channel_id = request.channel_id.as_str(),
415                    error = ?error,
416                    allow_remote_fallback,
417                    "message query local scan failed"
418                );
419                if !allow_remote_fallback {
420                    out.push(self.emit_timeline_failed(&request, now_ms, causation_id)?);
421                    self.emit_deferred_posts_create_after_timeline_event(deferred_send_http, out)?;
422                    return Ok(());
423                }
424                Vec::new()
425            }
426        };
427        sort_recent_rows_desc(&mut local_rows_desc);
428
429        if !allow_remote_fallback {
430            out.push(self.emit_timeline_snapshot_with_causation(
431                &request,
432                &local_rows_desc,
433                now_ms,
434                causation_id,
435                None,
436            )?);
437            self.emit_deferred_posts_create_after_timeline_event(deferred_send_http, out)?;
438            return Ok(());
439        }
440
441        let coverage = classify_local_read(
442            self.local_store_mode,
443            &request,
444            &local_rows_desc,
445            self.state.recent_message_coverage.get(&request.channel_id),
446            self.message_query_has_known_gap(request.channel_id),
447        );
448        tracing::debug!(
449            channel_id = request.channel_id.as_str(),
450            ?coverage,
451            local_rows = local_rows_desc.len(),
452            "message query local coverage classified"
453        );
454
455        if coverage == LocalReadCoverage::Complete {
456            out.push(self.emit_timeline_snapshot_with_causation(
457                &request,
458                &local_rows_desc,
459                now_ms,
460                causation_id,
461                None,
462            )?);
463            self.emit_deferred_posts_create_after_timeline_event(deferred_send_http, out)?;
464            return Ok(());
465        }
466        self.start_remote_message_query(
467            request,
468            local_rows_desc,
469            query_generation,
470            causation_id,
471            deferred_send_http,
472            out,
473        )
474    }
475
476    /// 解析 Go 权威最近消息并落 durable cache;最终窗口必须等待后续 Scan read-back。
477    pub(crate) fn handle_message_query_remote_reply(
478        &mut self,
479        request: MessageQueryRequest,
480        local_rows_desc: Vec<Value>,
481        query_session_epoch: u64,
482        query_generation: u64,
483        causation_id: Option<String>,
484        deferred_send_http: Option<crate::state::TemporaryId>,
485        now_ms: u64,
486        outcome: &PortOutcome,
487        out: &mut EffectSink,
488    ) -> Result<(), ImError> {
489        let current_query = self.is_current_message_query(
490            request.channel_id,
491            &request.window_token,
492            query_session_epoch,
493            query_generation,
494        );
495        if !current_query {
496            return Ok(());
497        }
498        let reply = match outcome {
499            PortOutcome::Ok(reply) => reply,
500            PortOutcome::Err(error) => {
501                tracing::warn!(
502                    channel_id = request.channel_id.as_str(),
503                    error = ?error,
504                    "message query remote fallback failed"
505                );
506                out.push(self.emit_timeline_failed(&request, now_ms, causation_id)?);
507                self.emit_deferred_posts_create_after_timeline_event(deferred_send_http, out)?;
508                return Ok(());
509            }
510        };
511
512        let remote_posts = match parse_latest_posts_reply(reply) {
513            Ok(posts) => posts,
514            Err(error) => {
515                tracing::warn!(
516                    channel_id = request.channel_id.as_str(),
517                    error = ?error,
518                    "message query remote fallback returned invalid response"
519                );
520                out.push(self.emit_timeline_failed(&request, now_ms, causation_id)?);
521                self.emit_deferred_posts_create_after_timeline_event(deferred_send_http, out)?;
522                return Ok(());
523            }
524        };
525        let received_count = remote_posts.len();
526        let (mut remote_rows_desc, mut cache_ops) = match visible_remote_rows_and_cache_ops(
527            request.channel_id,
528            remote_posts,
529            &local_rows_desc,
530            self.config.auth_user_id.as_str(),
531        ) {
532            Ok(result) => result,
533            Err(error) => {
534                tracing::warn!(
535                    channel_id = request.channel_id.as_str(),
536                    error = ?error,
537                    "message query remote fallback contained invalid posts"
538                );
539                out.push(self.emit_timeline_failed(&request, now_ms, causation_id)?);
540                self.emit_deferred_posts_create_after_timeline_event(deferred_send_http, out)?;
541                return Ok(());
542            }
543        };
544        dedup_recent_rows(&mut remote_rows_desc);
545        sort_recent_rows_desc(&mut remote_rows_desc);
546
547        let remote_exhausted = recent_reply_proves_history_exhausted(
548            &local_rows_desc,
549            &remote_rows_desc,
550            received_count,
551        );
552        let coverage = RecentMessageCoverage::from_remote(&remote_rows_desc, remote_exhausted);
553        if remote_exhausted {
554            if let Some(delete) = stale_local_server_rows_delete_op(
555                request.channel_id,
556                &local_rows_desc,
557                &remote_rows_desc,
558            ) {
559                cache_ops.push(delete);
560            }
561        }
562        let corr = self.alloc_corr_internal();
563        out.push(Effect::Persist {
564            corr,
565            ops: cache_ops,
566        });
567        self.state.corr_map.insert(
568            corr,
569            CorrelationContext::MessageQueryCache {
570                request: Box::new(request),
571                coverage,
572                query_session_epoch,
573                query_generation,
574                causation_id,
575                deferred_send_http,
576            },
577        );
578        Ok(())
579    }
580
581    /// cache Persist 成功后只发起 durable message Scan,失败则不触碰已附着窗口。
582    pub(crate) fn handle_message_query_cache_reply(
583        &mut self,
584        request: MessageQueryRequest,
585        coverage: Option<RecentMessageCoverage>,
586        query_session_epoch: u64,
587        query_generation: u64,
588        causation_id: Option<String>,
589        deferred_send_http: Option<crate::state::TemporaryId>,
590        now_ms: u64,
591        outcome: &PortOutcome,
592        out: &mut EffectSink,
593    ) -> Result<(), ImError> {
594        if !self.is_current_message_query(
595            request.channel_id,
596            &request.window_token,
597            query_session_epoch,
598            query_generation,
599        ) {
600            return Ok(());
601        }
602        if let PortOutcome::Err(error) = outcome {
603            tracing::warn!(
604                channel_id = request.channel_id.as_str(),
605                error = ?error,
606                "message query remote cache failed; preserving previous timeline"
607            );
608            out.push(self.emit_timeline_failed(&request, now_ms, causation_id)?);
609            self.emit_deferred_posts_create_after_timeline_event(deferred_send_http, out)?;
610            return Ok(());
611        }
612
613        // Cache success is only a barrier; the next Scan is the sole source of render rows.
614        let corr = self.alloc_corr_internal();
615        out.push(Effect::Persist {
616            corr,
617            ops: vec![helix_core::effect::StorageOp::Scan(
618                super::message_scan_spec(&request),
619            )],
620        });
621        self.state.corr_map.insert(
622            corr,
623            CorrelationContext::MessageQueryReadback {
624                request: Box::new(request),
625                coverage,
626                query_session_epoch,
627                query_generation,
628                causation_id,
629                deferred_send_http,
630            },
631        );
632        Ok(())
633    }
634
635    /// 读取 cache Persist 后的 durable message rows,并发布唯一 timeline 终态。
636    pub(crate) fn handle_message_query_readback_reply(
637        &mut self,
638        request: MessageQueryRequest,
639        coverage: Option<RecentMessageCoverage>,
640        query_session_epoch: u64,
641        query_generation: u64,
642        causation_id: Option<String>,
643        deferred_send_http: Option<crate::state::TemporaryId>,
644        now_ms: u64,
645        outcome: &PortOutcome,
646        out: &mut EffectSink,
647    ) -> Result<(), ImError> {
648        if !self.is_current_message_query(
649            request.channel_id,
650            &request.window_token,
651            query_session_epoch,
652            query_generation,
653        ) {
654            return Ok(());
655        }
656        let mut rows_desc = match outcome {
657            PortOutcome::Ok(reply) => match parse_local_rows(reply.0.as_ref()) {
658                Ok(rows) => rows,
659                Err(error) => {
660                    tracing::warn!(
661                        channel_id = request.channel_id.as_str(),
662                        error = ?error,
663                        "message query durable read-back malformed"
664                    );
665                    out.push(self.emit_timeline_failed(&request, now_ms, causation_id)?);
666                    self.emit_deferred_posts_create_after_timeline_event(deferred_send_http, out)?;
667                    return Ok(());
668                }
669            },
670            PortOutcome::Err(error) => {
671                tracing::warn!(
672                    channel_id = request.channel_id.as_str(),
673                    error = ?error,
674                    "message query durable read-back failed; preserving previous timeline"
675                );
676                out.push(self.emit_timeline_failed(&request, now_ms, causation_id)?);
677                self.emit_deferred_posts_create_after_timeline_event(deferred_send_http, out)?;
678                return Ok(());
679            }
680        };
681        sort_recent_rows_desc(&mut rows_desc);
682        if let Some(coverage) = coverage {
683            self.state
684                .recent_message_coverage
685                .insert(request.channel_id, coverage);
686        }
687        out.push(self.emit_timeline_snapshot_with_causation(
688            &request,
689            &rows_desc,
690            now_ms,
691            causation_id,
692            None,
693        )?);
694        self.emit_deferred_posts_create_after_timeline_event(deferred_send_http, out)?;
695        Ok(())
696    }
697
698    /// 发起单次 Go `getLatestPost` 查询并登记其 query/retry continuation。
699    fn start_remote_message_query(
700        &mut self,
701        request: MessageQueryRequest,
702        local_rows_desc: Vec<Value>,
703        query_generation: u64,
704        causation_id: Option<String>,
705        deferred_send_http: Option<crate::state::TemporaryId>,
706        out: &mut EffectSink,
707    ) -> Result<(), ImError> {
708        let corr = self.alloc_corr_internal();
709        let payload = serde_json::to_vec(&serde_json::json!({
710            "channel_id": request.channel_id.as_str(),
711            "timestamp": 0,
712            "cursor_version": 1,
713            "page_size": request.limit,
714        }))
715        .map_err(|error| ImError::Serialize(error.to_string()))?;
716        let mut effects = crate::commands::handle_outbound(
717            "im_get_latest_post",
718            &payload,
719            self.config.api_base_url.as_str(),
720            self.config.default_api_base_url.as_str(),
721            self.state.connection_id.as_deref(),
722            corr,
723        )?;
724        if effects.len() != 1 {
725            return Err(ImError::Parse(format!(
726                "im_get_latest_post expected one HTTP effect, got {}",
727                effects.len()
728            )));
729        }
730        let effect = effects
731            .pop()
732            .ok_or_else(|| ImError::Parse("im_get_latest_post produced no effect".to_string()))?;
733        if !matches!(&effect, Effect::Http { .. }) {
734            return Err(ImError::Parse(
735                "im_get_latest_post did not produce Effect::Http".to_string(),
736            ));
737        }
738        out.push(effect);
739        self.state.corr_map.insert(
740            corr,
741            CorrelationContext::MessageQueryRemote {
742                request: Box::new(request),
743                local_rows_desc: Box::new(local_rows_desc),
744                query_session_epoch: self.state.query_session_epoch,
745                query_generation,
746                causation_id,
747                deferred_send_http,
748            },
749        );
750        Ok(())
751    }
752
753    fn emit_deferred_posts_create_after_timeline_event(
754        &mut self,
755        deferred_send_http: Option<crate::state::TemporaryId>,
756        out: &mut EffectSink,
757    ) -> Result<(), ImError> {
758        let Some(temporary_id) = deferred_send_http else {
759            return Ok(());
760        };
761        let (channel_id, body) = self
762            .state
763            .pending_sends
764            .get(&temporary_id)
765            .and_then(|pending| {
766                pending.body.as_ref().and_then(|body| {
767                    body.get("channelId")
768                        .and_then(serde_json::Value::as_str)
769                        .and_then(ChannelId::from_str)
770                        .map(|channel_id| (channel_id, body.clone()))
771                })
772            })
773            .ok_or_else(|| {
774                ImError::Parse(format!(
775                    "deferred posts/create missing pending send body: {}",
776                    temporary_id.0
777                ))
778            })?;
779        self.emit_posts_create_http(channel_id, temporary_id, &body, out)
780    }
781
782    fn message_query_has_known_gap(&self, channel_id: ChannelId) -> bool {
783        let Some(channel) = self.state.channels.get(&channel_id) else {
784            return false;
785        };
786        let behind_target = self
787            .state
788            .increment_target
789            .get(&channel_id)
790            .is_some_and(|target| channel.cursor.value() < *target);
791        behind_target || channel.inflight_sync.is_some() || !channel.buffer.is_empty()
792    }
793
794    fn is_current_message_query(
795        &self,
796        channel_id: ChannelId,
797        window_token: &str,
798        query_session_epoch: u64,
799        query_generation: u64,
800    ) -> bool {
801        query_session_epoch == self.state.query_session_epoch
802            && self.state.is_current_message_query_generation(
803                channel_id,
804                window_token,
805                query_generation,
806            )
807    }
808
809    /// 把一页服务端行的最新时间登记为该频道的服务端时间下界,供后续乐观发送对齐 `createAt`。
810    ///
811    /// 只取有服务端 id 的行(本地未对账行的时间来自客户端时钟,不能反过来抬高下界)。
812    /// 每页一次 O(R) 扫描,R 为本页行数(有界);不读 DOM、不发额外查询。
813    fn observe_rows_create_at(&mut self, channel_id: ChannelId, rows: &[Value]) {
814        let newest = rows
815            .iter()
816            .filter(|row| {
817                ["id", "msgId", "postId", "post_id"].iter().any(|key| {
818                    row.get(*key)
819                        .and_then(Value::as_str)
820                        .is_some_and(|value| !value.is_empty())
821                })
822            })
823            .filter_map(|row| {
824                row.get("create_at")
825                    .or_else(|| row.get("createAt"))
826                    .or_else(|| row.get("createdAt"))
827                    .and_then(Value::as_i64)
828            })
829            .max();
830        if let Some(create_at) = newest {
831            self.state.observe_channel_create_at(channel_id, create_at);
832        }
833    }
834
835    /// local-first 已完成排序、去重、权限与 render-ready shaping 后的唯一 timeline 出口。
836    /// `rows_desc` 可含一条本地 lookahead;这里只投影请求页长,并把额外行转成分页事实。
837    pub(crate) fn emit_timeline_snapshot_with_causation(
838        &mut self,
839        request: &MessageQueryRequest,
840        rows_desc: &[Value],
841        _now_ms: u64,
842        causation_id: Option<String>,
843        page_override: Option<crate::timeline_state::WindowPage>,
844    ) -> Result<Effect, ImError> {
845        // 时间线是本会话本地乐观消息的唯一排序参照;在时间线出口登记本页服务端时间下界,
846        // 覆盖 latest/locate/older/read-back 全部路径,每页一次 O(R)。
847        self.observe_rows_create_at(request.channel_id, rows_desc);
848        let scope = crate::timeline_state::TimelineScope {
849            channel_id: request.channel_id.as_str().to_string(),
850            window_token: request.window_token.to_string(),
851        };
852        let current_view = self.state.timeline_state.current_view(&scope);
853        let anchored_window = current_view.is_some_and(|view| {
854            view.anchor.mode == crate::timeline_state::TimelineAnchorMode::Locate
855        });
856        let anchored_create_at = current_view.and_then(|view| {
857            let anchor_id = view.anchor.message_id.as_deref()?;
858            view.items
859                .iter()
860                .find(|item| item.id == anchor_id)
861                .map(|item| item.created_at)
862        });
863        let anchored_page_bounds = current_view
864            .filter(|_| anchored_window)
865            .map(|view| (view.page.has_older, view.page.has_newer, view.page.has_more));
866        let had_attached_window = current_view.is_some();
867        let visible_len = current_view
868            .filter(|view| view.page.has_older)
869            .map_or(request.limit as usize, |view| view.items.len());
870        let has_local_older = rows_desc.len() > visible_len;
871        let rows_asc = Value::Array(rows_desc.iter().take(visible_len).rev().cloned().collect());
872        let shaped = crate::render_ready::shape_message_rows_for_viewer(
873            &rows_asc,
874            self.config.auth_user_id.as_str(),
875        );
876        let rows = shaped.as_array().ok_or_else(|| {
877            ImError::Parse("render-ready timeline rows must be an array".to_string())
878        })?;
879        let mut timeline_request =
880            crate::timeline_state::TimelineWindowRequest::latest_with_window_token(
881                request.channel_id.as_str(),
882                request.window_token.as_str(),
883            );
884        timeline_request.page_size = visible_len as u32;
885        if let Some(page) = page_override {
886            timeline_request.page = page;
887        } else if let Some((has_older, has_newer, has_more)) = anchored_page_bounds {
888            timeline_request.page.has_older = has_older;
889            timeline_request.page.has_newer = has_newer;
890            timeline_request.page.has_more = has_more;
891        } else if has_local_older
892            || self
893                .state
894                .recent_message_coverage
895                .get(&request.channel_id)
896                .is_some_and(|coverage| !coverage.remote_exhausted)
897        {
898            // A full authoritative recent window proves only that older rows
899            // may exist. Preserve that proof in the render contract so shells
900            // can expose the bounded load-older action without guessing from
901            // the visible row count.
902            timeline_request.page.has_older = true;
903            timeline_request.page.has_more = true;
904        }
905        let has_older = timeline_request.page.has_older;
906        let has_newer = timeline_request.page.has_newer;
907        if anchored_window && timeline_request.target_message_id.is_none() {
908            // 离底窗口收到 durable 新消息时只向新侧扩展;不得用 latest scan 隐式重定位。
909            self.state.timeline_state.patch_page_from_render_ready(
910                timeline_request,
911                rows,
912                crate::timeline_state::TimelinePageMutation::Newer,
913                causation_id,
914            )
915        } else if self.state.timeline_state.current_view(&scope).is_some() {
916            self.state
917                .timeline_state
918                .patch_from_render_ready(timeline_request, rows, causation_id)
919        } else {
920            self.state
921                .timeline_state
922                .snapshot_from_render_ready_with_causation(timeline_request, rows, causation_id)
923        }
924        .map_err(|error| ImError::Parse(format!("timeline state: {error}")))?;
925        // Timeline events must carry the same render-ready rows as the attached state.
926        let event_rows = rows.to_vec();
927        let anchor_post_id = self
928            .state
929            .timeline_state
930            .current_view(&scope)
931            .and_then(|view| view.anchor.message_id.as_deref());
932        let effect = if !had_attached_window {
933            crate::event::timeline::window(
934                request.channel_id.as_str(),
935                request.window_token.as_str(),
936                "ready",
937                event_rows,
938                has_older,
939                has_newer,
940                None,
941            )?
942        } else if anchored_window {
943            let anchor_create_at = event_rows
944                .iter()
945                .find(|row| {
946                    row.get("id")
947                        .or_else(|| row.get("msgId"))
948                        .or_else(|| row.get("temporaryId"))
949                        .and_then(Value::as_str)
950                        == anchor_post_id
951                })
952                .and_then(|row| {
953                    row.get("createAt")
954                        .or_else(|| row.get("createdAt"))
955                        .or_else(|| row.get("create_at"))
956                })
957                .and_then(Value::as_i64)
958                .or(anchored_create_at);
959            let newer_count = event_rows
960                .iter()
961                .filter(|row| {
962                    let create_at = row
963                        .get("createAt")
964                        .or_else(|| row.get("createdAt"))
965                        .or_else(|| row.get("create_at"))
966                        .and_then(Value::as_i64);
967                    create_at.zip(anchor_create_at).is_some_and(
968                        |(message_create_at, anchor_create_at)| {
969                            message_create_at > anchor_create_at
970                        },
971                    )
972                })
973                .count();
974            crate::event::timeline::anchored_update(
975                request.channel_id.as_str(),
976                request.window_token.as_str(),
977                "ready",
978                event_rows,
979                has_older,
980                has_newer,
981                anchor_post_id,
982                newer_count,
983            )?
984        } else {
985            crate::event::timeline::page(
986                request.channel_id.as_str(),
987                request.window_token.as_str(),
988                "append",
989                "ready",
990                event_rows,
991                has_older,
992                has_newer,
993                anchor_post_id,
994            )?
995        }
996        .into_effect();
997        Ok(effect)
998    }
999
1000    /// 把已持久化并读回确认的 V3 导航页投影为同一 attached slot 的原子 Delta。
1001    pub(crate) fn emit_timeline_navigation_page(
1002        &mut self,
1003        state: &crate::timeline_navigation::TimelineNavigationState,
1004        _now_ms: u64,
1005    ) -> Result<Effect, ImError> {
1006        self.observe_rows_create_at(state.channel_id(), state.rows());
1007        let rows = Value::Array(state.rows().to_vec());
1008        let shaped = crate::render_ready::shape_message_rows_for_viewer(
1009            &rows,
1010            self.config.auth_user_id.as_str(),
1011        );
1012        let rows = shaped.as_array().ok_or_else(|| {
1013            ImError::Parse("timeline navigation rows must shape to array".to_string())
1014        })?;
1015        // Navigation effects use the shaped copy; `state.rows()` is the storage-facing input.
1016        let render_rows = rows.to_vec();
1017        let mut request = crate::timeline_state::TimelineWindowRequest::latest_with_window_token(
1018            state.channel_id().as_str(),
1019            state.window_token(),
1020        );
1021        request.page_size = state.page_size();
1022        request.page = state.page();
1023        let locate_is_current = match state.kind() {
1024            crate::timeline_navigation::TimelineNavigationKind::Locate {
1025                navigation_token, ..
1026            } => self.state.timeline_state.is_current_locate_navigation(
1027                state.channel_id().as_str(),
1028                state.window_token(),
1029                navigation_token,
1030            ),
1031            _ => true,
1032        };
1033        let page_mutation = match state.kind() {
1034            crate::timeline_navigation::TimelineNavigationKind::Older { .. } => {
1035                crate::timeline_state::TimelinePageMutation::Older
1036            }
1037            crate::timeline_navigation::TimelineNavigationKind::Newer { .. } => {
1038                crate::timeline_state::TimelinePageMutation::Newer
1039            }
1040            crate::timeline_navigation::TimelineNavigationKind::Locate {
1041                target_message_id,
1042                navigation_token,
1043            } => crate::timeline_state::TimelinePageMutation::Locate {
1044                target_message_id: target_message_id.to_string(),
1045                navigation_token: navigation_token.to_string(),
1046                activate: locate_is_current,
1047            },
1048        };
1049        self.state
1050            .timeline_state
1051            .patch_page_from_render_ready(
1052                request,
1053                rows,
1054                page_mutation,
1055                state.request_id().map(str::to_string),
1056            )
1057            .map_err(|error| ImError::Parse(format!("timeline navigation projection: {error}")))?;
1058        let page_direction = match state.kind() {
1059            crate::timeline_navigation::TimelineNavigationKind::Older {
1060                anchor_post_id, ..
1061            } => Some(("older", anchor_post_id.as_str())),
1062            crate::timeline_navigation::TimelineNavigationKind::Newer {
1063                anchor_post_id, ..
1064            } => Some(("newer", anchor_post_id.as_str())),
1065            crate::timeline_navigation::TimelineNavigationKind::Locate { .. } => None,
1066        };
1067        if let Some((direction, anchor_post_id)) = page_direction {
1068            let page = state.page();
1069            let messages = render_rows
1070                .iter()
1071                .filter(|row| {
1072                    row.get("id")
1073                        .or_else(|| row.get("msgId"))
1074                        .or_else(|| row.get("temporaryId"))
1075                        .and_then(Value::as_str)
1076                        != Some(anchor_post_id)
1077                })
1078                .cloned()
1079                .collect();
1080            return Ok(crate::event::timeline::page(
1081                state.channel_id().as_str(),
1082                state.window_token(),
1083                direction,
1084                "ready",
1085                messages,
1086                page.has_older,
1087                page.has_newer,
1088                Some(anchor_post_id),
1089            )?
1090            .into_effect());
1091        }
1092        let crate::timeline_navigation::TimelineNavigationKind::Locate {
1093            target_message_id,
1094            navigation_token,
1095        } = state.kind()
1096        else {
1097            return Err(ImError::Parse(
1098                "timeline navigation kind changed after page dispatch".to_string(),
1099            ));
1100        };
1101        let page = state.page();
1102        if !locate_is_current {
1103            return Ok(crate::event::timeline::page(
1104                state.channel_id().as_str(),
1105                state.window_token(),
1106                "merge",
1107                "stale",
1108                render_rows,
1109                page.has_older,
1110                page.has_newer,
1111                Some(target_message_id),
1112            )?
1113            .into_effect());
1114        }
1115        Ok(crate::event::timeline::located(serde_json::json!({
1116            "channelId": state.channel_id().as_str(),
1117            "windowToken": state.window_token(),
1118            "state": "ready",
1119            "messages": render_rows,
1120            "hasOlder": page.has_older,
1121            "hasNewer": page.has_newer,
1122            "targetMessageId": target_message_id,
1123            "anchorPostId": target_message_id,
1124            "revealPostId": target_message_id,
1125            "navigationToken": navigation_token,
1126        }))?
1127        .into_effect())
1128    }
1129
1130    fn emit_timeline_failed(
1131        &mut self,
1132        request: &MessageQueryRequest,
1133        _now_ms: u64,
1134        _causation_id: Option<String>,
1135    ) -> Result<Effect, ImError> {
1136        Ok(crate::event::timeline::window(
1137            request.channel_id.as_str(),
1138            request.window_token.as_str(),
1139            "failed",
1140            Vec::new(),
1141            false,
1142            false,
1143            None,
1144        )?
1145        .into_effect())
1146    }
1147}