Skip to main content

loonfs_api/
pagination.rs

1//! Pagination: page-size policy, typed page envelopes, and the opaque
2//! cursors each paginated endpoint round-trips.
3
4use crate::capability::{LIMIT_PAGINATION_DEFAULT, LIMIT_PAGINATION_MAX};
5use crate::{ChangeSeq, InodeId, NameKey, NamespaceId, RevisionNo};
6use serde::{Deserialize, Serialize};
7use std::collections::BTreeMap;
8use std::num::NonZeroU32;
9use thiserror::Error;
10
11/// Default page size for endpoints that can return unbounded result sets.
12pub const DEFAULT_PAGE_LIMIT: u32 = 1_000;
13/// Default maximum accepted page size.
14pub const DEFAULT_MAX_PAGE_LIMIT: u32 = 1_000;
15
16/// Wire cursor format version.
17pub const PAGE_CURSOR_VERSION: u8 = 1;
18
19/// A validated page size selected from a caller request and a policy.
20#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
21pub struct EffectiveLimit(NonZeroU32);
22
23impl EffectiveLimit {
24    /// Creates an effective limit from a non-zero value.
25    pub fn new(value: NonZeroU32) -> Self {
26        Self(value)
27    }
28
29    /// Returns the numeric page size.
30    pub fn get(self) -> u32 {
31        self.0.get()
32    }
33
34    /// Returns the page size as a `usize` for vector reservations and counters.
35    pub fn as_usize(self) -> usize {
36        self.0.get() as usize
37    }
38
39    /// Returns the number of items an engine should try to read to detect a next page.
40    pub fn limit_plus_one(self) -> usize {
41        self.as_usize().saturating_add(1)
42    }
43}
44
45/// Deployment or namespace policy for paginated endpoints.
46#[derive(Debug, Clone, Copy, PartialEq, Eq)]
47pub struct PaginationPolicy {
48    default_limit: NonZeroU32,
49    max_limit: NonZeroU32,
50}
51
52impl PaginationPolicy {
53    /// Creates a policy, requiring the default to be no larger than the max.
54    pub fn new(
55        default_limit: NonZeroU32,
56        max_limit: NonZeroU32,
57    ) -> Result<Self, PaginationPolicyError> {
58        if default_limit > max_limit {
59            return Err(PaginationPolicyError::DefaultExceedsMax {
60                default_limit: default_limit.get(),
61                max_limit: max_limit.get(),
62            });
63        }
64        Ok(Self {
65            default_limit,
66            max_limit,
67        })
68    }
69
70    /// Creates a policy from raw integers.
71    pub fn from_values(default_limit: u32, max_limit: u32) -> Result<Self, PaginationPolicyError> {
72        let default_limit =
73            NonZeroU32::new(default_limit).ok_or(PaginationPolicyError::ZeroDefaultLimit)?;
74        let max_limit = NonZeroU32::new(max_limit).ok_or(PaginationPolicyError::ZeroMaxLimit)?;
75        Self::new(default_limit, max_limit)
76    }
77
78    /// Returns the page size applied when callers omit `limit`.
79    pub fn default_limit(self) -> NonZeroU32 {
80        self.default_limit
81    }
82
83    /// Returns the largest accepted caller-supplied `limit`.
84    pub fn max_limit(self) -> NonZeroU32 {
85        self.max_limit
86    }
87
88    /// Resolves a caller-supplied limit into the enforced page size.
89    pub fn resolve_limit(self, requested: Option<u32>) -> Result<EffectiveLimit, LimitError> {
90        match requested {
91            None => Ok(EffectiveLimit(self.default_limit)),
92            Some(0) => Err(LimitError::Zero),
93            Some(value) if value > self.max_limit.get() => Err(LimitError::ExceedsMax {
94                requested: value,
95                max_limit: self.max_limit.get(),
96            }),
97            Some(value) => NonZeroU32::new(value)
98                .map(EffectiveLimit)
99                .ok_or(LimitError::Zero),
100        }
101    }
102
103    /// Returns the advisory capability-document limits for this policy.
104    pub fn capability_limits(self) -> BTreeMap<String, u64> {
105        BTreeMap::from([
106            (
107                LIMIT_PAGINATION_DEFAULT.to_owned(),
108                u64::from(self.default_limit.get()),
109            ),
110            (
111                LIMIT_PAGINATION_MAX.to_owned(),
112                u64::from(self.max_limit.get()),
113            ),
114        ])
115    }
116}
117
118impl Default for PaginationPolicy {
119    fn default() -> Self {
120        let default_limit = const { NonZeroU32::new(DEFAULT_PAGE_LIMIT).unwrap() };
121        let max_limit = const { NonZeroU32::new(DEFAULT_MAX_PAGE_LIMIT).unwrap() };
122        Self {
123            default_limit,
124            max_limit,
125        }
126    }
127}
128
129/// Invalid pagination policy configuration.
130#[derive(Debug, Clone, PartialEq, Eq, Error)]
131pub enum PaginationPolicyError {
132    /// The configured default limit was zero.
133    #[error("pagination default limit must be greater than zero")]
134    ZeroDefaultLimit,
135    /// The configured max limit was zero.
136    #[error("pagination max limit must be greater than zero")]
137    ZeroMaxLimit,
138    /// The configured default limit exceeded the configured max.
139    #[error("pagination default limit `{default_limit}` exceeds max limit `{max_limit}`")]
140    DefaultExceedsMax {
141        /// Default page size rejected by policy construction.
142        default_limit: u32,
143        /// Maximum page size the rejected default exceeded.
144        max_limit: u32,
145    },
146}
147
148/// Invalid caller-supplied page size.
149#[derive(Debug, Clone, PartialEq, Eq, Error)]
150pub enum LimitError {
151    /// The caller supplied `limit=0`.
152    #[error("limit must be greater than zero")]
153    Zero,
154    /// The caller supplied a limit larger than the active policy allows.
155    #[error("limit `{requested}` exceeds max limit `{max_limit}`")]
156    ExceedsMax {
157        /// Page size supplied by the caller.
158        requested: u32,
159        /// Largest page size allowed by the active policy.
160        max_limit: u32,
161    },
162}
163
164/// Typed request envelope for internal runtime/core page methods.
165///
166/// This is not a direct wire response type. HTTP handlers parse public query
167/// fields into this shape after validating `limit` and decoding `cursor`.
168#[derive(Debug, Clone, PartialEq, Eq)]
169pub struct PageRequest<C> {
170    /// Enforced page size.
171    pub limit: EffectiveLimit,
172    /// Optional decoded endpoint cursor.
173    pub cursor: Option<C>,
174}
175
176/// Typed result envelope for internal runtime/core page methods.
177///
178/// This is not a direct wire response type. HTTP handlers encode
179/// `next_cursor` into the public response envelope.
180#[derive(Debug, Clone, PartialEq, Eq)]
181pub struct Page<T, C> {
182    /// Returned items.
183    pub items: Vec<T>,
184    /// Cursor for the next page, if another page is available.
185    pub next_cursor: Option<C>,
186}
187
188/// Cursor for one directory listing position.
189///
190/// Directory pagination advances in canonical `name_key` order. The cursor
191/// is an ordering resume, not a snapshot pin: any head at or past `head_seq`
192/// serves the next page, resuming strictly after `last_name_key` — the same
193/// forward-only drift grep cursors tolerate.
194///
195/// The cursor intentionally contains only the minting head (`head_seq`),
196/// listed directory identity (`directory_inode_id`), and resume position
197/// (`last_name_key`). HTTP clients must pass the URL namespace and `path` on
198/// every page. Runtime/server code resolves that path at the current head
199/// and rejects the cursor unless it names `directory_inode_id`.
200#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
201pub struct DirectoryPageCursor {
202    /// Head sequence the issuing page was evaluated at.
203    pub head_seq: ChangeSeq,
204    /// Directory inode resolved at `head_seq`.
205    // The wire field is frozen as `dir_inode_id` in page cursor version 1.
206    #[serde(rename = "dir_inode_id")]
207    pub directory_inode_id: InodeId,
208    /// Last canonical name key returned to the client.
209    pub last_name_key: NameKey,
210}
211
212impl PageCursor for DirectoryPageCursor {
213    const KIND: &'static str = "directory";
214}
215
216/// Cursor for one file revision listing position.
217///
218/// Revision pagination advances in newest-first revision order for one file
219/// inode. Like directory and grep cursors, it is an ordering resume that
220/// tolerates forward head drift; it includes the minting head plus the last
221/// returned row's complete ordering identity so ties stay unambiguous.
222#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
223pub struct FileRevisionsPageCursor {
224    /// Head sequence the issuing page was evaluated at.
225    pub head_seq: ChangeSeq,
226    /// File inode whose revisions are being listed.
227    pub inode_id: InodeId,
228    /// Last revision number returned to the client.
229    pub last_revision_no: RevisionNo,
230    /// Namespace sequence that created the last returned revision.
231    pub last_committed_seq: ChangeSeq,
232    /// WAL delta index that created the last returned revision.
233    pub last_revision_delta_index: u32,
234}
235
236impl PageCursor for FileRevisionsPageCursor {
237    const KIND: &'static str = "file_revisions";
238}
239
240/// Cursor for one trash listing position.
241///
242/// Trash pagination advances oldest deletion first, in ascending
243/// `(deleted_at_seq, root_inode_id)` order — the order the derived
244/// active-deletion family is keyed in. Like every cursor, it is an ordering
245/// resume tolerating forward head drift: the next page evaluates at whatever
246/// head is loaded and continues strictly after the deletion generation named
247/// here.
248#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
249pub struct TrashPageCursor {
250    /// Head sequence the issuing page was evaluated at.
251    pub head_seq: ChangeSeq,
252    /// Commit sequence of the deletion the previous page ended on.
253    pub last_deleted_at_seq: ChangeSeq,
254    /// Deleted root inode the previous page ended on.
255    pub last_root_inode_id: InodeId,
256}
257
258impl PageCursor for TrashPageCursor {
259    const KIND: &'static str = "trash";
260}
261
262/// Cursor for one content-search (grep) snapshot.
263///
264/// Matches advance in ascending `(inode_id, byte_offset)` order: candidate
265/// files by durable inode identity, match positions within a file by byte
266/// offset. The cursor resumes strictly after the last returned match.
267#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
268pub struct GrepPageCursor {
269    /// Sequence the issuing page was evaluated at.
270    pub head_seq: ChangeSeq,
271    /// Inode of the last candidate the issuing page finished scanning.
272    pub last_inode_id: InodeId,
273    /// Byte offset of the last returned match within that file, or
274    /// `u64::MAX` when the file was fully scanned (budget stops and
275    /// matchless candidates resume at the next inode).
276    pub last_byte_offset: u64,
277    /// Fingerprint of the request (pattern, flags, scope) that issued the
278    /// cursor; a cursor replayed under a different request is rejected
279    /// instead of silently skipping results.
280    pub fingerprint: u64,
281}
282
283impl PageCursor for GrepPageCursor {
284    const KIND: &'static str = "grep";
285}
286
287/// One paginated endpoint's cursor.
288///
289/// Cursors are opaque to clients: hex-encoded JSON carrying the endpoint's
290/// [`KIND`](Self::KIND) and the format version, so a cursor replayed against
291/// the wrong endpoint or an older build is rejected rather than misread.
292pub trait PageCursor: Serialize + serde::de::DeserializeOwned {
293    /// Frozen endpoint discriminator written into the encoded cursor.
294    const KIND: &'static str;
295}
296
297#[derive(Serialize, Deserialize)]
298struct CursorEnvelope<C> {
299    // The wire field is frozen as `v` in page cursor version 1.
300    #[serde(rename = "v")]
301    version: u8,
302    kind: String,
303    #[serde(flatten)]
304    cursor: C,
305}
306
307/// Encodes a cursor as the opaque string clients round-trip.
308pub fn encode_cursor<C: PageCursor>(cursor: &C) -> Result<String, PageCursorError> {
309    let bytes = serde_json::to_vec(&CursorEnvelope {
310        version: PAGE_CURSOR_VERSION,
311        kind: C::KIND.to_owned(),
312        cursor,
313    })
314    .map_err(|error| PageCursorError::InvalidJson(error.to_string()))?;
315    Ok(crate::hex::hex_encode_bytes(&bytes))
316}
317
318/// Version and endpoint, read before the body so a cursor from another
319/// endpoint reports `WrongKind` rather than a missing-field decode error.
320#[derive(Deserialize)]
321struct CursorHeader {
322    #[serde(rename = "v")]
323    version: u8,
324    kind: String,
325}
326
327/// Decodes a cursor issued by [`encode_cursor`] for the same endpoint.
328pub fn decode_cursor<C: PageCursor>(value: &str) -> Result<C, PageCursorError> {
329    let bytes =
330        crate::hex::hex_decode_bytes(value).map_err(|_| PageCursorError::InvalidEncoding)?;
331    let header: CursorHeader = serde_json::from_slice(&bytes)
332        .map_err(|error| PageCursorError::InvalidJson(error.to_string()))?;
333    if header.version != PAGE_CURSOR_VERSION {
334        return Err(PageCursorError::UnsupportedVersion {
335            expected: PAGE_CURSOR_VERSION,
336            actual: header.version,
337        });
338    }
339    if header.kind != C::KIND {
340        return Err(PageCursorError::WrongKind {
341            expected: C::KIND,
342            actual: header.kind,
343        });
344    }
345    let envelope: CursorEnvelope<C> = serde_json::from_slice(&bytes)
346        .map_err(|error| PageCursorError::InvalidJson(error.to_string()))?;
347    Ok(envelope.cursor)
348}
349
350/// A cursor that resumes an enumeration of one namespace's own keyspace.
351///
352/// Maintenance passes walk keys rather than rows, and their cursors are
353/// enumeration shortcuts and nothing else: a pass re-reads whatever
354/// authorizes the work it does, whatever position it resumed from, so a
355/// cursor that is lost or refused costs a repeated walk and never a wrong
356/// decision. What the binding buys is that a token minted for another
357/// namespace, another job, or another key family is refused instead of
358/// quietly skipping the keys between here and wherever it points.
359pub trait NamespaceCursor: PageCursor {
360    /// Namespace whose keyspace this cursor walks.
361    fn namespace_id(&self) -> &NamespaceId;
362
363    /// Key the enumeration stopped at, or `None` at the start.
364    fn last_key(&self) -> Option<&str>;
365
366    /// Prefix every key this cursor may name lies under.
367    fn key_prefix(&self) -> String;
368}
369
370/// Decodes a cursor issued for `expected_namespace_id`'s own keyspace.
371pub fn decode_namespace_cursor<C: NamespaceCursor>(
372    token: &str,
373    expected_namespace_id: &NamespaceId,
374) -> Result<C, NamespaceCursorError> {
375    let cursor: C = decode_cursor(token)?;
376    if cursor.namespace_id() != expected_namespace_id {
377        return Err(NamespaceCursorError::ForeignNamespace);
378    }
379    let prefix = cursor.key_prefix();
380    if cursor
381        .last_key()
382        .is_some_and(|key| !key.starts_with(&prefix))
383    {
384        return Err(NamespaceCursorError::OutsideKeyspace);
385    }
386    Ok(cursor)
387}
388
389/// Why a namespace-bound cursor cannot resume the enumeration replaying it.
390#[derive(Debug, Clone, PartialEq, Eq, Error)]
391pub enum NamespaceCursorError {
392    /// Not a cursor this enumeration issued: unreadable, or from another
393    /// endpoint, job, or cursor version.
394    #[error(transparent)]
395    Malformed(#[from] PageCursorError),
396    /// A cursor for a different namespace than the one replaying it.
397    #[error("cursor belongs to a different namespace")]
398    ForeignNamespace,
399    /// A cursor naming a key outside the prefix its enumeration walks.
400    #[error("cursor names a key outside the enumeration it resumes")]
401    OutsideKeyspace,
402}
403
404/// Invalid opaque page cursor.
405#[derive(Debug, Clone, PartialEq, Eq, Error)]
406pub enum PageCursorError {
407    /// The cursor was not hex-encoded JSON.
408    #[error("invalid page cursor encoding")]
409    InvalidEncoding,
410    /// The cursor JSON did not match a supported cursor shape.
411    #[error("invalid page cursor JSON: {0}")]
412    InvalidJson(String),
413    /// The cursor was valid, but for a different paginated endpoint.
414    #[error("page cursor kind `{actual}` cannot be used as `{expected}` cursor")]
415    WrongKind {
416        /// Cursor kind accepted by the endpoint doing the decoding.
417        expected: &'static str,
418        /// Cursor kind recovered from the caller's opaque token.
419        actual: String,
420    },
421    /// The cursor format version is not supported by this build.
422    #[error("unsupported page cursor version `{actual}`; expected `{expected}`")]
423    UnsupportedVersion {
424        /// Cursor format version this build can decode.
425        expected: u8,
426        /// Version embedded in the caller's opaque token.
427        actual: u8,
428    },
429}
430
431#[cfg(test)]
432mod tests {
433    use super::*;
434
435    #[test]
436    fn default_policy_resolves_omitted_limit_to_default() {
437        let policy = PaginationPolicy::default();
438        let limit = policy.resolve_limit(None).expect("default limit");
439
440        assert_eq!(limit.get(), DEFAULT_PAGE_LIMIT);
441        assert_eq!(limit.limit_plus_one(), 1_001);
442    }
443
444    #[test]
445    fn policy_rejects_invalid_limits() {
446        let policy = PaginationPolicy::default();
447
448        assert_eq!(policy.resolve_limit(Some(0)), Err(LimitError::Zero));
449        assert_eq!(
450            policy.resolve_limit(Some(DEFAULT_MAX_PAGE_LIMIT + 1)),
451            Err(LimitError::ExceedsMax {
452                requested: DEFAULT_MAX_PAGE_LIMIT + 1,
453                max_limit: DEFAULT_MAX_PAGE_LIMIT,
454            })
455        );
456    }
457
458    #[test]
459    fn policy_rejects_default_above_max() {
460        assert_eq!(
461            PaginationPolicy::from_values(10, 5),
462            Err(PaginationPolicyError::DefaultExceedsMax {
463                default_limit: 10,
464                max_limit: 5,
465            })
466        );
467    }
468
469    #[test]
470    fn policy_exports_capability_limits() {
471        let limits = PaginationPolicy::default().capability_limits();
472
473        assert_eq!(
474            limits.get(LIMIT_PAGINATION_DEFAULT),
475            Some(&u64::from(DEFAULT_PAGE_LIMIT))
476        );
477        assert_eq!(
478            limits.get(LIMIT_PAGINATION_MAX),
479            Some(&u64::from(DEFAULT_MAX_PAGE_LIMIT))
480        );
481    }
482
483    #[test]
484    fn directory_cursor_round_trips() {
485        let cursor = DirectoryPageCursor {
486            head_seq: ChangeSeq(11),
487            directory_inode_id: InodeId(7),
488            last_name_key: NameKey::parse("plan.md").expect("name key"),
489        };
490
491        let encoded = encode_cursor(&cursor).expect("encode cursor");
492        let decoded: DirectoryPageCursor = decode_cursor(&encoded).expect("decode cursor");
493
494        assert_eq!(decoded, cursor);
495    }
496
497    #[test]
498    fn file_revisions_cursor_round_trips() {
499        let cursor = FileRevisionsPageCursor {
500            head_seq: ChangeSeq(11),
501            inode_id: InodeId(7),
502            last_revision_no: RevisionNo(5),
503            last_committed_seq: ChangeSeq(10),
504            last_revision_delta_index: 3,
505        };
506
507        let encoded = encode_cursor(&cursor).expect("encode cursor");
508        let decoded: FileRevisionsPageCursor = decode_cursor(&encoded).expect("decode cursor");
509
510        assert_eq!(decoded, cursor);
511    }
512
513    #[test]
514    fn cursor_kind_must_match_decoder() {
515        let cursor = FileRevisionsPageCursor {
516            head_seq: ChangeSeq(11),
517            inode_id: InodeId(7),
518            last_revision_no: RevisionNo(5),
519            last_committed_seq: ChangeSeq(10),
520            last_revision_delta_index: 3,
521        };
522        let encoded = encode_cursor(&cursor).expect("encode cursor");
523
524        assert_eq!(
525            decode_cursor::<DirectoryPageCursor>(&encoded),
526            Err(PageCursorError::WrongKind {
527                expected: "directory",
528                actual: "file_revisions".to_owned(),
529            })
530        );
531    }
532
533    #[test]
534    fn malformed_cursor_is_invalid_encoding() {
535        assert_eq!(
536            decode_cursor::<DirectoryPageCursor>("not-hex"),
537            Err(PageCursorError::InvalidEncoding)
538        );
539    }
540
541    #[test]
542    fn unsupported_cursor_version_is_rejected() {
543        let bytes = serde_json::to_vec(&CursorEnvelope {
544            version: PAGE_CURSOR_VERSION + 1,
545            kind: DirectoryPageCursor::KIND.to_owned(),
546            cursor: DirectoryPageCursor {
547                head_seq: ChangeSeq(11),
548                directory_inode_id: InodeId(7),
549                last_name_key: NameKey::parse("plan.md").expect("name key"),
550            },
551        })
552        .expect("encode cursor");
553        let encoded = crate::hex::hex_encode_bytes(&bytes);
554
555        assert_eq!(
556            decode_cursor::<DirectoryPageCursor>(&encoded),
557            Err(PageCursorError::UnsupportedVersion {
558                expected: PAGE_CURSOR_VERSION,
559                actual: PAGE_CURSOR_VERSION + 1,
560            })
561        );
562    }
563}