Skip to main content

cherenkov_model_data/discovery/dispatch/
cursor.rs

1//! Cursor envelopes bind provider positions to a store and an unchanged request.
2
3use super::StoreId;
4use crate::discovery::{Filter, SearchQuery};
5use anyhow::{Context, Result, ensure};
6use serde::{Deserialize, Serialize};
7
8/// Request fields that must stay fixed while following a continuation.
9/// Only serialized keys are compared; decoding numeric operands can round floats.
10#[derive(Serialize)]
11#[serde(tag = "operation", rename_all = "snake_case")]
12pub(super) enum RequestScope {
13    /// Search predicates and page size, excluding the changing cursor.
14    Search {
15        text: Option<String>,
16        filters: Vec<Filter>,
17        limit: usize,
18    },
19    /// Inventory traversal has no search predicates.
20    Enumerate { limit: usize },
21}
22
23/// Transport envelope for a provider-native cursor, not an authentication token.
24#[derive(Serialize, Deserialize)]
25#[serde(deny_unknown_fields)]
26struct Cursor {
27    store: StoreId,
28    /// Preserve the serialized request exactly instead of reparsing its numbers.
29    request_key: String,
30    position: String,
31}
32
33impl RequestScope {
34    /// Capture the search identity independently of its current page position.
35    pub(super) fn search(query: &SearchQuery) -> Self {
36        Self::Search {
37            text: query.text.clone(),
38            filters: query.filters.clone(),
39            limit: query.page.limit,
40        }
41    }
42
43    /// Return the native position after checking the store and serialized request.
44    pub(super) fn unwrap_cursor(
45        &self,
46        store: &StoreId,
47        cursor: Option<&str>,
48    ) -> Result<Option<String>> {
49        let Some(cursor) = cursor else {
50            return Ok(None);
51        };
52        let cursor: Cursor = serde_json::from_str(cursor).context("invalid discovery cursor")?;
53
54        ensure!(
55            cursor.store == *store,
56            "discovery cursor belongs to another store"
57        );
58        ensure!(
59            cursor.request_key == self.key()?,
60            "discovery cursor belongs to another request"
61        );
62
63        Ok(Some(cursor.position))
64    }
65
66    /// Bind a continuation to this request; preserve `None` at the end of a walk.
67    pub(super) fn wrap_cursor(
68        &self,
69        store: &StoreId,
70        position: Option<String>,
71    ) -> Result<Option<String>> {
72        position
73            .map(|position| {
74                serde_json::to_string(&Cursor {
75                    store: store.clone(),
76                    request_key: self.key()?,
77                    position,
78                })
79                .context("encoding discovery cursor")
80            })
81            .transpose()
82    }
83
84    /// Encode a deterministic comparison key without a numeric decode round trip.
85    /// Filter order remains significant, matching the unchanged-request contract.
86    fn key(&self) -> Result<String> {
87        serde_json::to_string(self).context("encoding discovery request key")
88    }
89}