Skip to main content

hey_sdk/services/
calendar_changes.rs

1//! Following the calendar changes feeds: what changed since a cursor, and where to resume.
2//!
3//! There are two of them and they speak the same cursor — the calendar-level feed behind a
4//! [`CalendarList`](crate::services::CalendarList)'s `calendar_changes_url`, and each
5//! calendar's own recording feed behind its
6//! [`ListedCalendar`]'s `recording_changes_url`.
7//!
8//! Neither feed's answers are cached. A cursor URL never repeats, so a cached response would
9//! never be revalidated, and a long-running watch would grow the cache by one dead entry per
10//! read.
11
12use std::borrow::Cow;
13use std::collections::{BTreeMap, HashSet};
14
15use serde::{Deserialize, Serialize};
16use url::Url;
17
18use crate::client::Response;
19use crate::error::Error;
20use crate::generated::services::calendars::Calendars;
21use crate::generated::types::{Calendar, Recording};
22use crate::http::Method;
23use crate::observability::OperationInfo;
24use crate::operation::Operation;
25use crate::pagination::next_link;
26use crate::security::is_same_origin;
27use crate::services::calendars::ListedCalendar;
28use crate::types::DateTime;
29
30/// The recording feed answers 409 when the cursor is too far behind for an increment to
31/// carry the difference, or speaks a version the feed no longer does. Both mean "read the
32/// calendar in full", which comes back as an answer rather than a failure.
33const TOO_FAR_BEHIND: u16 = 409;
34
35/// Where a read of a changes feed starts. `since` is an ISO 8601 timestamp with
36/// milliseconds and is exclusive; `version` is the contract version the caller speaks.
37///
38/// Build one with [`CalendarChangesCursor::from_url`] rather than by hand. The two
39/// server-issued URLs differ — a recording changes URL carries `v=1`, which the recording
40/// feed refuses to answer without, while a calendar changes URL carries no version at all —
41/// so only the server knows which pair its feed wants.
42#[derive(Debug, Clone, PartialEq, Eq)]
43pub struct CalendarChangesCursor {
44    /// The instant the changes come after.
45    pub since: Option<String>,
46    /// The contract version the feed speaks, HEY's `v`.
47    pub version: Option<String>,
48    /// The page within an increment, while it has more than one.
49    pub page: Option<String>,
50    /// How many changes a page holds, when the URL named a size.
51    pub per_page: Option<String>,
52}
53
54impl CalendarChangesCursor {
55    /// Reads a cursor out of a changes URL the server issued: a calendar list's
56    /// `calendar_changes_url`, a listed calendar's `recording_changes_url`, or the `Link`
57    /// header either feed answered with.
58    pub fn from_url(changes_url: &str) -> Result<CalendarChangesCursor, Error> {
59        let url = Url::parse(changes_url)
60            .map_err(|error| Error::usage(format!("changes URL {changes_url}: {error}")))?;
61        Ok(CalendarChangesCursor::from_parsed(&url))
62    }
63
64    fn from_parsed(url: &Url) -> CalendarChangesCursor {
65        CalendarChangesCursor {
66            since: parameter(url, "since"),
67            version: parameter(url, "v"),
68            page: parameter(url, "page"),
69            per_page: parameter(url, "per_page"),
70        }
71    }
72
73    /// Renders the cursor onto a request. The version is never invented here: a cursor read
74    /// from a server-issued URL carries whichever version that feed speaks.
75    fn apply(&self, operation: &mut Operation) {
76        operation.query_optional("since", self.since.as_ref());
77        operation.query_optional("v", self.version.as_ref());
78        operation.query_optional("page", self.page.as_ref());
79        operation.query_optional("per_page", self.per_page.as_ref());
80    }
81}
82
83/// A calendar the changes feed reports gone.
84#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
85#[non_exhaustive]
86pub struct DeletedCalendar {
87    /// The id the calendar had.
88    #[serde(default)]
89    pub id: i64,
90    /// When it went.
91    pub deleted_at: DateTime,
92}
93
94/// Everything that happened to the calendar list since a cursor. Added calendars arrive as
95/// [`ListedCalendar`], so a new calendar comes with the changes URL and signed stream name
96/// a live follower needs.
97///
98/// `next_page` is set while this increment has more pages to read now. `next_cursor` is set
99/// on the last page and is where the next read should resume; it is `None` when nothing
100/// changed, in which case the cursor that produced this page still stands. Unlike the
101/// recording feed, this one never falls too far behind, so there is no full sync to ask for.
102#[derive(Debug, Clone, Default, PartialEq)]
103#[non_exhaustive]
104pub struct CalendarChanges {
105    /// The calendars that appeared, each with what a live follower needs.
106    pub added: Vec<ListedCalendar>,
107    /// The calendars that changed.
108    pub updated: Vec<Calendar>,
109    /// The calendars that went.
110    pub deleted: Vec<DeletedCalendar>,
111    /// The next page of this increment, while it has one.
112    pub next_page: Option<CalendarChangesCursor>,
113    /// Where the next read resumes, once the increment is read to its end.
114    pub next_cursor: Option<CalendarChangesCursor>,
115}
116
117/// A recording the changes feed reports gone. `type` is the recordable type key the
118/// recording was grouped under while it existed.
119#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
120#[non_exhaustive]
121pub struct DeletedRecording {
122    /// The id the recording had.
123    #[serde(default)]
124    pub id: i64,
125    /// When it went.
126    pub deleted_at: DateTime,
127    /// The recordable type key it was grouped under, `Calendar::Event` and the like.
128    #[serde(default)]
129    pub r#type: String,
130}
131
132/// Everything that happened to a calendar's recordings since a cursor.
133///
134/// `added` and `updated` keep the wire's grouping by recordable type key —
135/// `Calendar::Event`, `Calendar::Habit`, `Calendar::Habit::Completion`,
136/// `Calendar::DayTitle`, `Calendar::DayBackground`, `Calendar::TimeTrack`,
137/// `Calendar::Todo`, `Calendar::Countdown`, `Calendar::JournalEntry` — the server owns that
138/// vocabulary. `deleted` is one deduplicated list instead: the wire groups deletions by
139/// type key too, but repeats the whole deleted collection under every key it groups, so the
140/// map shape carries nothing beyond each record's own `type`, which is authoritative.
141///
142/// `next_page` is set while this increment has more pages to read now. `next_cursor` is set
143/// on the last page and is where the next read should resume; it is `None` when nothing
144/// changed, in which case the cursor that produced this page still stands.
145/// `full_sync_required` is set when the cursor is too far behind for an increment to carry
146/// the difference — or speaks a version the feed no longer does — and the calendar has to be
147/// read in full instead.
148#[derive(Debug, Clone, Default, PartialEq)]
149#[non_exhaustive]
150pub struct RecordingChanges {
151    /// The recordings that appeared, grouped by recordable type key.
152    pub added: BTreeMap<String, Vec<Recording>>,
153    /// The recordings that changed, grouped by recordable type key.
154    pub updated: BTreeMap<String, Vec<Recording>>,
155    /// The recordings that went, each once.
156    pub deleted: Vec<DeletedRecording>,
157    /// The next page of this increment, while it has one.
158    pub next_page: Option<CalendarChangesCursor>,
159    /// Where the next read resumes, once the increment is read to its end.
160    pub next_cursor: Option<CalendarChangesCursor>,
161    /// Whether the cursor is too far behind and the calendar has to be read in full.
162    pub full_sync_required: bool,
163}
164
165impl Calendars<'_> {
166    /// Reads the calendar changes feed from a cursor to its end, following the pages the
167    /// feed hands out up to the client's page limit.
168    pub async fn all_calendar_changes(
169        &self,
170        cursor: &CalendarChangesCursor,
171    ) -> Result<CalendarChanges, Error> {
172        let mut all = CalendarChanges::default();
173        let mut cursor = cursor.clone();
174
175        for _ in 0..self.client().max_pages() {
176            let mut changes = self.calendar_changes(&cursor).await?;
177            all.added.append(&mut changes.added);
178            all.updated.append(&mut changes.updated);
179            all.deleted.append(&mut changes.deleted);
180            all.next_cursor = changes.next_cursor;
181
182            match changes.next_page {
183                None => return Ok(all),
184                Some(next) => cursor = next,
185            }
186        }
187
188        crate::trace::warning!(
189            max_pages = self.client().max_pages(),
190            "calendar changes pagination capped"
191        );
192        Ok(all)
193    }
194
195    /// Reads one page of the calendar changes feed.
196    pub async fn calendar_changes(
197        &self,
198        cursor: &CalendarChangesCursor,
199    ) -> Result<CalendarChanges, Error> {
200        if cursor.since.is_none() {
201            return Err(Error::usage(
202                "a since cursor is required — start from the list's calendar_changes_url",
203            ));
204        }
205
206        let mut operation = self.client().request(Method::GET, "/calendar/changes");
207        operation.info(changes_info("GetCalendarChanges", "calendar"));
208        cursor.apply(&mut operation);
209        operation.no_cache();
210
211        let response = self.client().execute(operation).await?;
212        let payload: CalendarChangesPayload = response.json()?;
213        let (next_page, next_cursor) = next_cursors(&response, self.client().base_url())?;
214        Ok(CalendarChanges {
215            added: payload.added,
216            updated: payload.updated,
217            deleted: payload.deleted,
218            next_page,
219            next_cursor,
220        })
221    }
222
223    /// Reads a calendar's recording changes feed from a cursor to its end, following the
224    /// pages the feed hands out up to the client's page limit. A cursor the feed has left
225    /// behind ends the walk on the spot with `full_sync_required`.
226    pub async fn all_recording_changes(
227        &self,
228        calendar_id: i64,
229        cursor: &CalendarChangesCursor,
230    ) -> Result<RecordingChanges, Error> {
231        let mut all = RecordingChanges::default();
232        let mut cursor = cursor.clone();
233
234        for _ in 0..self.client().max_pages() {
235            let mut changes = self.recording_changes(calendar_id, &cursor).await?;
236            if changes.full_sync_required {
237                return Ok(changes);
238            }
239
240            merge_recordings(&mut all.added, changes.added);
241            merge_recordings(&mut all.updated, changes.updated);
242            all.deleted.append(&mut changes.deleted);
243            all.next_cursor = changes.next_cursor;
244
245            match changes.next_page {
246                None => return Ok(all),
247                Some(next) => cursor = next,
248            }
249        }
250
251        crate::trace::warning!(
252            max_pages = self.client().max_pages(),
253            "recording changes pagination capped"
254        );
255        Ok(all)
256    }
257
258    /// Reads one page of a calendar's recording changes feed.
259    pub async fn recording_changes(
260        &self,
261        calendar_id: i64,
262        cursor: &CalendarChangesCursor,
263    ) -> Result<RecordingChanges, Error> {
264        if cursor.since.is_none() {
265            return Err(Error::usage(
266                "a since cursor is required — start from the calendar's recording_changes_url",
267            ));
268        }
269        if cursor.version.is_none() {
270            return Err(Error::usage(
271                "a feed version is required — read the calendar's recording_changes_url with CalendarChangesCursor::from_url",
272            ));
273        }
274
275        let mut operation = self.client().request(
276            Method::GET,
277            format!("/calendars/{calendar_id}/recording/changes"),
278        );
279        operation.info(changes_info("GetCalendarRecordingChanges", "recording"));
280        operation.resource_id(calendar_id);
281        cursor.apply(&mut operation);
282        operation.no_cache();
283
284        let response = match self.client().execute(operation).await {
285            Ok(response) => response,
286            Err(error) if error.http_status() == Some(TOO_FAR_BEHIND) => {
287                return Ok(RecordingChanges {
288                    full_sync_required: true,
289                    ..RecordingChanges::default()
290                });
291            }
292            Err(error) => return Err(error),
293        };
294
295        let payload: RecordingChangesPayload = response.json()?;
296        let (next_page, next_cursor) = next_cursors(&response, self.client().base_url())?;
297        Ok(RecordingChanges {
298            added: payload.added,
299            updated: payload.updated,
300            deleted: flatten_deleted_recordings(payload.deleted),
301            next_page,
302            next_cursor,
303            full_sync_required: false,
304        })
305    }
306}
307
308#[derive(Debug, Default, Deserialize)]
309struct CalendarChangesPayload {
310    #[serde(default)]
311    added: Vec<ListedCalendar>,
312    #[serde(default)]
313    updated: Vec<Calendar>,
314    #[serde(default)]
315    deleted: Vec<DeletedCalendar>,
316}
317
318#[derive(Debug, Default, Deserialize)]
319struct RecordingChangesPayload {
320    #[serde(default)]
321    added: BTreeMap<String, Vec<Recording>>,
322    #[serde(default)]
323    updated: BTreeMap<String, Vec<Recording>>,
324    #[serde(default)]
325    deleted: BTreeMap<String, Vec<DeletedRecording>>,
326}
327
328fn changes_info(operation: &'static str, resource_type: &'static str) -> OperationInfo {
329    OperationInfo {
330        service: Cow::Borrowed("Calendars"),
331        operation: Cow::Borrowed(operation),
332        resource_type: Cow::Borrowed(resource_type),
333        is_mutation: false,
334        resource_id: None,
335    }
336}
337
338/// The cursors the feed's `Link` header names, the page one first: while an increment has
339/// more pages the link carries a page cursor, and the last page carries a fresh `since`
340/// cursor instead. Both are `None` when there is no link.
341fn next_cursors(
342    response: &Response,
343    base_url: &Url,
344) -> Result<(Option<CalendarChangesCursor>, Option<CalendarChangesCursor>), Error> {
345    match link_cursor(response, base_url)? {
346        None => Ok((None, None)),
347        Some(cursor) if cursor.page.is_some() => Ok((Some(cursor), None)),
348        Some(cursor) => Ok((None, Some(cursor))),
349    }
350}
351
352fn link_cursor(
353    response: &Response,
354    base_url: &Url,
355) -> Result<Option<CalendarChangesCursor>, Error> {
356    match response.header("link").and_then(next_link) {
357        None => Ok(None),
358        Some(target) => {
359            let next = response.url.join(&target)?;
360            if is_same_origin(&next, base_url) {
361                Ok(Some(CalendarChangesCursor::from_parsed(&next)))
362            } else {
363                Err(Error::usage(format!(
364                    "changes Link header points to a different origin: {next}"
365                )))
366            }
367        }
368    }
369}
370
371fn merge_recordings(
372    into: &mut BTreeMap<String, Vec<Recording>>,
373    from: BTreeMap<String, Vec<Recording>>,
374) {
375    for (key, recordings) in from {
376        into.entry(key).or_default().extend(recordings);
377    }
378}
379
380/// Folds the wire's per-type deleted buckets into one list. The server repeats the whole
381/// deleted collection under every type key it groups, so the same deletion arrives once per
382/// key: the id dedupe drops the repeats, and each record's own `type` says what it was.
383fn flatten_deleted_recordings(
384    buckets: BTreeMap<String, Vec<DeletedRecording>>,
385) -> Vec<DeletedRecording> {
386    let mut seen = HashSet::new();
387    buckets
388        .into_values()
389        .flatten()
390        .filter(|record| seen.insert(record.id))
391        .collect()
392}
393
394fn parameter(url: &Url, name: &str) -> Option<String> {
395    url.query_pairs()
396        .find(|(key, _)| key == name)
397        .map(|(_, value)| value.into_owned())
398        .filter(|value| !value.is_empty())
399}