Skip to main content

wechat_profile/
thread_read.rs

1//! Whole-conversation local read projection at a caller's frozen timestamp.
2use crate::{
3    projection::{check_known_table, identity, ownership},
4    writer::{exact_columns, find_database, open_write},
5    *,
6};
7use rusqlite::{params, OptionalExtension};
8use serde::Serialize;
9
10#[derive(Clone, Debug, Serialize)]
11#[serde(rename_all = "camelCase")]
12pub struct ThreadReadProjection {
13    pub session_present: bool,
14    pub removed_unread_rows: usize,
15    pub unread_count_before: i64,
16    pub unread_count_after: i64,
17    pub last_clear_unread_timestamp: i64,
18    pub whole_thread: bool,
19}
20
21#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
22#[serde(rename_all = "snake_case")]
23pub enum ThreadExitReadOutcome {
24    Applied,
25    MissingSession,
26    HiddenSession,
27    ExcludedPeer,
28}
29
30#[derive(Clone, Debug, Serialize)]
31#[serde(rename_all = "camelCase")]
32pub struct ThreadExitReadProjection {
33    pub outcome: ThreadExitReadOutcome,
34    pub projection: Option<ThreadReadProjection>,
35}
36
37// The ordinary session-exit path excludes these built-in service holders and
38// endpoints. Notification aggregate peers have a separate handler branch.
39const EXIT_READ_EXCLUDED_PEERS: &[&str] = &[
40    "brandsessionholder",
41    "brandservicesessionholder",
42    "qmessage",
43    "medianote",
44    "gh_22b87fa7cb3c",
45    "qqsync",
46    "lbsapp",
47    "shakeapp",
48    "floatbottle",
49    "masssendapp",
50    "gh_43f2581f6fd6",
51    "appbrandcustomerservicemsg",
52    "service_officialaccounts",
53    "notifymessage",
54    "notification_messages",
55];
56
57/// Clear this session through a frozen server timestamp, preserving later rows.
58/// Unlike an exact-ID projection, this reconciles an older counter/list mismatch
59/// when the entire known session is before the boundary. It performs no RPC.
60pub fn mark_read_thread(
61    location: &ProfileLocation,
62    keys: &KeyFile,
63    thread: &str,
64    through_time: i64,
65    options: &WriteOptions,
66) -> Result<ThreadReadProjection> {
67    project_thread_read(location, keys, thread, through_time, options, false)?
68        .projection
69        .ok_or(ProfileError::InvalidMetadata)
70}
71
72/// Project only the unread-clear component of a recognized session-exit
73/// control. Missing, hidden and excluded sessions remain unchanged. The caller
74/// must retain the full control as incomplete and journal every evaluated result.
75pub fn mark_read_thread_exit(
76    location: &ProfileLocation,
77    keys: &KeyFile,
78    thread: &str,
79    through_time: i64,
80    options: &WriteOptions,
81) -> Result<ThreadExitReadProjection> {
82    project_thread_read(location, keys, thread, through_time, options, true)
83}
84
85fn project_thread_read(
86    location: &ProfileLocation,
87    keys: &KeyFile,
88    thread: &str,
89    through_time: i64,
90    options: &WriteOptions,
91    visible_ordinary_only: bool,
92) -> Result<ThreadExitReadProjection> {
93    ownership(location, keys, options)?;
94    identity(thread)?;
95    if !u32::try_from(through_time).is_ok_and(|time| time != 0) {
96        return Err(ProfileError::InvalidQuery);
97    }
98    if visible_ordinary_only && EXIT_READ_EXCLUDED_PEERS.contains(&thread) {
99        return Ok(ThreadExitReadProjection {
100            outcome: ThreadExitReadOutcome::ExcludedPeer,
101            projection: None,
102        });
103    }
104    let relative = find_database(location, "session.db")?;
105    let mut database = open_write(location, &relative, keys, options.allow_plaintext)?;
106    check_known_table(&database, "SessionTable")?;
107    check_known_table(&database, "SessionUnreadListTable_1")?;
108    exact_columns(&database, &relative, "Name2Id", &["user_name"])?;
109    let tx = database.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
110    let session = tx
111        .query_row(
112            "SELECT COALESCE(unread_count,0),COALESCE(last_timestamp,0),COALESCE(last_clear_unread_timestamp,0),COALESCE(is_hidden,0) FROM SessionTable WHERE username=?1",
113            [thread],
114            |row| Ok((row.get::<_, i64>(0)?, row.get::<_, i64>(1)?, row.get::<_, i64>(2)?, row.get::<_, i64>(3)?)),
115        )
116        .optional()?;
117    let Some((before, last_time, old_clear, is_hidden)) = session else {
118        // A status control can refer to a session absent from local history.
119        // Do not manufacture a conversation merely to record an empty read.
120        return Ok(ThreadExitReadProjection {
121            outcome: ThreadExitReadOutcome::MissingSession,
122            projection: Some(ThreadReadProjection {
123                session_present: false,
124                removed_unread_rows: 0,
125                unread_count_before: 0,
126                unread_count_after: 0,
127                last_clear_unread_timestamp: 0,
128                whole_thread: true,
129            }),
130        });
131    };
132    if visible_ordinary_only && is_hidden != 0 {
133        return Ok(ThreadExitReadProjection {
134            outcome: ThreadExitReadOutcome::HiddenSession,
135            projection: None,
136        });
137    }
138    if before < 0 || old_clear < 0 {
139        return Err(ProfileError::WritesUnsupported);
140    }
141    let name_id = tx
142        .query_row(
143            "SELECT rowid FROM Name2Id WHERE user_name=?1",
144            [thread],
145            |row| row.get::<_, i64>(0),
146        )
147        .optional()?;
148    let (removed, remaining, first) = if let Some(name_id) = name_id {
149        let removed = tx.execute(
150            "DELETE FROM SessionUnreadListTable_1 WHERE username_id=?1 AND create_time<=?2",
151            params![name_id, through_time],
152        )?;
153        let remaining: i64 = tx.query_row(
154            "SELECT COUNT(*) FROM SessionUnreadListTable_1 WHERE username_id=?1",
155            [name_id],
156            |row| row.get(0),
157        )?;
158        let first = tx.query_row(
159            "SELECT server_id FROM SessionUnreadListTable_1 WHERE username_id=?1 ORDER BY create_time,local_id,server_id LIMIT 1",
160            [name_id],
161            |row| row.get::<_, i64>(0),
162        ).optional()?.unwrap_or(0);
163        (removed, remaining, first)
164    } else {
165        (0, 0, 0)
166    };
167    // Unknown counter contributions can belong to newer history. Preserve them
168    // when the session summary is newer than this read boundary.
169    let after = if last_time > through_time {
170        before.saturating_sub(removed as i64).max(remaining)
171    } else {
172        remaining
173    };
174    let clear = old_clear.max(through_time);
175    tx.execute(
176        "UPDATE SessionTable SET unread_count=?1,unread_first_msg_srv_id=?2,last_clear_unread_timestamp=?3,unread_first_pat_msg_local_id=CASE WHEN ?1=0 THEN 0 ELSE unread_first_pat_msg_local_id END,unread_first_pat_msg_sort_seq=CASE WHEN ?1=0 THEN 0 ELSE unread_first_pat_msg_sort_seq END WHERE username=?4",
177        params![after, first, clear, thread],
178    )?;
179    tx.commit()?;
180    Ok(ThreadExitReadProjection {
181        outcome: ThreadExitReadOutcome::Applied,
182        projection: Some(ThreadReadProjection {
183            session_present: true,
184            removed_unread_rows: removed,
185            unread_count_before: before,
186            unread_count_after: after,
187            last_clear_unread_timestamp: clear,
188            whole_thread: true,
189        }),
190    })
191}
192
193#[cfg(test)]
194mod tests {
195    use super::*;
196    use rusqlite::Connection;
197    use std::{collections::BTreeMap, fs};
198
199    fn fixture(reordered: bool) -> (tempfile::TempDir, ProfileLocation, KeyFile, WriteOptions) {
200        let directory = tempfile::tempdir().unwrap();
201        let path = directory
202            .path()
203            .join("account/db_storage/session/session.db");
204        fs::create_dir_all(path.parent().unwrap()).unwrap();
205        let sql = include_str!("layout/session.db.sql");
206        let sql = if reordered {
207            sql.replace(
208                "server_id INTEGER, local_id INTEGER, create_time INTEGER",
209                "server_id INTEGER, create_time INTEGER, local_id INTEGER",
210            )
211        } else {
212            sql.into()
213        };
214        let database = Connection::open(path).unwrap();
215        database.execute_batch(&sql).unwrap();
216        database.execute_batch("INSERT INTO Name2Id(rowid,user_name)VALUES(1,'peer'),(2,'other'); INSERT INTO SessionTable(username,unread_count,unread_first_msg_srv_id,unread_first_pat_msg_local_id,unread_first_pat_msg_sort_seq,last_timestamp,last_clear_unread_timestamp,draft,status)VALUES('peer',9,90,12,13,100,20,'keep draft',7),('other',1,500,0,0,100,0,'other',3); INSERT INTO SessionUnreadListTable_1(username_id,server_id,local_id,create_time)VALUES(1,90,1,90),(1,80,2,100),(2,500,1,100);").unwrap();
217        drop(database);
218        let location = ProfileLocation::new(directory.path().join("account")).unwrap();
219        let keys = KeyFile {
220            account_binding: "account".into(),
221            databases: BTreeMap::new(),
222            image_dat: None,
223        };
224        let options = WriteOptions {
225            exclusive_ownership_confirmed: true,
226            allow_plaintext: true,
227            durable_intent_id: "synthetic-thread-read".into(),
228        };
229        (directory, location, keys, options)
230    }
231
232    #[test]
233    fn whole_thread_clears_legacy_counter_and_advances_watermark_in_both_column_orders() {
234        for reordered in [false, true] {
235            let (_directory, location, keys, options) = fixture(reordered);
236            let result = mark_read_thread(&location, &keys, "peer", 100, &options).unwrap();
237            assert_eq!(
238                (
239                    result.removed_unread_rows,
240                    result.unread_count_before,
241                    result.unread_count_after
242                ),
243                (2, 9, 0)
244            );
245            assert_eq!(result.last_clear_unread_timestamp, 100);
246            let database = Connection::open(location.storage.join("session/session.db")).unwrap();
247            let row = database.query_row("SELECT unread_count,unread_first_msg_srv_id,last_clear_unread_timestamp,unread_first_pat_msg_local_id,unread_first_pat_msg_sort_seq,draft,status FROM SessionTable WHERE username='peer'", [], |row| Ok((row.get::<_,i64>(0)?,row.get::<_,i64>(1)?,row.get::<_,i64>(2)?,row.get::<_,i64>(3)?,row.get::<_,i64>(4)?,row.get::<_,String>(5)?,row.get::<_,i64>(6)?))).unwrap();
248            assert_eq!(row, (0, 0, 100, 0, 0, "keep draft".into(), 7));
249            assert_eq!(
250                database
251                    .query_row(
252                        "SELECT unread_count FROM SessionTable WHERE username='other'",
253                        [],
254                        |row| row.get::<_, i64>(0)
255                    )
256                    .unwrap(),
257                1
258            );
259            assert_eq!(
260                database
261                    .query_row(
262                        "SELECT COUNT(*) FROM SessionUnreadListTable_1 WHERE username_id=2",
263                        [],
264                        |row| row.get::<_, i64>(0)
265                    )
266                    .unwrap(),
267                1
268            );
269            drop(database);
270            let again = mark_read_thread(&location, &keys, "peer", 90, &options).unwrap();
271            assert_eq!(
272                (
273                    again.removed_unread_rows,
274                    again.unread_count_after,
275                    again.last_clear_unread_timestamp
276                ),
277                (0, 0, 100)
278            );
279        }
280    }
281
282    #[test]
283    fn later_arrivals_and_unknown_newer_counter_contributions_survive() {
284        let (_directory, location, keys, options) = fixture(false);
285        let database = Connection::open(location.storage.join("session/session.db")).unwrap();
286        database.execute_batch("UPDATE SessionTable SET last_timestamp=110 WHERE username='peer'; INSERT INTO SessionUnreadListTable_1(username_id,server_id,local_id,create_time)VALUES(1,3,3,110);").unwrap();
287        drop(database);
288        let result = mark_read_thread(&location, &keys, "peer", 100, &options).unwrap();
289        assert_eq!(
290            (result.removed_unread_rows, result.unread_count_after),
291            (2, 7)
292        );
293        let database = Connection::open(location.storage.join("session/session.db")).unwrap();
294        assert_eq!(
295            database
296                .query_row(
297                    "SELECT unread_first_msg_srv_id FROM SessionTable WHERE username='peer'",
298                    [],
299                    |row| row.get::<_, i64>(0)
300                )
301                .unwrap(),
302            3
303        );
304        drop(database);
305        let result = mark_read_thread(&location, &keys, "peer", 110, &options).unwrap();
306        assert_eq!(result.unread_count_after, 0);
307        assert_eq!(result.last_clear_unread_timestamp, 110);
308    }
309
310    #[test]
311    fn unknown_session_is_empty_and_unsafe_writes_are_rejected() {
312        let (_directory, location, keys, mut options) = fixture(false);
313        assert!(
314            !mark_read_thread(&location, &keys, "absent", 100, &options)
315                .unwrap()
316                .session_present
317        );
318        assert!(mark_read_thread(&location, &keys, "peer", 0, &options).is_err());
319        assert!(mark_read_thread(&location, &keys, "peer\n", 100, &options).is_err());
320        options.exclusive_ownership_confirmed = false;
321        assert!(mark_read_thread(&location, &keys, "peer", 100, &options).is_err());
322        options.exclusive_ownership_confirmed = true;
323        let mut other_keys = keys.clone();
324        other_keys.account_binding = "other".into();
325        assert!(mark_read_thread(&location, &other_keys, "peer", 100, &options).is_err());
326        let database = Connection::open(location.storage.join("session/session.db")).unwrap();
327        database
328            .execute_batch(
329                "CREATE TRIGGER unexpected AFTER UPDATE ON SessionTable BEGIN SELECT 1; END;",
330            )
331            .unwrap();
332        drop(database);
333        assert!(mark_read_thread(&location, &keys, "peer", 100, &options).is_err());
334    }
335
336    #[test]
337    fn exit_read_skips_missing_hidden_and_every_excluded_builtin() {
338        let (_directory, location, keys, options) = fixture(false);
339        assert_eq!(
340            mark_read_thread_exit(&location, &keys, "absent", 100, &options)
341                .unwrap()
342                .outcome,
343            ThreadExitReadOutcome::MissingSession
344        );
345        let database = Connection::open(location.storage.join("session/session.db")).unwrap();
346        database
347            .execute(
348                "UPDATE SessionTable SET is_hidden=1 WHERE username='peer'",
349                [],
350            )
351            .unwrap();
352        assert_eq!(
353            mark_read_thread_exit(&location, &keys, "peer", 100, &options)
354                .unwrap()
355                .outcome,
356            ThreadExitReadOutcome::HiddenSession
357        );
358        assert_eq!(
359            database
360                .query_row(
361                    "SELECT unread_count FROM SessionTable WHERE username='peer'",
362                    [],
363                    |row| row.get::<_, i64>(0)
364                )
365                .unwrap(),
366            9
367        );
368        for peer in EXIT_READ_EXCLUDED_PEERS {
369            database.execute("INSERT INTO SessionTable(username,unread_count,is_hidden,last_timestamp,last_clear_unread_timestamp)VALUES(?1,4,0,100,20)", [peer]).unwrap();
370            assert_eq!(
371                mark_read_thread_exit(&location, &keys, peer, 100, &options)
372                    .unwrap()
373                    .outcome,
374                ThreadExitReadOutcome::ExcludedPeer
375            );
376            assert_eq!(
377                database
378                    .query_row(
379                        "SELECT unread_count FROM SessionTable WHERE username=?1",
380                        [peer],
381                        |row| row.get::<_, i64>(0)
382                    )
383                    .unwrap(),
384                4
385            );
386        }
387        database
388            .execute(
389                "UPDATE SessionTable SET is_hidden=0,last_timestamp=110 WHERE username='peer'",
390                [],
391            )
392            .unwrap();
393        database.execute_batch("INSERT INTO SessionUnreadListTable_1(username_id,server_id,local_id,create_time)VALUES(1,10,3,110)").unwrap();
394        drop(database);
395        let applied = mark_read_thread_exit(&location, &keys, "peer", 100, &options).unwrap();
396        assert_eq!(applied.outcome, ThreadExitReadOutcome::Applied);
397        let projection = applied.projection.unwrap();
398        assert_eq!(
399            (
400                projection.removed_unread_rows,
401                projection.unread_count_after,
402                projection.last_clear_unread_timestamp
403            ),
404            (2, 7, 100)
405        );
406    }
407}