Skip to main content

cherenkov_model_data/discovery/
dispatch.rs

1//! Checked adapter calls, store-qualified results, and scoped continuations.
2
3use super::{
4    DiscoveryCapabilities, ModelCandidate, PageRequest, SearchPage, SearchQuery, StoreDiscovery,
5};
6use anyhow::{Context, Result, ensure};
7use serde::{Deserialize, Serialize};
8
9mod cursor;
10use cursor::RequestScope;
11
12/// Stable identity assigned by the store registry, independent of its display name.
13/// IDs must be unique and persist across restarts and enable/disable operations.
14/// A replacement store with a different source namespace needs a new ID so saved
15/// locators and cursors cannot silently resolve against another source.
16#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
17#[serde(transparent)]
18pub struct StoreId(pub String);
19
20/// Candidate with the store needed to resolve its provider-defined reference.
21#[derive(Debug, Clone, Serialize, Deserialize)]
22pub struct StoredCandidate {
23    /// Registry identity of the originating store.
24    pub store: StoreId,
25    /// Metadata and locator supplied by that store.
26    #[serde(flatten)]
27    pub candidate: ModelCandidate,
28}
29
30/// Search request checked by [`Discovery`], with a provider-native cursor.
31/// Validation covers the advertised schema, not the native cursor's contents or
32/// whether returned candidates satisfy the filters. The adapter checks those.
33pub struct ValidatedSearch<'a>(&'a SearchQuery);
34
35impl ValidatedSearch<'_> {
36    /// Borrow the checked query. Adapters still implement predicate semantics.
37    pub fn query(&self) -> &SearchQuery {
38        self.0
39    }
40}
41
42/// Enumeration request checked by [`Discovery`], with a provider-native cursor.
43/// The adapter must still reject invalid or expired native positions.
44pub struct ValidatedPage<'a>(&'a PageRequest);
45
46impl ValidatedPage<'_> {
47    /// Borrow the checked page request.
48    pub fn page(&self) -> &PageRequest {
49        self.0
50    }
51}
52
53/// Validates discovery requests and attaches store identity to results.
54/// Constructing a dispatcher performs no I/O and does not enumerate the store.
55/// Search and enumeration are synchronous and may block on the adapter's I/O.
56/// Cursor envelopes detect accidental reuse; they do not authenticate input.
57pub struct Discovery<'a> {
58    store: StoreId,
59    adapter: &'a dyn StoreDiscovery,
60}
61
62impl<'a> Discovery<'a> {
63    /// Bind an adapter to its stable registry identity.
64    /// The caller supplies a unique, persistent ID; this does not register a store.
65    pub fn new(store: StoreId, adapter: &'a dyn StoreDiscovery) -> Self {
66        Self { store, adapter }
67    }
68
69    /// Describe the adapter's search fields and enumeration limits.
70    pub fn capabilities(&self) -> DiscoveryCapabilities {
71        self.adapter.capabilities()
72    }
73
74    /// Validate and search one page, preserving the source of every result.
75    pub fn search(&self, query: &SearchQuery) -> Result<SearchPage<StoredCandidate>> {
76        let capabilities = self.capabilities();
77        let schema = capabilities
78            .search
79            .context("this store does not support search")?;
80
81        schema.validate(query)?;
82
83        let scope = RequestScope::search(query);
84        let mut native = query.clone();
85
86        native.page.cursor = scope.unwrap_cursor(&self.store, query.page.cursor.as_deref())?;
87
88        let page = self.adapter.search(ValidatedSearch(&native))?;
89
90        self.finish(page, &scope, &native.page)
91    }
92
93    /// Validate and enumerate one page; search support alone does not allow this.
94    pub fn enumerate(&self, request: &PageRequest) -> Result<SearchPage<StoredCandidate>> {
95        let maximum = self
96            .capabilities()
97            .enumeration_max_page_size
98            .context("this store does not support enumeration")?;
99
100        request.validate(maximum)?;
101
102        let scope = RequestScope::Enumerate {
103            limit: request.limit,
104        };
105        let mut native = request.clone();
106
107        native.cursor = scope.unwrap_cursor(&self.store, request.cursor.as_deref())?;
108
109        let page = self.adapter.enumerate(ValidatedPage(&native))?;
110
111        self.finish(page, &scope, &native)
112    }
113
114    /// Enforce response bounds, reject a stalled cursor, and preserve result origin.
115    /// Empty result pages are valid when filtering leaves a continuation to follow.
116    fn finish(
117        &self,
118        page: SearchPage,
119        scope: &RequestScope,
120        request: &PageRequest,
121    ) -> Result<SearchPage<StoredCandidate>> {
122        ensure!(
123            page.items.len() <= request.limit,
124            "store exceeded the requested page limit"
125        );
126        ensure!(
127            page.next_cursor.is_none() || page.next_cursor != request.cursor,
128            "store returned a continuation cursor that did not advance"
129        );
130
131        Ok(SearchPage {
132            items: page
133                .items
134                .into_iter()
135                .map(|candidate| StoredCandidate {
136                    store: self.store.clone(),
137                    candidate,
138                })
139                .collect(),
140            next_cursor: scope.wrap_cursor(&self.store, page.next_cursor)?,
141            total: page.total,
142            gaps: page.gaps,
143        })
144    }
145}