Skip to main content

mail4agent_server/
sync_token.rs

1//! The Matrix sync-token format `GET /sync` (P10) emits and `GET
2//! /keys/changes` (P9) also consumes — defined here, once, so every future
3//! caller that needs a sync token parses/formats through this module rather
4//! than growing a second implementation.
5//!
6//! # Format: `s{stream_id}_{typing_gen}`
7//!
8//! `stream_id` is `messenger.db`'s own global `stream_counter` value — the
9//! same axis [`crate::store::next_stream_id`]/
10//! [`crate::store::max_stream_id`] and
11//! `routes::matrix::messaging`'s own `"t{stream_id}"` `/messages` pagination
12//! token already use. `typing_gen` is
13//! [`crate::typing::TypingRegistry`]'s GLOBAL typing
14//! generation (see that module's own doc for why it is global, not
15//! per-room) as of the moment this token was minted — `routes::matrix::sync`
16//! compares it against a room's own current typing serial to tell "this
17//! room's typing set changed since this token" apart from "nothing changed
18//! at all" without a busy long-poll loop. The leading `s` is Matrix's own
19//! conventional sync-token sigil (distinguishing it from `/messages`'
20//! `t`-prefixed pagination tokens at a glance in a log line), not a format
21//! version — there is exactly one sync token shape, and it is not expected
22//! to grow a second one.
23//!
24//! # Backward compatibility with the pre-typing-gen form
25//!
26//! `GET /keys/changes` (P9) predates this format's typing component and
27//! only ever emitted/consumed a bare `s{stream_id}` (or a plain integer).
28//! [`parse`] still accepts that shorter form, defaulting `typing_gen` to
29//! `0` — never greater than any real room serial a fresh
30//! [`crate::typing::TypingRegistry`] could have assigned by
31//! the time such a token is used, so this default never wrongly suppresses
32//! a real typing change (see [`crate::typing::TypingRegistry::
33//! current_typing_gen`]'s own doc).
34
35use crate::error::MatrixError;
36
37/// A parsed sync token: a position in `messenger.db`'s global stream order,
38/// plus the typing generation observed at that position — see the module
39/// doc.
40#[derive(Debug, Clone, Copy, PartialEq, Eq)]
41pub struct SyncToken {
42    pub stream_id: i64,
43    pub typing_gen: u64,
44}
45
46/// Format a sync token — `routes::matrix::sync`'s own `next_batch` is this
47/// format's one emitter.
48pub fn format(stream_id: i64, typing_gen: u64) -> String {
49    format!("s{stream_id}_{typing_gen}")
50}
51
52/// Parse a sync token back into its `(stream_id, typing_gen)` pair. Accepts
53/// the full `s{stream_id}_{typing_gen}` form [`format`] emits, the bare
54/// `s{stream_id}` form (typing_gen defaults to `0` — see the module doc),
55/// and a plain integer (a client that echoes a token verbatim never needs
56/// this leniency, but it costs nothing and matches
57/// `routes::matrix::messaging`'s own `parse_stream_token` leniency for the
58/// same reason).
59pub fn parse(raw: &str) -> Result<SyncToken, MatrixError> {
60    let digits = raw.strip_prefix('s').unwrap_or(raw);
61    let (stream_part, typing_part) = match digits.split_once('_') {
62        Some((stream_part, typing_part)) => (stream_part, Some(typing_part)),
63        None => (digits, None),
64    };
65    let stream_id = stream_part.parse::<i64>().map_err(|_| MatrixError::invalid_param("malformed sync token"))?;
66    let typing_gen = match typing_part {
67        Some(raw_gen) => raw_gen.parse::<u64>().map_err(|_| MatrixError::invalid_param("malformed sync token"))?,
68        None => 0,
69    };
70    Ok(SyncToken { stream_id, typing_gen })
71}
72
73#[cfg(test)]
74mod tests {
75    use super::*;
76
77    #[test]
78    fn sync_token_round_trips_the_full_form() {
79        for (stream_id, typing_gen) in [(0_i64, 0_u64), (1, 1), (42, 7), (1_000_000, 12_345)] {
80            let token = format(stream_id, typing_gen);
81            assert_eq!(parse(&token).expect("parse"), SyncToken { stream_id, typing_gen });
82        }
83    }
84
85    #[test]
86    fn sync_token_accepts_the_legacy_bare_stream_id_form_with_typing_gen_zero() {
87        assert_eq!(parse("s42").expect("parse"), SyncToken { stream_id: 42, typing_gen: 0 });
88    }
89
90    #[test]
91    fn sync_token_accepts_a_bare_integer_too() {
92        assert_eq!(parse("42").expect("parse"), SyncToken { stream_id: 42, typing_gen: 0 });
93    }
94
95    #[test]
96    fn sync_token_rejects_garbage() {
97        let err = parse("not-a-token").unwrap_err();
98        assert_eq!(err.errcode, "M_INVALID_PARAM");
99    }
100
101    #[test]
102    fn sync_token_rejects_a_malformed_typing_gen() {
103        let err = parse("s42_not-a-number").unwrap_err();
104        assert_eq!(err.errcode, "M_INVALID_PARAM");
105    }
106}