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/// Wire cursor format version.
21pub const PAGE_CURSOR_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 `path` on
170/// every page. Runtime/server code resolves that path at the current head
171/// and rejects the cursor unless it names `directory_inode_id`.
172#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
173pub struct DirectoryPageCursor {
174    /// Head sequence the issuing page was evaluated at.
175    pub head_seq: ChangeSeq,
176    /// Directory inode resolved at `head_seq`.
177    // The wire field is frozen as `dir_inode_id` in page cursor version 1.
178    #[serde(rename = "dir_inode_id")]
179    pub directory_inode_id: InodeId,
180    /// Last canonical name key returned to the client.
181    pub last_name_key: NameKey,
182}
183
184impl PageCursor for DirectoryPageCursor {
185    const KIND: &'static str = "directory";
186}
187
188/// Cursor for one file revision listing position.
189///
190/// Revision pagination advances in newest-first revision order for one file
191/// inode. Like directory and grep cursors, it is an ordering resume that
192/// tolerates forward head drift; it includes the minting head plus the last
193/// returned row's complete ordering identity so ties stay unambiguous.
194#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
195pub struct FileRevisionsPageCursor {
196    /// Head sequence the issuing page was evaluated at.
197    pub head_seq: ChangeSeq,
198    /// File inode whose revisions are being listed.
199    pub inode_id: InodeId,
200    /// Last revision number returned to the client.
201    pub last_revision_no: RevisionNo,
202    /// Namespace sequence that created the last returned revision.
203    pub last_committed_seq: ChangeSeq,
204    /// WAL delta index that created the last returned revision.
205    pub last_revision_delta_index: u32,
206}
207
208impl PageCursor for FileRevisionsPageCursor {
209    const KIND: &'static str = "file_revisions";
210}
211
212/// Cursor for one trash listing position.
213///
214/// Trash pagination advances oldest deletion first, in ascending
215/// `(deleted_at_seq, root_inode_id)` order — the order the derived
216/// active-deletion family is keyed in. Like every cursor, it is an ordering
217/// resume tolerating forward head drift: the next page evaluates at whatever
218/// head is loaded and continues strictly after the deletion generation named
219/// here.
220#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
221pub struct TrashPageCursor {
222    /// Head sequence the issuing page was evaluated at.
223    pub head_seq: ChangeSeq,
224    /// Commit sequence of the deletion the previous page ended on.
225    pub last_deleted_at_seq: ChangeSeq,
226    /// Deleted root inode the previous page ended on.
227    pub last_root_inode_id: InodeId,
228}
229
230impl PageCursor for TrashPageCursor {
231    const KIND: &'static str = "trash";
232}
233
234/// Cursor for one content-search (grep) snapshot.
235///
236/// Matches advance in ascending `(inode_id, byte_offset)` order: candidate
237/// files by durable inode identity, match positions within a file by byte
238/// offset. The cursor resumes strictly after the last returned match.
239#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
240pub struct GrepPageCursor {
241    /// Sequence the issuing page was evaluated at.
242    pub head_seq: ChangeSeq,
243    /// Inode of the last candidate the issuing page finished scanning.
244    pub last_inode_id: InodeId,
245    /// Byte offset of the last returned match within that file, or
246    /// `u64::MAX` when the file was fully scanned (budget stops and
247    /// matchless candidates resume at the next inode).
248    pub last_byte_offset: u64,
249    /// Fingerprint of the request (pattern, flags, scope) that issued the
250    /// cursor; a cursor replayed under a different request is rejected
251    /// instead of silently skipping results.
252    pub fingerprint: u64,
253}
254
255impl PageCursor for GrepPageCursor {
256    const KIND: &'static str = "grep";
257}
258
259/// One paginated endpoint's cursor.
260///
261/// Cursors are opaque to clients: hex-encoded JSON carrying the endpoint's
262/// [`KIND`](Self::KIND) and the format version, so a cursor replayed against
263/// the wrong endpoint or an older build is rejected rather than misread.
264pub trait PageCursor: Serialize + serde::de::DeserializeOwned {
265    /// Frozen endpoint discriminator written into the encoded cursor.
266    const KIND: &'static str;
267}
268
269#[derive(Serialize, Deserialize)]
270struct CursorEnvelope<C> {
271    // The wire field is frozen as `v` in page cursor version 1.
272    #[serde(rename = "v")]
273    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        version: PAGE_CURSOR_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    #[serde(rename = "v")]
295    version: u8,
296    kind: String,
297}
298
299/// Decodes a cursor issued by [`encode_cursor`] for the same endpoint.
300pub fn decode_cursor<C: PageCursor>(value: &str) -> Result<C, PageCursorError> {
301    let bytes =
302        crate::hex::hex_decode_bytes(value).map_err(|_| PageCursorError::InvalidEncoding)?;
303    let header: CursorHeader = serde_json::from_slice(&bytes)
304        .map_err(|error| PageCursorError::InvalidJson(error.to_string()))?;
305    if header.version != PAGE_CURSOR_VERSION {
306        return Err(PageCursorError::UnsupportedVersion {
307            expected: PAGE_CURSOR_VERSION,
308            actual: header.version,
309        });
310    }
311    if header.kind != C::KIND {
312        return Err(PageCursorError::WrongKind {
313            expected: C::KIND,
314            actual: header.kind,
315        });
316    }
317    let envelope: CursorEnvelope<C> = serde_json::from_slice(&bytes)
318        .map_err(|error| PageCursorError::InvalidJson(error.to_string()))?;
319    Ok(envelope.cursor)
320}
321
322/// A cursor that resumes an enumeration of one namespace's own keyspace.
323///
324/// Maintenance passes walk keys rather than rows, and their cursors are
325/// enumeration shortcuts and nothing else: a pass re-reads whatever
326/// authorizes the work it does, whatever position it resumed from, so a
327/// cursor that is lost or refused costs a repeated walk and never a wrong
328/// decision. What the binding buys is that a token minted for another
329/// namespace, another job, or another key family is refused instead of
330/// quietly skipping the keys between here and wherever it points.
331pub trait NamespaceCursor: PageCursor {
332    /// Namespace whose keyspace this cursor walks.
333    fn namespace_id(&self) -> &NamespaceId;
334
335    /// Key the enumeration stopped at, or `None` at the start.
336    fn last_key(&self) -> Option<&str>;
337
338    /// Prefix every key this cursor may name lies under.
339    fn key_prefix(&self) -> String;
340}
341
342/// Decodes a cursor issued for `expected_namespace_id`'s own keyspace.
343pub fn decode_namespace_cursor<C: NamespaceCursor>(
344    token: &str,
345    expected_namespace_id: &NamespaceId,
346) -> Result<C, NamespaceCursorError> {
347    let cursor: C = decode_cursor(token)?;
348    if cursor.namespace_id() != expected_namespace_id {
349        return Err(NamespaceCursorError::ForeignNamespace);
350    }
351    let prefix = cursor.key_prefix();
352    if cursor
353        .last_key()
354        .is_some_and(|key| !key.starts_with(&prefix))
355    {
356        return Err(NamespaceCursorError::OutsideKeyspace);
357    }
358    Ok(cursor)
359}
360
361/// Why a namespace-bound cursor cannot resume the enumeration replaying it.
362#[derive(Debug, Clone, PartialEq, Eq, Error)]
363#[non_exhaustive]
364pub enum NamespaceCursorError {
365    /// Not a cursor this enumeration issued: unreadable, or from another
366    /// endpoint, job, or cursor version.
367    #[error(transparent)]
368    Malformed(#[from] PageCursorError),
369    /// A cursor for a different namespace than the one replaying it.
370    #[error("cursor belongs to a different namespace")]
371    ForeignNamespace,
372    /// A cursor naming a key outside the prefix its enumeration walks.
373    #[error("cursor names a key outside the enumeration it resumes")]
374    OutsideKeyspace,
375}
376
377/// Invalid opaque page cursor.
378#[derive(Debug, Clone, PartialEq, Eq, Error)]
379#[non_exhaustive]
380pub enum PageCursorError {
381    /// The cursor was not hex-encoded JSON.
382    #[error("invalid page cursor encoding")]
383    InvalidEncoding,
384    /// The cursor JSON did not match a supported cursor shape.
385    #[error("invalid page cursor JSON: {0}")]
386    InvalidJson(String),
387    /// The cursor was valid, but for a different paginated endpoint.
388    #[error("page cursor kind `{actual}` cannot be used as `{expected}` cursor")]
389    WrongKind {
390        /// Cursor kind accepted by the endpoint doing the decoding.
391        expected: &'static str,
392        /// Cursor kind recovered from the caller's opaque token.
393        actual: String,
394    },
395    /// The cursor format version is not supported by this build.
396    #[error("unsupported page cursor version `{actual}`; expected `{expected}`")]
397    UnsupportedVersion {
398        /// Cursor format version this build can decode.
399        expected: u8,
400        /// Version embedded in the caller's opaque token.
401        actual: u8,
402    },
403}
404
405#[cfg(test)]
406mod tests {
407    use super::*;
408
409    #[test]
410    fn default_policy_resolves_omitted_limit_to_default() {
411        let policy = PaginationPolicy::default();
412        let limit = policy.resolve_limit(None).expect("default limit");
413
414        assert_eq!(limit.get(), DEFAULT_PAGE_LIMIT);
415        assert_eq!(limit.limit_plus_one(), 1_001);
416    }
417
418    #[test]
419    fn policy_rejects_invalid_limits() {
420        let policy = PaginationPolicy::default();
421
422        assert_eq!(policy.resolve_limit(Some(0)), Err(LimitError::Zero));
423        assert_eq!(
424            policy.resolve_limit(Some(DEFAULT_MAX_PAGE_LIMIT + 1)),
425            Err(LimitError::ExceedsMax {
426                requested: DEFAULT_MAX_PAGE_LIMIT + 1,
427                max_limit: DEFAULT_MAX_PAGE_LIMIT,
428            })
429        );
430    }
431
432    #[test]
433    fn policy_exports_capability_limits() {
434        let limits = PaginationPolicy::default().capability_limits();
435
436        assert_eq!(
437            limits.get(LIMIT_PAGINATION_DEFAULT),
438            Some(&u64::from(DEFAULT_PAGE_LIMIT))
439        );
440        assert_eq!(
441            limits.get(LIMIT_PAGINATION_MAX),
442            Some(&u64::from(DEFAULT_MAX_PAGE_LIMIT))
443        );
444    }
445
446    #[test]
447    fn directory_cursor_round_trips() {
448        let cursor = DirectoryPageCursor {
449            head_seq: ChangeSeq(11),
450            directory_inode_id: InodeId(7),
451            last_name_key: NameKey::parse("plan.md").expect("name key"),
452        };
453
454        let encoded = encode_cursor(&cursor).expect("encode cursor");
455        let decoded: DirectoryPageCursor = decode_cursor(&encoded).expect("decode cursor");
456
457        assert_eq!(decoded, cursor);
458    }
459
460    #[test]
461    fn file_revisions_cursor_round_trips() {
462        let cursor = FileRevisionsPageCursor {
463            head_seq: ChangeSeq(11),
464            inode_id: InodeId(7),
465            last_revision_no: RevisionNo(5),
466            last_committed_seq: ChangeSeq(10),
467            last_revision_delta_index: 3,
468        };
469
470        let encoded = encode_cursor(&cursor).expect("encode cursor");
471        let decoded: FileRevisionsPageCursor = decode_cursor(&encoded).expect("decode cursor");
472
473        assert_eq!(decoded, cursor);
474    }
475
476    #[test]
477    fn trash_cursor_round_trips() {
478        let cursor = TrashPageCursor {
479            head_seq: ChangeSeq(11),
480            last_deleted_at_seq: ChangeSeq(10),
481            last_root_inode_id: InodeId(7),
482        };
483
484        let encoded = encode_cursor(&cursor).expect("encode cursor");
485        let decoded: TrashPageCursor = decode_cursor(&encoded).expect("decode cursor");
486
487        assert_eq!(decoded, cursor);
488    }
489
490    #[test]
491    fn grep_cursor_round_trips() {
492        let cursor = GrepPageCursor {
493            head_seq: ChangeSeq(11),
494            last_inode_id: InodeId(7),
495            last_byte_offset: 13,
496            fingerprint: 17,
497        };
498
499        let encoded = encode_cursor(&cursor).expect("encode cursor");
500        let decoded: GrepPageCursor = decode_cursor(&encoded).expect("decode cursor");
501
502        assert_eq!(decoded, cursor);
503    }
504
505    #[test]
506    fn cursor_kind_must_match_decoder() {
507        let cursor = FileRevisionsPageCursor {
508            head_seq: ChangeSeq(11),
509            inode_id: InodeId(7),
510            last_revision_no: RevisionNo(5),
511            last_committed_seq: ChangeSeq(10),
512            last_revision_delta_index: 3,
513        };
514        let encoded = encode_cursor(&cursor).expect("encode cursor");
515
516        assert_eq!(
517            decode_cursor::<DirectoryPageCursor>(&encoded),
518            Err(PageCursorError::WrongKind {
519                expected: "directory",
520                actual: "file_revisions".to_owned(),
521            })
522        );
523    }
524
525    #[test]
526    fn malformed_cursor_is_invalid_encoding() {
527        assert_eq!(
528            decode_cursor::<DirectoryPageCursor>("not-hex"),
529            Err(PageCursorError::InvalidEncoding)
530        );
531    }
532
533    #[test]
534    fn unsupported_cursor_version_is_rejected() {
535        let bytes = serde_json::to_vec(&CursorEnvelope {
536            version: PAGE_CURSOR_VERSION + 1,
537            kind: DirectoryPageCursor::KIND.to_owned(),
538            cursor: DirectoryPageCursor {
539                head_seq: ChangeSeq(11),
540                directory_inode_id: InodeId(7),
541                last_name_key: NameKey::parse("plan.md").expect("name key"),
542            },
543        })
544        .expect("encode cursor");
545        let encoded = crate::hex::hex_encode_bytes(&bytes);
546
547        assert_eq!(
548            decode_cursor::<DirectoryPageCursor>(&encoded),
549            Err(PageCursorError::UnsupportedVersion {
550                expected: PAGE_CURSOR_VERSION,
551                actual: PAGE_CURSOR_VERSION + 1,
552            })
553        );
554    }
555
556    #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
557    struct TestNamespaceCursor {
558        namespace_id: NamespaceId,
559        last_key: String,
560    }
561
562    impl PageCursor for TestNamespaceCursor {
563        const KIND: &'static str = "test_namespace";
564    }
565
566    impl NamespaceCursor for TestNamespaceCursor {
567        fn namespace_id(&self) -> &NamespaceId {
568            &self.namespace_id
569        }
570
571        fn last_key(&self) -> Option<&str> {
572            Some(&self.last_key)
573        }
574
575        fn key_prefix(&self) -> String {
576            format!("namespaces/{}/items/", self.namespace_id)
577        }
578    }
579
580    #[test]
581    fn namespace_cursor_accepts_its_namespace_and_keyspace() {
582        let namespace_id = NamespaceId::parse("demo").expect("namespace id");
583        let cursor = TestNamespaceCursor {
584            namespace_id: namespace_id.clone(),
585            last_key: "namespaces/demo/items/item-42".to_owned(),
586        };
587        let encoded = encode_cursor(&cursor).expect("encode cursor");
588
589        assert_eq!(
590            decode_namespace_cursor::<TestNamespaceCursor>(&encoded, &namespace_id)
591                .expect("decode namespace cursor"),
592            cursor
593        );
594    }
595
596    #[test]
597    fn namespace_cursor_rejects_a_different_namespace() {
598        let cursor = TestNamespaceCursor {
599            namespace_id: NamespaceId::parse("demo").expect("namespace id"),
600            last_key: "namespaces/demo/items/item-42".to_owned(),
601        };
602        let encoded = encode_cursor(&cursor).expect("encode cursor");
603
604        assert_eq!(
605            decode_namespace_cursor::<TestNamespaceCursor>(
606                &encoded,
607                &NamespaceId::parse("other").expect("other namespace id")
608            ),
609            Err(NamespaceCursorError::ForeignNamespace)
610        );
611    }
612
613    #[test]
614    fn namespace_cursor_rejects_a_key_outside_its_keyspace() {
615        let namespace_id = NamespaceId::parse("demo").expect("namespace id");
616        let cursor = TestNamespaceCursor {
617            namespace_id: namespace_id.clone(),
618            last_key: "namespaces/demo/checkpoints/checkpoint-42".to_owned(),
619        };
620        let encoded = encode_cursor(&cursor).expect("encode cursor");
621
622        assert_eq!(
623            decode_namespace_cursor::<TestNamespaceCursor>(&encoded, &namespace_id),
624            Err(NamespaceCursorError::OutsideKeyspace)
625        );
626    }
627}