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}