imsg_session/sync.rs
1//! Store-integrated sync: message ingestion and per-folder cursor backfill.
2
3use crate::util::now_ms;
4pub use crate::util::{datetime_to_ms, ms_to_display};
5
6use map_core::client::MapClient;
7use map_core::folders::Folder;
8use store::{Direction, FolderSyncStatus, NewMessage, Store};
9use tokio::io::{AsyncRead, AsyncWrite};
10
11use crate::fetch::{fetch_folder, FetchedMessage};
12
13/// Maps a device-read message to a store insert record, stamping `synced_at`.
14///
15/// `direction` is derived from `sent`; `status` is `1` for read, `0` for unread. The persist
16/// boundary — store shape (`NewMessage`) stays out of the shared read path.
17fn to_new_message(msg: FetchedMessage, synced_at: i64) -> NewMessage {
18 NewMessage {
19 map_handle: msg.handle,
20 timestamp_ms: msg.timestamp_ms,
21 folder: msg.folder,
22 direction: if msg.sent { Direction::Sent } else { Direction::Received },
23 address: msg.address,
24 status: i32::from(msg.read),
25 synced_at,
26 text: msg.text,
27 outgoing_status: None,
28 }
29}
30
31/// Fetches and upserts all messages in `folder` since the per-folder cursor anchor, paging
32/// at 1024 messages per request.
33///
34/// Reads the cursor before fetching to derive `since_ms` (`None` on first run = full fetch).
35/// Tracks the highest `timestamp_ms` seen across all upserted messages; on success writes the
36/// cursor with `sync_status = Complete` and `highest_ts` set to that value (or the previous
37/// `highest_ts` when no new messages were found). The cursor is not updated on error —
38/// the next run retries from the same anchor.
39///
40/// # Errors
41///
42/// Returns an error if any MAP operation or store read/write fails.
43async fn backfill_folder<T: AsyncRead + AsyncWrite + Unpin>(
44 client: &mut MapClient<T>,
45 store: &Store,
46 folder: Folder,
47 now: i64,
48) -> anyhow::Result<()> {
49 let cursor = store.get_cursor(folder.as_str()).await?;
50 let since_ms = cursor.as_ref().map(|c| c.highest_ts);
51 // Preserve the previous highest_ts when no new messages arrive this run.
52 let mut highest_ts_seen = cursor.as_ref().map_or(0, |c| c.highest_ts);
53 let folder_str = folder.as_str();
54 let is_sent = folder == Folder::Sent;
55
56 for msg in fetch_folder(client, folder, since_ms, now).await? {
57 if msg.timestamp_ms > highest_ts_seen {
58 highest_ts_seen = msg.timestamp_ms;
59 }
60 let handle = msg.handle.clone();
61 store.upsert(to_new_message(msg, now)).await?;
62 // Reconcile: if this Sent message matches a speculatively-created local row
63 // that was awaiting confirmation, advance its outgoing_status to sent_confirmed.
64 if is_sent {
65 store.reconcile_outgoing(&handle).await?;
66 }
67 }
68
69 // Cursor is written only on full success; errors above exit before reaching this line.
70 store.set_cursor(folder_str, now, highest_ts_seen, FolderSyncStatus::Complete).await?;
71 Ok(())
72}
73
74/// Fetches MAP messages and upserts them into the store using per-folder cursor anchors.
75///
76/// When `folder_scope` is `None`, all four folders are processed in order: `Inbox`, `Sent`,
77/// `Deleted`, `Outbox`. When `Some`, only the specified folder is processed.
78///
79/// Each folder runs independently: a failure on one folder is logged and skipped so other
80/// folders can still advance their cursors. Returns an error if any folder failed.
81///
82/// # Errors
83///
84/// Returns the first folder error encountered. Callers should treat a partial failure as
85/// "sync incomplete" and re-run to retry the failed folder(s).
86pub async fn backfill<T: AsyncRead + AsyncWrite + Unpin>(
87 client: &mut MapClient<T>,
88 store: &Store,
89 folder_scope: Option<Folder>,
90) -> anyhow::Result<()> {
91 let all_folders = [Folder::Inbox, Folder::Sent, Folder::Deleted, Folder::Outbox];
92 let single;
93 let folders: &[Folder] = if let Some(f) = folder_scope {
94 single = [f];
95 &single
96 } else {
97 &all_folders
98 };
99
100 let now = now_ms();
101 let mut first_err: Option<anyhow::Error> = None;
102
103 for &folder in folders {
104 if let Err(e) = backfill_folder(client, store, folder, now).await {
105 tracing::warn!("backfill: {} failed — {e:#}", folder.as_str());
106 if first_err.is_none() {
107 first_err = Some(e);
108 }
109 }
110 }
111
112 first_err.map_or(Ok(()), Err)
113}
114
115/// Runs an incremental backfill across all folders using per-folder cursor anchors.
116///
117/// Each folder's cursor determines the pull boundary; a folder with no cursor triggers a full
118/// fetch. This is the sync coordinator's entry point — it is not intended for read commands.
119/// Call [`backfill`] directly when a folder scope is needed.
120///
121/// # Errors
122///
123/// Returns an error if any folder's backfill fails; see [`backfill`] for continuation semantics.
124pub async fn backfill_catch_up<T: AsyncRead + AsyncWrite + Unpin>(
125 client: &mut MapClient<T>,
126 store: &Store,
127) -> anyhow::Result<()> {
128 backfill(client, store, None).await
129}