Skip to main content

loonfs_api/
pagination.rs

1//! Page-size policy, page envelopes, and opaque cursors for paginated endpoints.
2
3use crate::capability::{LIMIT_PAGINATION_DEFAULT, LIMIT_PAGINATION_MAX};
4use crate::{ChangeSeq, InodeId, NameKey, NamespaceId, RevisionNo};
5use serde::{Deserialize, Serialize};
6use std::collections::BTreeMap;
7use std::future::Future;
8use std::num::NonZeroU32;
9use std::pin::Pin;
10use thiserror::Error;
11
12/// A response that carries items and a continuation position.
13pub trait PagedResponse: Send + 'static {
14    /// One item in the response.
15    type Item: Send + 'static;
16    /// The position passed to the next request.
17    type Cursor: Clone + Send + 'static;
18
19    /// Returns the response items for collection or splitting.
20    fn items_mut(&mut self) -> &mut Vec<Self::Item>;
21
22    /// Returns the response items.
23    fn items(&self) -> &[Self::Item];
24
25    /// Returns the next request position.
26    fn next_cursor(&self) -> Option<Self::Cursor>;
27
28    /// Appends a later page and adopts its continuation metadata.
29    fn absorb(&mut self, later: Self);
30}
31
32enum PagerState<C> {
33    NotStarted,
34    More(C),
35    Done,
36}
37
38type PageFuture<P, E> = Pin<Box<dyn Future<Output = Result<P, E>> + Send>>;
39type PageFetcher<P, E> =
40    Box<dyn FnMut(Option<<P as PagedResponse>::Cursor>) -> PageFuture<P, E> + Send>;
41
42/// Fetches pages and retains unused items between bounded collections.
43#[must_use]
44pub struct Pager<P: PagedResponse, E> {
45    fetch: PageFetcher<P, E>,
46    state: PagerState<P::Cursor>,
47    pending: Option<P>,
48}
49
50impl<P: PagedResponse, E> Pager<P, E> {
51    /// Creates a pager beginning at `cursor`.
52    pub fn new<F, Fut>(cursor: Option<P::Cursor>, mut fetch: F) -> Self
53    where
54        F: FnMut(Option<P::Cursor>) -> Fut + Send + 'static,
55        Fut: Future<Output = Result<P, E>> + Send + 'static,
56    {
57        let state = match cursor {
58            Some(cursor) => PagerState::More(cursor),
59            None => PagerState::NotStarted,
60        };
61        Self {
62            fetch: Box::new(move |cursor| Box::pin(fetch(cursor))),
63            state,
64            pending: None,
65        }
66    }
67
68    /// Returns the next page, or `None` after exhaustion.
69    pub async fn next(&mut self) -> Option<Result<P, E>> {
70        if let Some(page) = self.pending.take() {
71            return Some(Ok(page));
72        }
73        let cursor = match &self.state {
74            PagerState::NotStarted => None,
75            PagerState::More(cursor) => Some(cursor.clone()),
76            PagerState::Done => return None,
77        };
78        let page = (self.fetch)(cursor).await;
79        if let Ok(page) = &page {
80            self.state = match page.next_cursor() {
81                Some(cursor) => PagerState::More(cursor),
82                None => PagerState::Done,
83            };
84        }
85        Some(page)
86    }
87
88    /// Returns at most `max_items` items.
89    pub async fn collect_up_to(&mut self, max_items: usize) -> Result<Vec<P::Item>, E> {
90        let mut items = Vec::new();
91        while items.len() < max_items {
92            let Some(page) = self.next().await else {
93                break;
94            };
95            let mut page = page?;
96            let page_items = page.items_mut();
97            let take = (max_items - items.len()).min(page_items.len());
98            if take < page_items.len() {
99                let remaining = page_items.split_off(take);
100                items.append(page_items);
101                *page.items_mut() = remaining;
102                self.pending = Some(page);
103                break;
104            }
105            items.append(page_items);
106        }
107        Ok(items)
108    }
109}
110
111macro_rules! string_cursor_response {
112    ($response:path, $item:ty, $field:ident $(, $metadata:ident)*) => {
113        impl PagedResponse for $response {
114            type Item = $item;
115            type Cursor = String;
116
117            fn items_mut(&mut self) -> &mut Vec<Self::Item> {
118                &mut self.$field
119            }
120
121            fn items(&self) -> &[Self::Item] {
122                &self.$field
123            }
124
125            fn next_cursor(&self) -> Option<Self::Cursor> {
126                self.next_cursor.clone()
127            }
128
129            fn absorb(&mut self, mut later: Self) {
130                $(self.$metadata = later.$metadata;)*
131                self.$field.append(&mut later.$field);
132                self.next_cursor = later.next_cursor;
133            }
134        }
135    };
136}
137
138string_cursor_response!(
139    crate::ListPathEntriesResponse,
140    crate::PathEntry,
141    entries,
142    head_seq
143);
144string_cursor_response!(
145    crate::ListInodeChildrenResponse,
146    crate::PathEntry,
147    entries,
148    head_seq
149);
150string_cursor_response!(
151    crate::ListFileRevisionsResponse,
152    crate::FileRevision,
153    revisions,
154    head_seq
155);
156string_cursor_response!(
157    crate::ListTrashResponse,
158    crate::TrashEntry,
159    entries,
160    head_seq
161);
162string_cursor_response!(
163    crate::ListCheckpointsResponse,
164    crate::Checkpoint,
165    checkpoints
166);
167string_cursor_response!(
168    crate::v0::ListSnapshotsResponse,
169    crate::v0::SnapshotSummary,
170    snapshots
171);
172
173impl PagedResponse for crate::v0::ListChangesResponse {
174    type Item = crate::v0::Commit;
175    type Cursor = ChangeSeq;
176
177    fn items_mut(&mut self) -> &mut Vec<Self::Item> {
178        &mut self.changes
179    }
180
181    fn items(&self) -> &[Self::Item] {
182        &self.changes
183    }
184
185    fn next_cursor(&self) -> Option<Self::Cursor> {
186        self.next_after_seq
187    }
188
189    fn absorb(&mut self, mut later: Self) {
190        self.through_seq = later.through_seq;
191        self.next_after_seq = later.next_after_seq;
192        self.changes.append(&mut later.changes);
193    }
194}
195
196/// Contract page size for endpoints that omit a caller-supplied limit.
197///
198/// This value is deliberately fixed and advertised through capabilities.
199pub const DEFAULT_PAGE_LIMIT: u32 = 1_000;
200/// Contract maximum accepted page size.
201///
202/// This value is deliberately fixed and advertised through capabilities.
203pub const DEFAULT_MAX_PAGE_LIMIT: u32 = 1_000;
204
205/// Format version written into every encoded cursor.
206pub const PAGE_CURSOR_FORMAT_VERSION: u8 = 1;
207
208/// A validated page size selected from a caller request and a policy.
209#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
210pub struct EffectiveLimit(NonZeroU32);
211
212impl EffectiveLimit {
213    /// Creates an effective limit from a non-zero value.
214    pub fn new(value: NonZeroU32) -> Self {
215        Self(value)
216    }
217
218    /// Returns the numeric page size.
219    pub fn get(self) -> u32 {
220        self.0.get()
221    }
222
223    /// Returns the page size as a `usize` for vector reservations and counters.
224    pub fn as_usize(self) -> usize {
225        self.0.get() as usize
226    }
227
228    /// Returns the number of items an engine should try to read to detect a next page.
229    pub fn limit_plus_one(self) -> usize {
230        self.as_usize().saturating_add(1)
231    }
232
233    /// Truncates an overfilled page and builds a cursor from its last row.
234    pub fn finish_page<R, C>(self, rows: &mut Vec<R>, cursor: impl FnOnce(&R) -> C) -> Option<C> {
235        if rows.len() <= self.as_usize() {
236            return None;
237        }
238        rows.truncate(self.as_usize());
239        rows.last().map(cursor)
240    }
241}
242
243/// Fixed pagination contract for endpoints with potentially unbounded results.
244#[derive(Debug, Clone, Copy, PartialEq, Eq)]
245pub struct PaginationPolicy {
246    default_limit: NonZeroU32,
247    max_limit: NonZeroU32,
248}
249
250impl PaginationPolicy {
251    /// Returns the page size applied when callers omit `limit`.
252    pub fn default_limit(self) -> NonZeroU32 {
253        self.default_limit
254    }
255
256    /// Returns the largest accepted caller-supplied `limit`.
257    pub fn max_limit(self) -> NonZeroU32 {
258        self.max_limit
259    }
260
261    /// Resolves a caller-supplied limit into the enforced page size.
262    pub fn resolve_limit(self, requested: Option<u32>) -> Result<EffectiveLimit, LimitError> {
263        match requested {
264            None => Ok(EffectiveLimit(self.default_limit)),
265            Some(value) if value > self.max_limit.get() => Err(LimitError::ExceedsMax {
266                requested: value,
267                max_limit: self.max_limit.get(),
268            }),
269            Some(value) => NonZeroU32::new(value)
270                .map(EffectiveLimit)
271                .ok_or(LimitError::Zero),
272        }
273    }
274
275    /// Returns the advisory capability-document limits for this policy.
276    pub fn capability_limits(self) -> BTreeMap<String, u64> {
277        BTreeMap::from([
278            (
279                LIMIT_PAGINATION_DEFAULT.to_owned(),
280                u64::from(self.default_limit.get()),
281            ),
282            (
283                LIMIT_PAGINATION_MAX.to_owned(),
284                u64::from(self.max_limit.get()),
285            ),
286        ])
287    }
288}
289
290impl Default for PaginationPolicy {
291    fn default() -> Self {
292        // These are protocol constants, not configuration defaults. Keeping
293        // them together here makes every consumer enforce and advertise the
294        // same deliberate contract.
295        let default_limit = const { NonZeroU32::new(DEFAULT_PAGE_LIMIT).unwrap() };
296        let max_limit = const { NonZeroU32::new(DEFAULT_MAX_PAGE_LIMIT).unwrap() };
297        Self {
298            default_limit,
299            max_limit,
300        }
301    }
302}
303
304/// Invalid caller-supplied page size.
305#[derive(Debug, Clone, PartialEq, Eq, Error)]
306#[non_exhaustive]
307pub enum LimitError {
308    /// The caller supplied `limit=0`.
309    #[error("limit must be greater than zero")]
310    Zero,
311    /// The caller supplied a limit larger than the active policy allows.
312    #[error("limit `{requested}` exceeds max limit `{max_limit}`")]
313    ExceedsMax {
314        /// Page size supplied by the caller.
315        requested: u32,
316        /// Largest page size allowed by the active policy.
317        max_limit: u32,
318    },
319}
320
321/// A typed page request for internal runtime and core methods.
322#[derive(Debug, Clone, PartialEq, Eq)]
323pub struct PageRequest<C> {
324    /// Enforced page size.
325    pub limit: EffectiveLimit,
326    /// Optional decoded endpoint cursor.
327    pub cursor: Option<C>,
328}
329
330/// A typed page result for internal runtime and core methods.
331#[derive(Debug, Clone, PartialEq, Eq)]
332pub struct Page<T, C> {
333    /// Returned items.
334    pub items: Vec<T>,
335    /// Cursor for the next page, if another page is available.
336    pub next_cursor: Option<C>,
337}
338
339/// A cursor that resumes one directory listing after `last_name_key`.
340///
341/// Snapshot cursors can resume only against the same snapshot.
342#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
343pub struct DirectoryPageCursor {
344    /// Head sequence the issuing page was evaluated at.
345    pub head_seq: ChangeSeq,
346    /// The snapshot that issued this cursor, or `None` for a live read.
347    #[serde(default, skip_serializing_if = "Option::is_none")]
348    pub snapshot_id: Option<crate::SnapshotId>,
349    /// Directory inode resolved at `head_seq`.
350    pub directory_inode_id: InodeId,
351    /// Last canonical name key returned to the client.
352    pub last_name_key: NameKey,
353}
354
355impl PageCursor for DirectoryPageCursor {
356    const KIND: &'static str = "directory";
357}
358
359/// A cursor that resumes a newest-first revision listing for one file.
360#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
361pub struct FileRevisionsPageCursor {
362    /// Head sequence the issuing page was evaluated at.
363    pub head_seq: ChangeSeq,
364    /// File inode whose revisions are being listed.
365    pub inode_id: InodeId,
366    /// Last revision number returned to the client.
367    pub last_revision_no: RevisionNo,
368    /// Namespace sequence that created the last returned revision.
369    pub last_committed_seq: ChangeSeq,
370    /// WAL delta index that created the last returned revision.
371    pub last_revision_delta_index: u32,
372}
373
374impl PageCursor for FileRevisionsPageCursor {
375    const KIND: &'static str = "file_revisions";
376}
377
378/// A cursor that resumes an oldest-first trash listing after one deletion.
379#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
380pub struct TrashPageCursor {
381    /// Head sequence the issuing page was evaluated at.
382    pub head_seq: ChangeSeq,
383    /// Commit sequence of the deletion the previous page ended on.
384    pub last_deletion_seq: ChangeSeq,
385    /// Deleted root inode the previous page ended on.
386    pub last_root_inode_id: InodeId,
387}
388
389impl PageCursor for TrashPageCursor {
390    const KIND: &'static str = "trash";
391}
392
393/// A cursor that resumes content search after one `(inode_id, byte_offset)` position.
394#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
395pub struct GrepPageCursor {
396    /// Sequence the issuing page was evaluated at.
397    pub head_seq: ChangeSeq,
398    /// Inode of the last candidate the issuing page finished scanning.
399    pub last_inode_id: InodeId,
400    /// The last match offset, or `u64::MAX` when the file was fully scanned.
401    pub last_byte_offset: u64,
402    /// The fingerprint of the pattern, flags, and scope that issued the cursor.
403    pub fingerprint: u64,
404}
405
406impl PageCursor for GrepPageCursor {
407    const KIND: &'static str = "grep";
408}
409
410/// A serializable cursor with an endpoint discriminator.
411pub trait PageCursor: Serialize + serde::de::DeserializeOwned {
412    /// Frozen endpoint discriminator written into the encoded cursor.
413    const KIND: &'static str;
414}
415
416#[derive(Serialize, Deserialize)]
417struct OpaqueTokenEnvelope<T> {
418    format_version: u8,
419    kind: String,
420    #[serde(flatten)]
421    token: T,
422}
423
424/// A serializable value with a frozen opaque-token discriminator.
425pub trait OpaqueToken: Serialize + serde::de::DeserializeOwned {
426    /// Frozen discriminator written into the encoded token.
427    const KIND: &'static str;
428}
429
430impl<C: PageCursor> OpaqueToken for C {
431    const KIND: &'static str = C::KIND;
432}
433
434/// Encodes a value as lowercase hexadecimal JSON with a version and kind.
435pub fn encode_token<T: OpaqueToken>(
436    token: &T,
437    format_version: u8,
438) -> Result<String, serde_json::Error> {
439    serde_json::to_vec(&OpaqueTokenEnvelope {
440        format_version,
441        kind: T::KIND.to_owned(),
442        token,
443    })
444    .map(|bytes| crate::hex::hex_encode_bytes(&bytes))
445}
446
447/// Encodes a cursor as the opaque string clients round-trip.
448pub fn encode_cursor<C: PageCursor>(cursor: &C) -> Result<String, PageCursorError> {
449    encode_token(cursor, PAGE_CURSOR_FORMAT_VERSION)
450        .map_err(|error| PageCursorError::InvalidJson(error.to_string()))
451}
452
453/// Version and endpoint, read before the body so a cursor from another
454/// endpoint reports `WrongKind` rather than a missing-field decode error.
455#[derive(Deserialize)]
456struct CursorHeader {
457    format_version: u8,
458    kind: String,
459}
460
461/// Decodes a lowercase hexadecimal JSON token with the expected version and kind.
462pub fn decode_token<T: OpaqueToken>(
463    value: &str,
464    supported_version: u8,
465) -> Result<T, OpaqueTokenError> {
466    let bytes =
467        crate::hex::hex_decode_bytes(value).map_err(|_| OpaqueTokenError::InvalidEncoding)?;
468    let header: CursorHeader = serde_json::from_slice(&bytes)
469        .map_err(|error| OpaqueTokenError::InvalidJson(error.to_string()))?;
470    if header.format_version != supported_version {
471        return Err(OpaqueTokenError::UnsupportedVersion {
472            expected: supported_version,
473            actual: header.format_version,
474        });
475    }
476    if header.kind != T::KIND {
477        return Err(OpaqueTokenError::WrongKind {
478            expected: T::KIND,
479            actual: header.kind,
480        });
481    }
482    let envelope: OpaqueTokenEnvelope<T> = serde_json::from_slice(&bytes)
483        .map_err(|error| OpaqueTokenError::InvalidJson(error.to_string()))?;
484    Ok(envelope.token)
485}
486
487/// Decodes a cursor issued by [`encode_cursor`] for the same endpoint.
488pub fn decode_cursor<C: PageCursor>(value: &str) -> Result<C, PageCursorError> {
489    decode_token(value, PAGE_CURSOR_FORMAT_VERSION).map_err(PageCursorError::from)
490}
491
492/// A cursor bound to one namespace keyspace.
493pub trait NamespaceCursor: PageCursor {
494    /// Namespace whose keyspace this cursor walks.
495    fn namespace_id(&self) -> &NamespaceId;
496
497    /// Key the enumeration stopped at, or `None` at the start.
498    fn last_key(&self) -> Option<&str>;
499
500    /// Prefix every key this cursor may name lies under.
501    fn key_prefix(&self) -> String;
502}
503
504/// Decodes a cursor issued for `expected_namespace_id`'s own keyspace.
505pub fn decode_namespace_cursor<C: NamespaceCursor>(
506    token: &str,
507    expected_namespace_id: &NamespaceId,
508) -> Result<C, NamespaceCursorError> {
509    let cursor: C = decode_cursor(token)?;
510    if cursor.namespace_id() != expected_namespace_id {
511        return Err(NamespaceCursorError::ForeignNamespace);
512    }
513    let prefix = cursor.key_prefix();
514    if cursor
515        .last_key()
516        .is_some_and(|key| !key.starts_with(&prefix))
517    {
518        return Err(NamespaceCursorError::OutsideKeyspace);
519    }
520    Ok(cursor)
521}
522
523/// Why a namespace-bound cursor cannot resume the enumeration replaying it.
524#[derive(Debug, Clone, PartialEq, Eq, Error)]
525#[non_exhaustive]
526pub enum NamespaceCursorError {
527    /// The cursor is unreadable or belongs to another endpoint, job, or version.
528    #[error(transparent)]
529    Malformed(#[from] PageCursorError),
530    /// A cursor for a different namespace than the one replaying it.
531    #[error("cursor belongs to a different namespace")]
532    ForeignNamespace,
533    /// A cursor naming a key outside the prefix its enumeration walks.
534    #[error("cursor names a key outside the enumeration it resumes")]
535    OutsideKeyspace,
536}
537
538/// Invalid opaque page cursor.
539#[derive(Debug, Clone, PartialEq, Eq, Error)]
540#[non_exhaustive]
541pub enum PageCursorError {
542    /// The cursor was not hex-encoded JSON.
543    #[error("invalid page cursor encoding")]
544    InvalidEncoding,
545    /// The cursor JSON did not match a supported cursor shape.
546    #[error("invalid page cursor JSON: {0}")]
547    InvalidJson(String),
548    /// The cursor was valid, but for a different paginated endpoint.
549    #[error("page cursor kind `{actual}` cannot be used as `{expected}` cursor")]
550    WrongKind {
551        /// Cursor kind accepted by the endpoint doing the decoding.
552        expected: &'static str,
553        /// Cursor kind recovered from the caller's opaque token.
554        actual: String,
555    },
556    /// The cursor format version is not supported by this build.
557    #[error("unsupported page cursor version `{actual}`; expected `{expected}`")]
558    UnsupportedVersion {
559        /// Cursor format version this build can decode.
560        expected: u8,
561        /// Version embedded in the caller's opaque token.
562        actual: u8,
563    },
564}
565
566/// Invalid opaque token encoding, version, kind, or body.
567#[derive(Debug, Clone, PartialEq, Eq, Error)]
568#[non_exhaustive]
569pub enum OpaqueTokenError {
570    /// The token was not lowercase hexadecimal JSON.
571    #[error("invalid opaque token encoding")]
572    InvalidEncoding,
573    /// The token JSON did not match the expected shape.
574    #[error("invalid opaque token JSON: {0}")]
575    InvalidJson(String),
576    /// The token belongs to another family.
577    #[error("opaque token kind `{actual}` cannot be used as `{expected}` token")]
578    WrongKind {
579        /// Token kind accepted by the decoder.
580        expected: &'static str,
581        /// Token kind recovered from the encoded value.
582        actual: String,
583    },
584    /// The token format version is not supported by this build.
585    #[error("unsupported opaque token version `{actual}`; expected `{expected}`")]
586    UnsupportedVersion {
587        /// Token version this build can decode.
588        expected: u8,
589        /// Version embedded in the encoded value.
590        actual: u8,
591    },
592}
593
594impl From<OpaqueTokenError> for PageCursorError {
595    fn from(error: OpaqueTokenError) -> Self {
596        match error {
597            OpaqueTokenError::InvalidEncoding => Self::InvalidEncoding,
598            OpaqueTokenError::InvalidJson(message) => Self::InvalidJson(message),
599            OpaqueTokenError::WrongKind { expected, actual } => {
600                Self::WrongKind { expected, actual }
601            }
602            OpaqueTokenError::UnsupportedVersion { expected, actual } => {
603                Self::UnsupportedVersion { expected, actual }
604            }
605        }
606    }
607}
608
609#[cfg(test)]
610mod tests {
611    use super::*;
612
613    #[test]
614    fn default_policy_resolves_omitted_limit_to_default() {
615        let policy = PaginationPolicy::default();
616        let limit = policy.resolve_limit(None).expect("default limit");
617
618        assert_eq!(limit.get(), DEFAULT_PAGE_LIMIT);
619        assert_eq!(limit.limit_plus_one(), 1_001);
620    }
621
622    #[test]
623    fn policy_rejects_invalid_limits() {
624        let policy = PaginationPolicy::default();
625
626        assert_eq!(policy.resolve_limit(Some(0)), Err(LimitError::Zero));
627        assert_eq!(
628            policy.resolve_limit(Some(DEFAULT_MAX_PAGE_LIMIT + 1)),
629            Err(LimitError::ExceedsMax {
630                requested: DEFAULT_MAX_PAGE_LIMIT + 1,
631                max_limit: DEFAULT_MAX_PAGE_LIMIT,
632            })
633        );
634    }
635
636    #[test]
637    fn policy_exports_capability_limits() {
638        let limits = PaginationPolicy::default().capability_limits();
639
640        assert_eq!(
641            limits.get("pagination.default_limit"),
642            Some(&u64::from(DEFAULT_PAGE_LIMIT))
643        );
644        assert_eq!(
645            limits.get("pagination.max_limit"),
646            Some(&u64::from(DEFAULT_MAX_PAGE_LIMIT))
647        );
648    }
649
650    #[test]
651    fn directory_cursor_round_trips() {
652        let cursor = DirectoryPageCursor {
653            head_seq: ChangeSeq(11),
654            snapshot_id: Some(
655                crate::SnapshotId::parse("pin_00000000000000000001-0000000000000001")
656                    .expect("snapshot id"),
657            ),
658            directory_inode_id: InodeId(7),
659            last_name_key: NameKey::parse("plan.md").expect("name key"),
660        };
661
662        let encoded = encode_cursor(&cursor).expect("encode cursor");
663        let decoded: DirectoryPageCursor = decode_cursor(&encoded).expect("decode cursor");
664
665        assert_eq!(decoded, cursor);
666    }
667
668    #[test]
669    fn live_directory_cursor_omits_the_additive_snapshot_field() {
670        let cursor = DirectoryPageCursor {
671            head_seq: ChangeSeq(11),
672            snapshot_id: None,
673            directory_inode_id: InodeId(7),
674            last_name_key: NameKey::parse("plan.md").expect("name key"),
675        };
676
677        let encoded = encode_cursor(&cursor).expect("encode cursor");
678        let bytes = crate::hex::hex_decode_bytes(&encoded).expect("decode hex");
679        let json: serde_json::Value = serde_json::from_slice(&bytes).expect("decode JSON");
680
681        assert!(json.get("snapshot_id").is_none());
682        assert_eq!(
683            decode_cursor::<DirectoryPageCursor>(&encoded).expect("decode cursor"),
684            cursor
685        );
686    }
687
688    #[test]
689    fn file_revisions_cursor_round_trips() {
690        let cursor = FileRevisionsPageCursor {
691            head_seq: ChangeSeq(11),
692            inode_id: InodeId(7),
693            last_revision_no: RevisionNo(5),
694            last_committed_seq: ChangeSeq(10),
695            last_revision_delta_index: 3,
696        };
697
698        let encoded = encode_cursor(&cursor).expect("encode cursor");
699        let decoded: FileRevisionsPageCursor = decode_cursor(&encoded).expect("decode cursor");
700
701        assert_eq!(decoded, cursor);
702    }
703
704    #[test]
705    fn trash_cursor_round_trips() {
706        let cursor = TrashPageCursor {
707            head_seq: ChangeSeq(11),
708            last_deletion_seq: ChangeSeq(10),
709            last_root_inode_id: InodeId(7),
710        };
711
712        let encoded = encode_cursor(&cursor).expect("encode cursor");
713        let decoded: TrashPageCursor = decode_cursor(&encoded).expect("decode cursor");
714
715        assert_eq!(decoded, cursor);
716    }
717
718    #[test]
719    fn grep_cursor_round_trips() {
720        let cursor = GrepPageCursor {
721            head_seq: ChangeSeq(11),
722            last_inode_id: InodeId(7),
723            last_byte_offset: 13,
724            fingerprint: 17,
725        };
726
727        let encoded = encode_cursor(&cursor).expect("encode cursor");
728        let decoded: GrepPageCursor = decode_cursor(&encoded).expect("decode cursor");
729
730        assert_eq!(decoded, cursor);
731    }
732
733    #[test]
734    fn cursor_kind_must_match_decoder() {
735        let cursor = FileRevisionsPageCursor {
736            head_seq: ChangeSeq(11),
737            inode_id: InodeId(7),
738            last_revision_no: RevisionNo(5),
739            last_committed_seq: ChangeSeq(10),
740            last_revision_delta_index: 3,
741        };
742        let encoded = encode_cursor(&cursor).expect("encode cursor");
743
744        assert_eq!(
745            decode_cursor::<DirectoryPageCursor>(&encoded),
746            Err(PageCursorError::WrongKind {
747                expected: "directory",
748                actual: "file_revisions".to_owned(),
749            })
750        );
751    }
752
753    #[test]
754    fn malformed_cursor_is_invalid_encoding() {
755        assert_eq!(
756            decode_cursor::<DirectoryPageCursor>("not-hex"),
757            Err(PageCursorError::InvalidEncoding)
758        );
759    }
760
761    #[test]
762    fn unsupported_cursor_version_is_rejected() {
763        let bytes = serde_json::to_vec(&OpaqueTokenEnvelope {
764            format_version: PAGE_CURSOR_FORMAT_VERSION + 1,
765            kind: <DirectoryPageCursor as PageCursor>::KIND.to_owned(),
766            token: DirectoryPageCursor {
767                head_seq: ChangeSeq(11),
768                snapshot_id: None,
769                directory_inode_id: InodeId(7),
770                last_name_key: NameKey::parse("plan.md").expect("name key"),
771            },
772        })
773        .expect("encode cursor");
774        let encoded = crate::hex::hex_encode_bytes(&bytes);
775
776        assert_eq!(
777            decode_cursor::<DirectoryPageCursor>(&encoded),
778            Err(PageCursorError::UnsupportedVersion {
779                expected: PAGE_CURSOR_FORMAT_VERSION,
780                actual: PAGE_CURSOR_FORMAT_VERSION + 1,
781            })
782        );
783    }
784
785    #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
786    struct TestNamespaceCursor {
787        namespace_id: NamespaceId,
788        last_key: String,
789    }
790
791    impl PageCursor for TestNamespaceCursor {
792        const KIND: &'static str = "test_namespace";
793    }
794
795    impl NamespaceCursor for TestNamespaceCursor {
796        fn namespace_id(&self) -> &NamespaceId {
797            &self.namespace_id
798        }
799
800        fn last_key(&self) -> Option<&str> {
801            Some(&self.last_key)
802        }
803
804        fn key_prefix(&self) -> String {
805            format!("namespaces/{}/items/", self.namespace_id)
806        }
807    }
808
809    #[test]
810    fn namespace_cursor_accepts_its_namespace_and_keyspace() {
811        let namespace_id = NamespaceId::parse("demo").expect("namespace id");
812        let cursor = TestNamespaceCursor {
813            namespace_id: namespace_id.clone(),
814            last_key: "namespaces/demo/items/item-42".to_owned(),
815        };
816        let encoded = encode_cursor(&cursor).expect("encode cursor");
817
818        assert_eq!(
819            decode_namespace_cursor::<TestNamespaceCursor>(&encoded, &namespace_id)
820                .expect("decode namespace cursor"),
821            cursor
822        );
823    }
824
825    #[test]
826    fn namespace_cursor_rejects_a_different_namespace() {
827        let cursor = TestNamespaceCursor {
828            namespace_id: NamespaceId::parse("demo").expect("namespace id"),
829            last_key: "namespaces/demo/items/item-42".to_owned(),
830        };
831        let encoded = encode_cursor(&cursor).expect("encode cursor");
832
833        assert_eq!(
834            decode_namespace_cursor::<TestNamespaceCursor>(
835                &encoded,
836                &NamespaceId::parse("other").expect("other namespace id")
837            ),
838            Err(NamespaceCursorError::ForeignNamespace)
839        );
840    }
841
842    #[test]
843    fn namespace_cursor_rejects_a_key_outside_its_keyspace() {
844        let namespace_id = NamespaceId::parse("demo").expect("namespace id");
845        let cursor = TestNamespaceCursor {
846            namespace_id: namespace_id.clone(),
847            last_key: "namespaces/demo/pins/checkpoint-42".to_owned(),
848        };
849        let encoded = encode_cursor(&cursor).expect("encode cursor");
850
851        assert_eq!(
852            decode_namespace_cursor::<TestNamespaceCursor>(&encoded, &namespace_id),
853            Err(NamespaceCursorError::OutsideKeyspace)
854        );
855    }
856}