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