Skip to main content

baukit_sync/
horizon.rs

1//! Pull-cursor rules for tombstone purge horizons.
2//!
3//! A server that purges tombstones keeps one horizon per owner: the greatest
4//! revision of any tombstone it purged. A pull cursor below that horizon can
5//! no longer replay every deletion, so the client must rebuild from cursor
6//! zero. The constants here are the stable wire signal that
7//! `@baukit/sync-client` already decodes: HTTP 409 with error code
8//! `resync_required` and the horizon in `details.horizonRevision`.
9
10use thiserror::Error;
11
12/// HTTP status of a stale-cursor response.
13pub const RESYNC_REQUIRED_STATUS: u16 = 409;
14
15/// Stable error `code` of a stale-cursor response.
16pub const RESYNC_REQUIRED_CODE: &str = "resync_required";
17
18/// Error `details` key that carries the owner's purge horizon.
19pub const HORIZON_REVISION_DETAIL: &str = "horizonRevision";
20
21/// Cursor that requests a full rebuild. It is valid at every horizon.
22pub const FULL_RESYNC_CURSOR: i64 = 0;
23
24/// Reason a pull cursor cannot be served incrementally.
25#[derive(Clone, Copy, Debug, Eq, Error, PartialEq)]
26#[non_exhaustive]
27pub enum PullCursorError {
28    /// The cursor is below zero, which no revision counter can produce.
29    #[error("pull cursor must not be negative")]
30    Negative,
31    /// The cursor is above zero and below the owner's purge horizon.
32    #[error("pull cursor is below the purge horizon {horizon_revision}")]
33    ResyncRequired {
34        /// The owner's current purge horizon.
35        horizon_revision: i64,
36    },
37}
38
39/// Checks a pull cursor against the owner's purge horizon.
40///
41/// Cursor zero is always accepted. A cursor equal to or above the horizon is a
42/// valid incremental request. A cursor between zero and the horizon returns
43/// [`PullCursorError::ResyncRequired`]. Pass zero as the horizon for an owner
44/// that has none.
45///
46/// ```
47/// use baukit_sync::horizon::{PullCursorError, check_pull_cursor};
48///
49/// assert_eq!(check_pull_cursor(0, 42), Ok(()));
50/// assert_eq!(check_pull_cursor(42, 42), Ok(()));
51/// assert_eq!(
52///     check_pull_cursor(41, 42),
53///     Err(PullCursorError::ResyncRequired { horizon_revision: 42 })
54/// );
55/// ```
56///
57/// # Errors
58///
59/// Returns [`PullCursorError::Negative`] for a negative cursor and
60/// [`PullCursorError::ResyncRequired`] for a stale one.
61pub const fn check_pull_cursor(cursor: i64, horizon_revision: i64) -> Result<(), PullCursorError> {
62    if cursor < FULL_RESYNC_CURSOR {
63        return Err(PullCursorError::Negative);
64    }
65    if cursor > FULL_RESYNC_CURSOR && cursor < horizon_revision {
66        return Err(PullCursorError::ResyncRequired { horizon_revision });
67    }
68    Ok(())
69}
70
71#[cfg(test)]
72mod tests {
73    use super::*;
74
75    #[test]
76    fn cursor_zero_is_accepted_at_every_horizon() {
77        assert_eq!(check_pull_cursor(0, 0), Ok(()));
78        assert_eq!(check_pull_cursor(0, i64::MAX), Ok(()));
79    }
80
81    #[test]
82    fn cursor_at_or_above_the_horizon_is_incremental() {
83        assert_eq!(check_pull_cursor(10, 10), Ok(()));
84        assert_eq!(check_pull_cursor(11, 10), Ok(()));
85        assert_eq!(check_pull_cursor(1, 0), Ok(()));
86    }
87
88    #[test]
89    fn cursor_below_the_horizon_requires_resync() {
90        assert_eq!(
91            check_pull_cursor(9, 10),
92            Err(PullCursorError::ResyncRequired {
93                horizon_revision: 10
94            })
95        );
96        assert_eq!(
97            check_pull_cursor(1, 2),
98            Err(PullCursorError::ResyncRequired {
99                horizon_revision: 2
100            })
101        );
102    }
103
104    #[test]
105    fn horizon_detail_uses_the_camel_case_wire_name() {
106        assert_eq!(HORIZON_REVISION_DETAIL, "horizonRevision");
107    }
108
109    #[test]
110    fn negative_cursor_is_rejected() {
111        assert_eq!(check_pull_cursor(-1, 0), Err(PullCursorError::Negative));
112    }
113}