Skip to main content

ahri_tre_app/
browser_dictionary_discovery.rs

1//! Browser-safe, datastore-scoped dictionary discovery workflows.
2//!
3//! The interface accepts only resolved metadata capabilities. Dataset values,
4//! Datafile content, storage topology, and credentials cannot cross this seam.
5
6use crate::{
7    BrowserDictionaryVariableCatalogueRecord, BrowserDictionaryVariableDiscoverySourceRecord,
8    browser_dictionary_variable_search_response,
9};
10use ahri_tre_protocol::{
11    ProtocolVersion, PublicUuid,
12    dictionary::{BrowserVariableSearchResponse, SearchVariablesRequest},
13    dictionary_discovery::{
14        BrowserDictionaryDiscoveryDatasetRef, BrowserDictionaryDiscoveryEmptyState,
15        BrowserDictionaryDiscoveryResourceRef, BrowserDictionaryDiscoveryResponse,
16    },
17    domain::DomainSummary,
18    refs::{DatasetRef, StudyRef},
19    study::BrowserContentSummaryPolicy,
20};
21use ahri_tre_types::VersionId;
22use std::fmt;
23use thiserror::Error;
24
25/// Stable authority label emitted by browser dictionary discovery responses.
26pub const BROWSER_DICTIONARY_DISCOVERY_AUTHORITY: &str = "tre_browser.dictionary_discovery";
27
28/// Closed workflow failures returned by browser dictionary discovery.
29#[derive(Debug, Error, Clone, PartialEq, Eq)]
30pub enum BrowserDictionaryDiscoveryError {
31    /// A resource reference has the wrong kind or Datastore origin.
32    #[error("dictionary resource reference is invalid")]
33    InvalidReference(Box<ahri_tre_protocol::ProtocolError>),
34    /// The supplied public datastore identifier is not a ready datastore.
35    #[error("selected datastore identity is invalid")]
36    InvalidDatastoreIdentity,
37    /// The selected datastore metadata connection could not be used.
38    #[error("selected datastore is not available for browser discovery")]
39    DatastoreUnavailable,
40    /// The configured dictionary discovery capability could not be resolved.
41    #[error("browser dictionary discovery authority is unavailable")]
42    AuthorityUnavailable,
43    /// The selected study does not exist in the datastore.
44    #[error("study was not found in the selected datastore")]
45    StudyNotFound,
46    /// The selected dataset or datafile does not exist in the study.
47    #[error("resource was not found in the selected study")]
48    ResourceNotFound,
49    /// Legacy Datafile dictionary requests no longer have a successful shape.
50    #[error("data dictionaries belong to dataset versions")]
51    DataFileDictionaryUnsupported,
52}
53
54/// Failures returned by the browser variable-search workflow.
55#[derive(Debug, Error, Clone, PartialEq, Eq)]
56pub enum BrowserVariableWorkflowError {
57    /// Dictionary discovery failed before filtering could be applied.
58    #[error(transparent)]
59    Discovery(#[from] BrowserDictionaryDiscoveryError),
60    /// The stable search request or its cursor is invalid.
61    #[error("browser Variable search request is invalid")]
62    InvalidRequest(ahri_tre_protocol::ProtocolError),
63}
64
65/// Adapter-resolved resource identity used to build the public resource reference.
66#[derive(Debug, Clone, PartialEq, Eq)]
67pub enum BrowserDictionaryDiscoveryResourceTarget {
68    /// A resolved dataset version.
69    Dataset { dataset_id: VersionId },
70}
71
72/// Metadata-only source values required to project one browser dictionary.
73#[derive(Debug, Clone, PartialEq, Eq)]
74pub struct BrowserDictionaryDiscoverySource {
75    /// Public identity of the selected datastore.
76    pub datastore_id: PublicUuid,
77    /// Selected study metadata; private fields are excluded by projection.
78    pub study: ahri_tre_pgmeta::PgStudyReadModel,
79    /// Selected resource after adapter-side authorization and lookup.
80    pub resource: BrowserDictionaryDiscoveryResourceTarget,
81    /// Browser-visible domains associated with the selected resource.
82    pub domains: Vec<ahri_tre_pgmeta::PgDomainReadModel>,
83    /// Browser-visible variable metadata associated with the selected resource.
84    pub variables: Vec<BrowserDictionaryVariableDiscoverySourceRecord>,
85}
86
87/// Read-only capability required by browser dictionary discovery and search.
88///
89/// Implementations must return metadata only and must not expose database
90/// handles, content rows, storage topology, or credentials.
91pub trait BrowserDictionaryDiscoveryCapability: fmt::Debug + Send + Sync {
92    /// Resolves one study resource and all browser-visible dictionary metadata.
93    ///
94    /// Returns a typed discovery failure when the datastore, study, resource,
95    /// or configured authority is unavailable.
96    fn discover_dictionary(
97        &self,
98        datastore_id: PublicUuid,
99        study_id: PublicUuid,
100        resource: BrowserDictionaryDiscoveryResourceRef,
101    ) -> Result<BrowserDictionaryDiscoverySource, BrowserDictionaryDiscoveryError>;
102
103    /// Lists browser-visible variables across the selected datastore.
104    ///
105    /// Returned records are filtered, ordered, and paged by the app workflow.
106    fn discover_catalogue_variables(
107        &self,
108        datastore_id: PublicUuid,
109    ) -> Result<Vec<BrowserDictionaryVariableCatalogueRecord>, BrowserDictionaryDiscoveryError>;
110}
111
112/// Discovers and projects one resource dictionary through a resolved capability.
113///
114/// The returned response contains metadata only. Typed datastore, study,
115/// resource, and authority failures are preserved unchanged.
116pub fn discover_browser_dictionary(
117    capability: &dyn BrowserDictionaryDiscoveryCapability,
118    datastore_id: PublicUuid,
119    study_id: PublicUuid,
120    resource: BrowserDictionaryDiscoveryResourceRef,
121) -> Result<BrowserDictionaryDiscoveryResponse, BrowserDictionaryDiscoveryError> {
122    if matches!(
123        resource,
124        BrowserDictionaryDiscoveryResourceRef::DataFile { .. }
125    ) {
126        return Err(BrowserDictionaryDiscoveryError::DataFileDictionaryUnsupported);
127    }
128    if let BrowserDictionaryDiscoveryResourceRef::Dataset { dataset } = &resource {
129        dataset
130            .validate_context(
131                datastore_id,
132                ahri_tre_protocol::refs::ObjectKind::AssetVersion,
133            )
134            .map_err(|error| BrowserDictionaryDiscoveryError::InvalidReference(Box::new(error)))?;
135    }
136    capability
137        .discover_dictionary(datastore_id, study_id, resource)
138        .map(browser_dictionary_discovery_response)
139}
140
141#[allow(clippy::result_large_err)]
142/// Searches browser-visible dictionary variables with stable cursor semantics.
143///
144/// The request must already describe the selected datastore and protocol
145/// version. Discovery failures remain typed; request and cursor failures are
146/// returned as [`BrowserVariableWorkflowError::InvalidRequest`].
147pub fn search_browser_variables(
148    capability: &dyn BrowserDictionaryDiscoveryCapability,
149    datastore_id: PublicUuid,
150    request: &SearchVariablesRequest,
151    protocol_version: &ProtocolVersion,
152) -> Result<BrowserVariableSearchResponse, BrowserVariableWorkflowError> {
153    let sources = capability.discover_catalogue_variables(datastore_id)?;
154    browser_dictionary_variable_search_response(datastore_id, sources, request, protocol_version)
155        .map_err(BrowserVariableWorkflowError::InvalidRequest)
156}
157
158/// Projects adapter-owned metadata into the stable browser dictionary DTO.
159///
160/// Private study metadata, content values, storage paths, credentials, and
161/// runtime handles are never copied into the response.
162pub fn browser_dictionary_discovery_response(
163    source: BrowserDictionaryDiscoverySource,
164) -> BrowserDictionaryDiscoveryResponse {
165    let datastore_id = source.datastore_id;
166    let variables = source
167        .variables
168        .into_iter()
169        .map(|value| crate::variable_record(datastore_id, value))
170        .collect::<Vec<_>>();
171    BrowserDictionaryDiscoveryResponse {
172        authority: BROWSER_DICTIONARY_DISCOVERY_AUTHORITY.to_string(),
173        datastore_id,
174        study: StudyRef {
175            datastore_id,
176            kind: ahri_tre_protocol::refs::ObjectKind::Study,
177            id: PublicUuid::from_uuid(source.study.study_id.0),
178        },
179        resource: resource_ref(datastore_id, source.resource),
180        domains: source
181            .domains
182            .into_iter()
183            .map(|value| domain_summary(datastore_id, value))
184            .collect(),
185        empty_state: variables
186            .is_empty()
187            .then(|| BrowserDictionaryDiscoveryEmptyState {
188                code: "no_browser_visible_dictionary_variables".to_string(),
189                message: "No browser-visible dictionary variables are available for this resource."
190                    .to_string(),
191            }),
192        variables,
193        content_derived_summaries: BrowserContentSummaryPolicy {
194            included: false,
195            reason:
196                "Content-derived summaries are excluded from data dictionary Discovery metadata."
197                    .to_string(),
198        },
199    }
200}
201
202fn resource_ref(
203    datastore_id: PublicUuid,
204    resource: BrowserDictionaryDiscoveryResourceTarget,
205) -> BrowserDictionaryDiscoveryDatasetRef {
206    match resource {
207        BrowserDictionaryDiscoveryResourceTarget::Dataset { dataset_id } => {
208            BrowserDictionaryDiscoveryDatasetRef::Dataset {
209                dataset: DatasetRef {
210                    datastore_id,
211                    kind: ahri_tre_protocol::refs::ObjectKind::AssetVersion,
212                    id: PublicUuid::from_uuid(dataset_id.0),
213                },
214            }
215        }
216    }
217}
218
219fn domain_summary(
220    datastore_id: PublicUuid,
221    domain: ahri_tre_pgmeta::PgDomainReadModel,
222) -> DomainSummary {
223    DomainSummary {
224        domain: crate::projections::domain_ref(datastore_id, domain.domain_id),
225        name: domain.name,
226        description: domain.description,
227        uri: domain.uri,
228    }
229}
230
231#[cfg(test)]
232mod tests {
233    use super::*;
234    use ahri_tre_types::StudyId;
235    use uuid::Uuid;
236
237    #[derive(Debug)]
238    struct NoReads;
239    impl BrowserDictionaryDiscoveryCapability for NoReads {
240        fn discover_dictionary(
241            &self,
242            _: PublicUuid,
243            _: PublicUuid,
244            _: BrowserDictionaryDiscoveryResourceRef,
245        ) -> Result<BrowserDictionaryDiscoverySource, BrowserDictionaryDiscoveryError> {
246            panic!("invalid resource reference must fail before lookup")
247        }
248        fn discover_catalogue_variables(
249            &self,
250            _: PublicUuid,
251        ) -> Result<Vec<BrowserDictionaryVariableCatalogueRecord>, BrowserDictionaryDiscoveryError>
252        {
253            panic!("invalid resource reference must fail before lookup")
254        }
255    }
256
257    #[test]
258    fn dictionary_rejects_foreign_and_wrong_kind_revisions_before_lookup() {
259        use ahri_tre_protocol::{
260            ProtocolErrorCode,
261            refs::{ObjectKind, ObjectRef},
262        };
263        let datastore = PublicUuid::from_uuid(Uuid::from_u128(1));
264        let study = PublicUuid::from_uuid(Uuid::from_u128(2));
265        for (origin, kind, code) in [
266            (
267                PublicUuid::from_uuid(Uuid::from_u128(9)),
268                ObjectKind::AssetVersion,
269                ProtocolErrorCode::Conflict,
270            ),
271            (
272                datastore,
273                ObjectKind::Asset,
274                ProtocolErrorCode::ValidationFailed,
275            ),
276        ] {
277            let resource = BrowserDictionaryDiscoveryResourceRef::Dataset {
278                dataset: ObjectRef {
279                    datastore_id: origin,
280                    kind,
281                    id: PublicUuid::from_uuid(Uuid::from_u128(3)),
282                },
283            };
284            assert!(
285                matches!(discover_browser_dictionary(&NoReads, datastore, study, resource),
286                Err(BrowserDictionaryDiscoveryError::InvalidReference(error)) if error.code == code)
287            );
288        }
289    }
290
291    #[test]
292    fn empty_dictionary_projection_is_metadata_only_and_stable() {
293        let datastore_id = PublicUuid::from_uuid(Uuid::from_u128(1));
294        let response = browser_dictionary_discovery_response(BrowserDictionaryDiscoverySource {
295            datastore_id,
296            study: ahri_tre_pgmeta::PgStudyReadModel {
297                study_id: StudyId(Uuid::from_u128(2)),
298                name: "cohort".to_string(),
299                description: None,
300                documentation: None,
301                agent_instructions: Some("private instructions".to_string()),
302                external_id: None,
303                study_type_id: None,
304                date_created: None,
305                created_by: Some("private actor".to_string()),
306            },
307            resource: BrowserDictionaryDiscoveryResourceTarget::Dataset {
308                dataset_id: VersionId(Uuid::from_u128(3)),
309            },
310            domains: Vec::new(),
311            variables: Vec::new(),
312        });
313
314        assert_eq!(response.authority, BROWSER_DICTIONARY_DISCOVERY_AUTHORITY);
315        assert_eq!(
316            response.empty_state.as_ref().unwrap().code,
317            "no_browser_visible_dictionary_variables"
318        );
319        assert!(!response.content_derived_summaries.included);
320        let serialized = serde_json::to_string(&response).unwrap();
321        assert!(!serialized.contains("private instructions"));
322        assert!(!serialized.contains("private actor"));
323    }
324}