Skip to main content

helix_im/sync/
session.rs

1//! SyncSession 聚合 + EventEnvelope
2//!
3//! ## 源码印证(2026-06-08 实测)
4//!
5//! - /sync/notify(from_seq) 返回 4 种响应
6//! - 分桶顺序铁律:type1 upsert → type2 edit → type3 revoke → type6 read
7//! - ≥500 events 需续拉(needs_continuation)
8//! - TooLong:cursor = reset_to-1 + Emit(SyncTooLong) + getLatestPost 覆盖式重拉;不物理删 message
9
10use crate::state::{ChannelId, Seq};
11
12pub(crate) const POST_FIELD_ID: u64 = 1 << 0;
13pub(crate) const POST_FIELD_CHANNEL_ID: u64 = 1 << 1;
14pub(crate) const POST_FIELD_USER_ID: u64 = 1 << 2;
15pub(crate) const POST_FIELD_TYPE: u64 = 1 << 3;
16pub(crate) const POST_FIELD_MESSAGE: u64 = 1 << 4;
17pub(crate) const POST_FIELD_SIMPLE_MESSAGE: u64 = 1 << 5;
18pub(crate) const POST_FIELD_PROPS: u64 = 1 << 6;
19pub(crate) const POST_FIELD_USER_SNAPSHOT: u64 = 1 << 7;
20pub(crate) const POST_FIELD_CREATE_AT: u64 = 1 << 8;
21pub(crate) const POST_FIELD_UPDATE_AT: u64 = 1 << 9;
22pub(crate) const POST_FIELD_READ_BITS: u64 = 1 << 10;
23pub(crate) const POST_FIELD_SNAPSHOT_ID: u64 = 1 << 11;
24pub(crate) const POST_FIELD_VIEWERS: u64 = 1 << 12;
25pub(crate) const POST_FIELD_MENTIONS: u64 = 1 << 13;
26pub(crate) const POST_FIELD_EXPEDITE_MAP: u64 = 1 << 14;
27pub(crate) const POST_FIELD_QUICK_REPLY: u64 = 1 << 15;
28pub(crate) const POST_FIELD_TOPIC: u64 = 1 << 16;
29pub(crate) const POST_FIELD_REPLY_ID: u64 = 1 << 17;
30pub(crate) const POST_FIELD_REPLY_ROOT_ID: u64 = 1 << 18;
31pub(crate) const POST_FIELD_REPLY_FIRST_LEVEL_ID: u64 = 1 << 19;
32pub(crate) const POST_FIELD_REPLIED_MESSAGE: u64 = 1 << 20;
33pub(crate) const POST_FIELD_REPLY_MESSAGES: u64 = 1 << 21;
34pub(crate) const POST_FIELD_REPLY_COUNT: u64 = 1 << 22;
35
36/// 落库所需的 owned typed 视图(HX-C005:字段提取上移 parser,热路径零再解析)。
37///
38/// parser(`parse_inbound` / `parse_channel_events`,`Value` 已在手)一次解析填充此结构;
39/// `channel::event_to_upsert_op` 退化为零再解析的 Row 拼装——不再每事件
40/// `serde_json::from_slice(raw)` 全量重解析 + ~14 次 String 堆分配。
41///
42/// 字段映射真源 = cses-client `From<types::Post> for Message`(PK 三级回退、snake/camel 兼容
43/// 已在 parser 提取阶段处理)。`props` 保留服务端原始对象(业务扩展 / event_seq / read map 下游对账)。
44#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize)]
45pub struct PostFields {
46    /// `temporary_id` ?? `temporaryId`(PK 首选,空字符串=缺省,由 event_to_upsert_op 走三级回退)。
47    pub temporary_id: String,
48    pub id: String,
49    /// post 内的 channel_id(缺省回退到 envelope 权威 channel_id,在拼装时处理)。
50    pub channel_id: String,
51    pub user_id: String,
52    /// Wire `type` 字段在内部 typed view 中使用 `msg_type`,日志仍映射回协议名称。
53    #[serde(rename = "type")]
54    pub msg_type: String,
55    pub message: String,
56    /// 短预览文本(Go wire `simpleMessage` / `simple_message`),用于 Dialog lastMessage。
57    pub simple_message: String,
58    /// 服务端原始 props 对象的字符串形态(业务扩展透传)。
59    pub props: String,
60    /// 服务端原始 userSnapshot 对象的字符串形态,用于 senderUserId 优先级与渲染透传。
61    pub user_snapshot: String,
62    pub create_at: i64,
63    /// 服务端消息更新时间;缺省保持 0,禁止用本地墙钟伪造远端权威值。
64    pub update_at: i64,
65    /// 服务端权威已读位(C2 / type=6 真源 post.rs:1087-1089)。
66    /// 缺省空串=该 post 无已读信息;落库时**单调覆盖** message.read_bits 列(非累加)。
67    pub read_bits: String,
68    /// 创建时成员快照权威键;缺省空串表示服务端未提供,禁止本地合成。
69    pub snapshot_id: String,
70    /// 可见性受众(A3/CAP-9:sync 应用未读 +1 的 `visible_to_user` 谓词真源
71    /// message_service.rs:323-333——`viewers` 含 `"all"` 或当前 auth_id 即可见)。
72    /// 缺省空 Vec(无堆分配);NOTICE 类型恒可见不依赖此字段。
73    pub viewers: Vec<String>,
74    /// `mentions` 用户 id 列表,供 Rust 侧计算 Dialog mention patch。
75    pub mentions: Vec<String>,
76    /// 服务端原始 expediteMap / expedite_map 对象字符串,供 urgent 判定和存储透传。
77    pub expedite_map: String,
78    /// Go `post_update.quickReply` 规范化数组的 JSON 文本,独立落 message.quick_reply。
79    pub quick_reply: String,
80    /// 消息的话题映射对象(Go wire `topic`),原样 JSON 持久化并投影给 UI。
81    pub topic: String,
82    /// 被回复消息 id,以及服务端归一后的回复根/一级回复锚。
83    /// 这些字段由 Helix parser 原样持久化,UI 只消费投影,不计算回复层级。
84    pub reply_id: String,
85    pub reply_root_id: String,
86    pub reply_first_level_id: String,
87    /// 被引用消息与回复摘要由服务端直接给出,Helix 只做一次解析/投影。
88    /// JSON 字段保留字符串形态落库,避免 UI 再探测 snake/camel 或重组树。
89    pub replied_message: String,
90    pub reply_messages: String,
91    pub reply_count: i64,
92    /// JSON key presence mask; missing fields must not overwrite an existing readback row.
93    #[serde(skip)]
94    pub(crate) present_fields: u64,
95}
96
97impl PostFields {
98    /// Reports whether the source payload explicitly contained the given field.
99    pub(crate) fn has_field(&self, field: u64) -> bool {
100        self.present_fields & field != 0
101    }
102}
103
104/// 解析后的强类型入站事件(14 类 WS type 的产物)
105#[derive(Debug, Clone, PartialEq, Eq)]
106pub struct EventEnvelope {
107    /// per-channel ID(channel_id 的内部引用,两个字段保持同步)
108    pub id: ChannelId,
109    pub channel_id: ChannelId,
110    pub seq: Seq,
111    pub kind: EventKind,
112    /// 落库所需 owned typed 视图(parser 一次解析填充;HX-C005 热路径零再解析)。
113    pub fields: PostFields,
114    /// 事件 wire 的 `msgId`(C2 phantom 判定真源 post.rs:1064-1069)。
115    ///
116    /// sync `events` 路径:事件本身只带 `eventSeq/eventType/msgId/actorId`,消息内容在
117    /// 分离的 `messages` map(key=msgId)。`msg_id ∈ messages` → 落内容行;`∉` → phantom
118    /// (cursor 推进但不落 message 行,HX-C008 §1.6)。`None` = 无 msgId(read 事件可空 / legacy)。
119    pub msg_id: Option<String>,
120    /// 服务端事件行 identity。撤回投影使用这些字段生成 viewer-ready 系统事件;它们与原消息
121    /// 作者完全独立,缺失时保持空值/0,绝不从 `fields.user_id` 猜 actor。
122    pub event_id: String,
123    pub actor_id: String,
124    pub occurred_at: i64,
125    /// ChannelEvent.payload 原样 JSON;当前撤回合同为空也保留,防后续系统事件扩展被 parser 丢弃。
126    pub event_payload: String,
127    /// 同一业务效果在 stream 与 member projection 间的关联键;不参与排序或幂等。
128    pub effect_id: String,
129    /// ACL marker 只占据 stream 位置,不得物化消息或泄露 actor/payload。
130    pub redacted: bool,
131    /// ⑤ 未读 +1 决策(在边界 handler 用**原始帧 + auth + 完整 echo 判定**算定,gate 在
132    /// apply/flush **两路**统一发出)。`Some` = 该 PostUpsert 落库时一并 `GuardedBump` unread
133    /// (已含 sender 豁免 + 可见性 + echo 跳过);`None` = 不 bump(echo / 非新消息 / 其它 kind /
134    /// sync 路径——sync 未读由 CAP-9 apply 自管,故默认 None 不重复 +1)。
135    ///
136    /// **乱序进 gate buffer 的事件同样携带本决策**,flush 时由 gate 发出——修复「乱序 post 经
137    /// buffer flush 不补未读」偏差(gate 成为未读 bump 的**单一发出点**,立即 apply 与 flush 同口径)。
138    pub unread_bump: Option<crate::channel_write::PostChannelUpdate>,
139    /// 当前投影视角用户;仅用于生成 `isSelf` 与消息动作 capability,不参与存储。
140    pub viewer_user_id: String,
141    /// Trusted action causation copied from the WS envelope, never from post content.
142    pub causation_id: Option<String>,
143}
144
145impl EventEnvelope {
146    /// 构造 EventEnvelope(统一入口,确保 id 与 channel_id 始终一致)。
147    ///
148    /// parser 经此入口传入已提取的 `fields`(owned typed 视图),落库路径直接读取,
149    /// 不再持有 / 重解析原始 JSON 字节(HX-C005)。
150    ///
151    /// `msg_id` 缺省从 fields 派生(`id` ?? `temporary_id`,皆空=None)——既有调用方
152    /// (WS 路径 / driver-host 测试)无须改签名;sync 路径再用 `with_msg_id` 覆盖为 wire 权威值。
153    pub fn new(channel_id: ChannelId, seq: Seq, kind: EventKind, fields: PostFields) -> Self {
154        let msg_id = if !fields.id.is_empty() {
155            Some(fields.id.clone())
156        } else if !fields.temporary_id.is_empty() {
157            Some(fields.temporary_id.clone())
158        } else {
159            None
160        };
161        Self {
162            id: channel_id,
163            channel_id,
164            seq,
165            kind,
166            fields,
167            msg_id,
168            event_id: String::new(),
169            actor_id: String::new(),
170            occurred_at: 0,
171            event_payload: String::new(),
172            effect_id: String::new(),
173            redacted: false,
174            unread_bump: None,
175            viewer_user_id: String::new(),
176            causation_id: None,
177        }
178    }
179
180    /// 覆盖 wire 权威 `msgId`(sync `events` / WS 路径,C2 phantom 判定)。
181    ///
182    /// 仅当传入 `Some(非空)` 时覆盖——`None` / `Some("")` 保留 `new` 派生的 fallback
183    /// (`id` ?? `temporary_id`),不抹掉。边界零信任,helix-im 不变量 4。
184    pub fn with_msg_id(mut self, msg_id: Option<String>) -> Self {
185        if let Some(id) = msg_id.filter(|s| !s.is_empty()) {
186            self.msg_id = Some(id);
187        }
188        self
189    }
190
191    /// 附加 wire 权威事件 identity。空 actor 保持 unavailable,调用方不得回退为消息作者。
192    pub fn with_event_identity(
193        mut self,
194        event_id: Option<String>,
195        actor_id: Option<String>,
196        occurred_at: i64,
197        event_payload: String,
198    ) -> Self {
199        self.event_id = event_id.filter(|id| !id.is_empty()).unwrap_or_default();
200        self.actor_id = actor_id.filter(|id| !id.is_empty()).unwrap_or_default();
201        self.occurred_at = occurred_at.max(0);
202        self.event_payload = event_payload;
203        self
204    }
205
206    pub fn with_effect(mut self, effect_id: Option<String>, redacted: bool) -> Self {
207        self.effect_id = effect_id.filter(|id| !id.is_empty()).unwrap_or_default();
208        self.redacted = redacted;
209        self
210    }
211
212    /// 附加 ⑤ 未读 bump 决策(仅 `post` handler 对新消息算定后调用)。
213    /// gate 在 apply/flush 两路读取并发出——确保乱序经 buffer 的消息 flush 时也补未读。
214    pub fn with_unread_bump(
215        mut self,
216        bump: Option<crate::channel_write::PostChannelUpdate>,
217    ) -> Self {
218        self.unread_bump = bump;
219        self
220    }
221
222    pub fn with_viewer_user_id(mut self, viewer_user_id: &str) -> Self {
223        self.viewer_user_id = viewer_user_id.to_string();
224        self
225    }
226
227    pub fn with_causation_id(mut self, causation_id: Option<String>) -> Self {
228        self.causation_id = causation_id.filter(|value| !value.is_empty());
229        self
230    }
231}
232
233/// `increment_channel` 帧 data(`IncrementChannel.ToMap()`)的解析结果(B-rest 契约)。
234///
235/// 真 Go data 含 id/teamId/displayName/lastEventSeq/unreadCount/needSync/mentionList/
236/// urgentPostList(设计文档 §1.3);helix 增量同步只需 `channel_id` + `last_event_seq`
237/// (作 cursor 种子)+ `need_sync`(false=已追平省 sync);`raw` 供 emit im:channel:increment 透传。
238#[derive(Debug, Clone, PartialEq, Eq)]
239pub struct IncrementChannel {
240    pub channel_id: ChannelId,
241    pub last_event_seq: Seq,
242    pub need_sync: bool,
243    pub raw: bytes::Bytes,
244}
245
246/// IM 事件类型(对应 WS 协议 type 字段,源码 14 类分桶)
247#[derive(Debug, Clone, PartialEq, Eq)]
248pub enum EventKind {
249    /// type1:新消息 upsert
250    PostUpsert,
251    /// type2:消息编辑
252    PostEdit,
253    /// type3:消息撤回
254    PostRevoke,
255    /// type6:已读
256    PostRead,
257    /// type7:频道终态 tombstone。
258    ///
259    /// 该事件只允许经 `/channel/sync/notify` 的严格 wire 进入;它不是 post,不能落到
260    /// message 表或沿用普通 cursor 提交链。`sync::flow` 会把 tombstone marker 与 cursor
261    /// 放进同一个 `PersistAtomic`,收到成功回执后才关闭本地 channel 并发布终态事件。
262    ChannelTerminalClosed,
263    /// 其他类型(暂不处理)
264    Other(u8),
265}
266
267impl EventKind {
268    /// 返回对应的 WS 协议 type 数字(用于落库和分桶排序)
269    pub fn type_num(&self) -> u8 {
270        match self {
271            EventKind::PostUpsert => 1,
272            EventKind::PostEdit => 2,
273            EventKind::PostRevoke => 3,
274            EventKind::PostRead => 6,
275            EventKind::ChannelTerminalClosed => 7,
276            EventKind::Other(n) => *n,
277        }
278    }
279}
280
281/// sync 响应的 4 种形态
282#[derive(Debug, Clone, PartialEq, Eq, Default)]
283pub struct SyncPersona {
284    pub membership_state: String,
285    pub epoch_start_seq: Option<Seq>,
286    pub epoch_end_seq: Option<Seq>,
287    pub member_projection: Option<serde_json::Value>,
288}
289
290#[derive(Debug)]
291pub enum SyncResponse {
292    /// 无新事件(cursor 已是最新)
293    NoChange { next_seq: Seq, persona: SyncPersona },
294    /// 有事件(列表,≥500 时 needs_continuation=true 需续拉)
295    Events {
296        events: Vec<EventEnvelope>,
297        /// C2:服务端按可见性过滤的消息内容快照(key=msgId,真源 post.rs:1047 `messages` 参数)。
298        ///
299        /// 落库时:type1/2 的 `event.msg_id ∈ messages` → 用此 map 内容落 message 行;
300        /// `∉` → phantom(不落行,cursor 照推)。type6 → 读 `messages[msg_id].read_bits`
301        /// 单调覆盖。空 map = 全 phantom(仅推 cursor)。owned `PostFields`(HX-C005)。
302        messages: std::collections::HashMap<String, PostFields>,
303        /// 服务端权威水位(Go `SyncEntry.nextSeq`):cursor 应推进到此值,**不要**用
304        /// `max(event.seq)` 推导。当前契约下二者恒等(Go `next = events[末].EventSeq`),
305        /// 但显式锚 nextSeq 防契约漂移(尾部 phantom seq 不进 events 数组时不落后)。
306        next_seq: Seq,
307        needs_continuation: bool,
308        persona: SyncPersona,
309    },
310    /// 服务端返回快照(覆盖式 upsert,不先物理删除既有 message)
311    Snapshot(ChannelSnapshot),
312    /// cursor gap 过大,保留旧 message 并覆盖式重拉
313    TooLong { reset_to: Seq },
314}
315
316/// 频道快照(用于 Snapshot 响应的全量重建)
317#[derive(Debug)]
318pub struct ChannelSnapshot {
319    pub channel_id: ChannelId,
320    pub reset_to: Seq,
321    pub messages: Vec<EventEnvelope>,
322}
323
324/// SyncSession 状态(一次 /sync/notify 请求的生命周期)
325pub struct SyncSession {
326    pub channel_id: ChannelId,
327    pub from_seq: Seq,
328    pub corr: helix_core::Correlation,
329}
330
331/// One reconnect/relogin authority window.  It deliberately contains only
332/// correlation-free state: the actual network and storage work is still
333/// represented by Effects and their matching PortReply contexts.
334#[derive(Debug, Clone, PartialEq, Eq)]
335pub struct RecoverySession {
336    pub phase: RecoveryPhase,
337    pub session_epoch: u64,
338    pub actor_id: String,
339    pub pending_commits: std::collections::BTreeMap<ChannelId, Seq>,
340    completion_published: bool,
341}
342
343impl Default for RecoverySession {
344    fn default() -> Self {
345        Self {
346            phase: RecoveryPhase::Idle,
347            session_epoch: 0,
348            actor_id: String::new(),
349            pending_commits: std::collections::BTreeMap::new(),
350            completion_published: false,
351        }
352    }
353}
354
355impl RecoverySession {
356    /// Start a new trust boundary.  Epoch zero is intentionally never emitted
357    /// on the V2 wire, so the first usable session is one.
358    pub fn begin(&mut self, actor_id: &str) {
359        self.session_epoch = self.session_epoch.saturating_add(1).max(1);
360        self.actor_id.clear();
361        self.actor_id.push_str(actor_id);
362        self.pending_commits.clear();
363        self.completion_published = false;
364        self.phase = RecoveryPhase::Comparing;
365    }
366
367    pub fn invalidate(&mut self) {
368        self.pending_commits.clear();
369        self.completion_published = false;
370        self.phase = RecoveryPhase::Idle;
371        self.actor_id.clear();
372    }
373
374    pub fn compare(
375        &mut self,
376        local: CommittedRecoveryHead,
377        authority: AuthorityHead,
378    ) -> RecoveryComparison {
379        let comparison = compare_committed_recovery(local, authority);
380        self.phase = match comparison {
381            RecoveryComparison::Equal => RecoveryPhase::Recovered,
382            RecoveryComparison::Pull { .. } => RecoveryPhase::Pulling,
383            RecoveryComparison::AuthorityReloadRequired => RecoveryPhase::Blocked,
384        };
385        comparison
386    }
387
388    pub fn await_commit(&mut self, channel_id: ChannelId, committed_to: Seq) {
389        self.pending_commits.insert(channel_id, committed_to);
390        self.phase = RecoveryPhase::AwaitingCommit;
391    }
392
393    /// A storage acknowledgement is meaningful only when it matches exactly
394    /// the batch that this session registered.  Late A-session acknowledgements
395    /// are therefore a no-op rather than a renderer-visible completion.
396    pub fn commit_ok(&mut self, channel_id: ChannelId, committed_to: Seq) -> bool {
397        if self.pending_commits.remove(&channel_id) != Some(committed_to) {
398            return false;
399        }
400        self.phase = if self.pending_commits.is_empty() {
401            RecoveryPhase::Recovered
402        } else {
403            RecoveryPhase::AwaitingCommit
404        };
405        true
406    }
407
408    pub fn commit_failed(&mut self, channel_id: ChannelId) {
409        self.pending_commits.remove(&channel_id);
410        self.phase = RecoveryPhase::Failed;
411    }
412
413    pub fn is_active_for(&self, actor_id: &str) -> bool {
414        self.session_epoch != 0 && self.actor_id == actor_id && !self.actor_id.is_empty()
415    }
416
417    pub fn is_collecting_for(&self, actor_id: &str) -> bool {
418        self.is_active_for(actor_id) && !self.completion_published
419    }
420
421    pub fn is_collecting(&self) -> bool {
422        self.session_epoch != 0 && !self.actor_id.is_empty() && !self.completion_published
423    }
424
425    pub fn has_pending_commits(&self) -> bool {
426        !self.pending_commits.is_empty()
427    }
428
429    pub fn mark_completion_published(&mut self) {
430        self.completion_published = true;
431    }
432}
433
434/// Recovery is deliberately a pure comparison model. The caller obtains both
435/// inputs through ports, persists any chosen batch, and only then advances the
436/// in-memory head; this type cannot manufacture a UI completion on its own.
437#[derive(Debug, Clone, Copy, PartialEq, Eq)]
438pub enum RecoveryPhase {
439    Idle,
440    Comparing,
441    Pulling,
442    AwaitingCommit,
443    Recovered,
444    Failed,
445    Blocked,
446}
447
448#[derive(Debug, Clone, Copy, PartialEq, Eq)]
449pub struct AuthorityHead {
450    pub event_seq: Seq,
451}
452
453#[derive(Debug, Clone, Copy, PartialEq, Eq)]
454pub struct CommittedRecoveryHead {
455    pub cursor: Seq,
456    pub ledger_to_seq: Seq,
457    pub coverage_to_seq: Seq,
458}
459
460impl CommittedRecoveryHead {
461    pub const fn is_coherent(self) -> bool {
462        self.cursor.0 == self.ledger_to_seq.0 && self.cursor.0 == self.coverage_to_seq.0
463    }
464}
465
466#[derive(Debug, Clone, Copy, PartialEq, Eq)]
467pub enum RecoveryComparison {
468    Equal,
469    Pull {
470        from_exclusive: Seq,
471        to_inclusive: Seq,
472    },
473    AuthorityReloadRequired,
474}
475
476/// A normalized sync batch whose sequence evidence has already been checked.
477/// The parser stays responsible for wire decoding; this type owns the
478/// cross-entry invariants shared by reconnect and incremental recovery.
479#[derive(Debug, Clone)]
480pub struct SyncBatchFacts {
481    pub channel_id: ChannelId,
482    pub from_exclusive: Seq,
483    pub authority_head: AuthorityHead,
484    pub events: Vec<EventEnvelope>,
485}
486
487impl SyncBatchFacts {
488    pub fn from_events(
489        channel_id: ChannelId,
490        from_exclusive: Seq,
491        authority_head: Seq,
492        events: Vec<EventEnvelope>,
493    ) -> Result<Self, &'static str> {
494        if events.is_empty() {
495            return Err("sync batch facts require at least one event");
496        }
497        let mut previous = from_exclusive;
498        for event in &events {
499            if event.channel_id != channel_id || event.id != channel_id {
500                return Err("sync batch event channel differs from request channel");
501            }
502            // sync/notify is viewer-filtered: invisible channel events create
503            // legitimate gaps. The returned facts must still be strictly
504            // increasing and newer than the committed cursor.
505            if event.seq <= previous {
506                return Err("sync batch event sequence is not strictly increasing");
507            }
508            previous = event.seq;
509        }
510        let last = events.last().map(|event| event.seq).unwrap_or(Seq(0));
511        if authority_head < last {
512            return Err("sync batch authority head precedes final event");
513        }
514        Ok(Self {
515            channel_id,
516            from_exclusive,
517            authority_head: AuthorityHead {
518                event_seq: authority_head,
519            },
520            events,
521        })
522    }
523}
524
525/// Compare only committed local evidence with an authority head. An incoherent
526/// local ledger/coverage or local-ahead state is a fail-closed authority reload,
527/// never a client-side attempt to fill a missing sequence.
528pub const fn compare_committed_recovery(
529    local: CommittedRecoveryHead,
530    authority: AuthorityHead,
531) -> RecoveryComparison {
532    if !local.is_coherent() || local.cursor.0 > authority.event_seq.0 {
533        return RecoveryComparison::AuthorityReloadRequired;
534    }
535    if local.cursor.0 == authority.event_seq.0 {
536        RecoveryComparison::Equal
537    } else {
538        RecoveryComparison::Pull {
539            from_exclusive: local.cursor,
540            to_inclusive: authority.event_seq,
541        }
542    }
543}