Skip to main content

ahri_tre_app/
browser_domain_discovery.rs

1use crate::browser_study_discovery::{BrowserStudyDiscoverySource, browser_study_facts};
2use crate::cursor_integrity::hmac_sha256;
3use ahri_tre_protocol::{
4    ProtocolVersion, PublicUuid,
5    domain::{
6        BrowserDomainCollectionFilters, BrowserDomainCollectionRequest,
7        BrowserDomainCollectionResponse, BrowserDomainDetailRequest, BrowserDomainDetailResponse,
8        BrowserDomainEmptyState, BrowserDomainPageMetadata, BrowserDomainRecord,
9        BrowserDomainStudyCollectionRequest, BrowserDomainStudyCollectionResponse,
10        BrowserDomainTextFilter, BrowserDomainVariableCollectionRequest,
11        BrowserDomainVariableCollectionResponse, BrowserDomainVariableDetail,
12        BrowserDomainVariableDetailRequest, BrowserDomainVariableDetailResponse,
13        BrowserDomainVariableRecord, BrowserDomainVariableVocabulary, BrowserVocabularyDetail,
14        BrowserVocabularyDetailRequest, BrowserVocabularyDetailResponse,
15        BrowserVocabularyItemCollectionRequest, BrowserVocabularyItemCollectionResponse,
16        BrowserVocabularyItemRecord, BrowserVocabularyMappingCollectionRequest,
17        BrowserVocabularyMappingCollectionResponse, BrowserVocabularyMappingEndpoint,
18        BrowserVocabularyMappingRecord,
19    },
20    refs::{DomainRef, TagRef, TagScope, TagSummary, VariableRef, VocabularyRef},
21};
22use ahri_tre_types::{DomainId, VariableId, VocabularyId};
23use serde::{Deserialize, Serialize};
24use sha2::{Digest, Sha256};
25use std::fmt;
26use std::sync::OnceLock;
27use thiserror::Error;
28use uuid::Uuid;
29
30const DEFAULT_LIMIT: u16 = 100;
31const MAX_LIMIT: u16 = 500;
32const COLLECTION: &str = "domains";
33const ORDERING: &str = "name_identity_v1";
34const ORDERING_DESCRIPTION: &str =
35    "Case-insensitive Domain name ascending, then stable Domain identity ascending.";
36const STUDY_ORDERING_DESCRIPTION: &str =
37    "Case-insensitive Study name ascending, then stable Study identity ascending.";
38static CURSOR_MAC_KEY: OnceLock<[u8; 16]> = OnceLock::new();
39const DOMAIN_STUDIES_COLLECTION: &str = "domain_studies";
40const DOMAIN_VARIABLES_COLLECTION: &str = "domain_variables";
41const VARIABLE_ORDERING_DESCRIPTION: &str =
42    "Case-insensitive Variable name ascending, then stable Variable identity ascending.";
43const VOCABULARY_ITEMS_COLLECTION: &str = "vocabulary_items";
44const VOCABULARY_ITEM_ORDERING: &str = "code_identity_v1";
45const VOCABULARY_ITEM_ORDERING_DESCRIPTION: &str =
46    "Public integer code ascending, then stable Vocabulary item identity ascending.";
47const VOCABULARY_MAPPINGS_COLLECTION: &str = "vocabulary_mappings";
48const VOCABULARY_MAPPING_ORDERING: &str = "source_target_identity_v1";
49const VOCABULARY_MAPPING_ORDERING_DESCRIPTION: &str = "Source Vocabulary identity, source item code and identity, target Vocabulary identity, target item code and identity, then stable mapping identity ascending.";
50
51#[derive(Debug, Error, Clone, PartialEq, Eq)]
52pub enum BrowserDomainDiscoveryError {
53    #[error("invalid catalogue reference")]
54    InvalidReference(Box<ahri_tre_protocol::ProtocolError>),
55    #[error("selected datastore identity is invalid")]
56    InvalidDatastoreIdentity,
57    #[error("selected datastore is unavailable")]
58    DatastoreUnavailable,
59    #[error("selected datastore is unavailable for associated Study discovery")]
60    StudyDatastoreUnavailable,
61    #[error("browser Domain discovery authority is unavailable")]
62    AuthorityUnavailable,
63    #[error("Domain was not found")]
64    DomainNotFound,
65    #[error("Variable was not found")]
66    VariableNotFound,
67    #[error("Vocabulary was not found")]
68    VocabularyNotFound,
69    #[error("page request is invalid")]
70    InvalidPage,
71    #[error("Domain collection filters are invalid")]
72    InvalidFilters,
73    #[error("cursor is invalid or does not match this collection")]
74    InvalidCursor,
75    #[error("cursor is stale")]
76    StaleCursor,
77    #[error("protocol version is unsupported")]
78    UnsupportedVersion,
79}
80
81#[derive(Debug, Clone, PartialEq, Eq)]
82pub struct BrowserDomainSourceRecord {
83    pub domain: ahri_tre_pgmeta::PgDomainReadModel,
84    pub tags: Vec<ahri_tre_pgmeta::PgTagReadModel>,
85}
86
87#[derive(Debug, Clone, PartialEq, Eq)]
88pub struct BrowserDomainVariableSourceRecord {
89    pub variable: ahri_tre_pgmeta::PgVariableReadModel,
90    pub vocabulary: Option<ahri_tre_pgmeta::PgVocabularyReadModel>,
91    pub tags: Vec<ahri_tre_pgmeta::PgTagReadModel>,
92}
93
94#[derive(Debug, Clone, PartialEq, Eq)]
95pub struct BrowserVocabularySource {
96    pub vocabulary: ahri_tre_pgmeta::PgVocabularyReadModel,
97    pub items: Vec<ahri_tre_pgmeta::PgVocabularyItemReadModel>,
98    pub mappings: Vec<BrowserVocabularyMappingSourceRecord>,
99}
100
101#[derive(Debug, Clone, PartialEq, Eq)]
102pub struct BrowserVocabularyMappingSourceRecord {
103    pub mapping: ahri_tre_pgmeta::PgVocabularyMappingReadModel,
104    pub from_item: ahri_tre_pgmeta::PgVocabularyItemReadModel,
105    pub from_domain_id: DomainId,
106    pub to_item: ahri_tre_pgmeta::PgVocabularyItemReadModel,
107    pub to_domain_id: DomainId,
108}
109
110/// Metadata-only capability consumed by Domain and Vocabulary browser workflows.
111pub trait BrowserDomainDiscoveryCapability: fmt::Debug + Send + Sync {
112    fn discover_domains(
113        &self,
114        datastore_id: PublicUuid,
115    ) -> Result<Vec<BrowserDomainSourceRecord>, BrowserDomainDiscoveryError>;
116
117    fn discover_domain_studies(
118        &self,
119        datastore_id: PublicUuid,
120        domain_id: DomainId,
121    ) -> Result<BrowserStudyDiscoverySource, BrowserDomainDiscoveryError>;
122
123    fn discover_domain_variables(
124        &self,
125        datastore_id: PublicUuid,
126        domain_id: DomainId,
127    ) -> Result<Vec<BrowserDomainVariableSourceRecord>, BrowserDomainDiscoveryError>;
128
129    fn discover_vocabulary(
130        &self,
131        datastore_id: PublicUuid,
132        vocabulary_id: VocabularyId,
133        include_items: bool,
134        include_mappings: bool,
135    ) -> Result<Option<BrowserVocabularySource>, BrowserDomainDiscoveryError>;
136}
137
138pub fn list_browser_domains(
139    capability: &dyn BrowserDomainDiscoveryCapability,
140    request: &BrowserDomainCollectionRequest,
141) -> Result<BrowserDomainCollectionResponse, BrowserDomainDiscoveryError> {
142    for tag in &request.filters.any_tags {
143        crate::browser_catalogue_search::validate_tag(request.datastore_id, tag)
144            .map_err(|error| BrowserDomainDiscoveryError::InvalidReference(Box::new(error)))?;
145    }
146    collection_response(
147        capability.discover_domains(request.datastore_id)?,
148        request.datastore_id,
149        request.filters.clone(),
150        Some(request.page.limit),
151        request.page.cursor.as_deref(),
152        request.requested_protocol_version.as_deref(),
153    )
154}
155
156pub fn get_browser_domain(
157    capability: &dyn BrowserDomainDiscoveryCapability,
158    request: &BrowserDomainDetailRequest,
159) -> Result<BrowserDomainDetailResponse, BrowserDomainDiscoveryError> {
160    request
161        .domain
162        .validate_context(
163            request.datastore_id,
164            ahri_tre_protocol::refs::ObjectKind::Domain,
165        )
166        .map_err(|error| BrowserDomainDiscoveryError::InvalidReference(Box::new(error)))?;
167    detail_response(
168        capability.discover_domains(request.datastore_id)?,
169        request.datastore_id,
170        request.domain.id,
171        request.requested_protocol_version.as_deref(),
172    )
173}
174
175pub fn list_browser_domain_studies(
176    capability: &dyn BrowserDomainDiscoveryCapability,
177    request: &BrowserDomainStudyCollectionRequest,
178) -> Result<BrowserDomainStudyCollectionResponse, BrowserDomainDiscoveryError> {
179    request
180        .domain
181        .validate_context(
182            request.datastore_id,
183            ahri_tre_protocol::refs::ObjectKind::Domain,
184        )
185        .map_err(|error| BrowserDomainDiscoveryError::InvalidReference(Box::new(error)))?;
186    get_browser_domain(
187        capability,
188        &BrowserDomainDetailRequest {
189            datastore_id: request.datastore_id,
190            domain: request.domain,
191            requested_protocol_version: request.requested_protocol_version.clone(),
192        },
193    )?;
194    let domain_id =
195        internal_domain_id(request.domain.id).ok_or(BrowserDomainDiscoveryError::DomainNotFound)?;
196    study_collection_response(
197        capability.discover_domain_studies(request.datastore_id, domain_id)?,
198        request.domain.id,
199        Some(request.page.limit),
200        request.page.cursor.as_deref(),
201        request.requested_protocol_version.as_deref(),
202    )
203}
204
205pub fn list_browser_domain_variables(
206    capability: &dyn BrowserDomainDiscoveryCapability,
207    request: &BrowserDomainVariableCollectionRequest,
208) -> Result<BrowserDomainVariableCollectionResponse, BrowserDomainDiscoveryError> {
209    request
210        .domain
211        .validate_context(
212            request.datastore_id,
213            ahri_tre_protocol::refs::ObjectKind::Domain,
214        )
215        .map_err(|error| BrowserDomainDiscoveryError::InvalidReference(Box::new(error)))?;
216    ensure_browser_domain(capability, request.datastore_id, request.domain.id)?;
217    let domain_id =
218        internal_domain_id(request.domain.id).ok_or(BrowserDomainDiscoveryError::DomainNotFound)?;
219    variable_collection_response(
220        capability.discover_domain_variables(request.datastore_id, domain_id)?,
221        request.datastore_id,
222        request.domain.id,
223        Some(request.page.limit),
224        request.page.cursor.as_deref(),
225        None,
226    )
227}
228
229pub fn get_browser_domain_variable(
230    capability: &dyn BrowserDomainDiscoveryCapability,
231    request: &BrowserDomainVariableDetailRequest,
232) -> Result<BrowserDomainVariableDetailResponse, BrowserDomainDiscoveryError> {
233    request
234        .variable
235        .validate_context(
236            request.datastore_id,
237            ahri_tre_protocol::refs::ObjectKind::Variable,
238        )
239        .map_err(|error| BrowserDomainDiscoveryError::InvalidReference(Box::new(error)))?;
240
241    request
242        .domain
243        .validate_context(
244            request.datastore_id,
245            ahri_tre_protocol::refs::ObjectKind::Domain,
246        )
247        .map_err(|error| BrowserDomainDiscoveryError::InvalidReference(Box::new(error)))?;
248    ensure_browser_domain(capability, request.datastore_id, request.domain.id)?;
249    let domain_id =
250        internal_domain_id(request.domain.id).ok_or(BrowserDomainDiscoveryError::DomainNotFound)?;
251    variable_detail_response(
252        capability.discover_domain_variables(request.datastore_id, domain_id)?,
253        request.datastore_id,
254        request.domain.id,
255        request.variable.id,
256        None,
257    )
258}
259
260pub fn get_browser_vocabulary(
261    capability: &dyn BrowserDomainDiscoveryCapability,
262    request: &BrowserVocabularyDetailRequest,
263) -> Result<BrowserVocabularyDetailResponse, BrowserDomainDiscoveryError> {
264    request
265        .vocabulary
266        .validate_context(
267            request.datastore_id,
268            ahri_tre_protocol::refs::ObjectKind::Vocabulary,
269        )
270        .map_err(|error| BrowserDomainDiscoveryError::InvalidReference(Box::new(error)))?;
271
272    request
273        .domain
274        .validate_context(
275            request.datastore_id,
276            ahri_tre_protocol::refs::ObjectKind::Domain,
277        )
278        .map_err(|error| BrowserDomainDiscoveryError::InvalidReference(Box::new(error)))?;
279    let source = discover_browser_vocabulary(
280        capability,
281        request.datastore_id,
282        request.domain.id,
283        request.vocabulary.id,
284        false,
285        false,
286    )?;
287    vocabulary_detail_response(
288        source,
289        request.datastore_id,
290        request.domain.id,
291        request.vocabulary.id,
292    )
293}
294
295pub fn list_browser_vocabulary_items(
296    capability: &dyn BrowserDomainDiscoveryCapability,
297    request: &BrowserVocabularyItemCollectionRequest,
298) -> Result<BrowserVocabularyItemCollectionResponse, BrowserDomainDiscoveryError> {
299    request
300        .vocabulary
301        .validate_context(
302            request.datastore_id,
303            ahri_tre_protocol::refs::ObjectKind::Vocabulary,
304        )
305        .map_err(|error| BrowserDomainDiscoveryError::InvalidReference(Box::new(error)))?;
306
307    request
308        .domain
309        .validate_context(
310            request.datastore_id,
311            ahri_tre_protocol::refs::ObjectKind::Domain,
312        )
313        .map_err(|error| BrowserDomainDiscoveryError::InvalidReference(Box::new(error)))?;
314    let source = discover_browser_vocabulary(
315        capability,
316        request.datastore_id,
317        request.domain.id,
318        request.vocabulary.id,
319        true,
320        false,
321    )?;
322    vocabulary_item_collection_response(
323        source,
324        request.datastore_id,
325        request.domain.id,
326        request.vocabulary.id,
327        Some(request.page.limit),
328        request.page.cursor.as_deref(),
329    )
330}
331
332pub fn list_browser_vocabulary_mappings(
333    capability: &dyn BrowserDomainDiscoveryCapability,
334    request: &BrowserVocabularyMappingCollectionRequest,
335) -> Result<BrowserVocabularyMappingCollectionResponse, BrowserDomainDiscoveryError> {
336    request
337        .vocabulary
338        .validate_context(
339            request.datastore_id,
340            ahri_tre_protocol::refs::ObjectKind::Vocabulary,
341        )
342        .map_err(|error| BrowserDomainDiscoveryError::InvalidReference(Box::new(error)))?;
343
344    request
345        .domain
346        .validate_context(
347            request.datastore_id,
348            ahri_tre_protocol::refs::ObjectKind::Domain,
349        )
350        .map_err(|error| BrowserDomainDiscoveryError::InvalidReference(Box::new(error)))?;
351    let source = discover_browser_vocabulary(
352        capability,
353        request.datastore_id,
354        request.domain.id,
355        request.vocabulary.id,
356        false,
357        true,
358    )?;
359    vocabulary_mapping_collection_response(
360        source,
361        request.datastore_id,
362        request.domain.id,
363        request.vocabulary.id,
364        Some(request.page.limit),
365        request.page.cursor.as_deref(),
366    )
367}
368
369fn ensure_browser_domain(
370    capability: &dyn BrowserDomainDiscoveryCapability,
371    datastore_id: PublicUuid,
372    domain_id: PublicUuid,
373) -> Result<(), BrowserDomainDiscoveryError> {
374    detail_response(
375        capability.discover_domains(datastore_id)?,
376        datastore_id,
377        domain_id,
378        None,
379    )?;
380    Ok(())
381}
382
383fn discover_browser_vocabulary(
384    capability: &dyn BrowserDomainDiscoveryCapability,
385    datastore_id: PublicUuid,
386    domain_id: PublicUuid,
387    vocabulary_id: PublicUuid,
388    include_items: bool,
389    include_mappings: bool,
390) -> Result<BrowserVocabularySource, BrowserDomainDiscoveryError> {
391    ensure_browser_domain(capability, datastore_id, domain_id)?;
392    let internal = internal_vocabulary_id(vocabulary_id)
393        .ok_or(BrowserDomainDiscoveryError::VocabularyNotFound)?;
394    capability
395        .discover_vocabulary(datastore_id, internal, include_items, include_mappings)?
396        .ok_or(BrowserDomainDiscoveryError::VocabularyNotFound)
397}
398
399#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
400#[serde(rename_all = "snake_case")]
401enum Direction {
402    Forward,
403    Backward,
404}
405
406#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
407struct Cursor {
408    datastore_id: PublicUuid,
409    collection: String,
410    limit: u16,
411    ordering: String,
412    direction: Direction,
413    boundary: usize,
414    protocol_version: String,
415    snapshot: String,
416    #[serde(default)]
417    filters: BrowserDomainCollectionFilters,
418    #[serde(default, skip_serializing_if = "Option::is_none")]
419    domain_id: Option<PublicUuid>,
420    #[serde(default, skip_serializing_if = "Option::is_none")]
421    vocabulary_id: Option<PublicUuid>,
422    integrity: String,
423}
424
425pub fn collection_response(
426    mut source: Vec<BrowserDomainSourceRecord>,
427    datastore_id: PublicUuid,
428    mut filters: BrowserDomainCollectionFilters,
429    limit: Option<u16>,
430    raw_cursor: Option<&str>,
431    requested_version: Option<&str>,
432) -> Result<BrowserDomainCollectionResponse, BrowserDomainDiscoveryError> {
433    for tag in &filters.any_tags {
434        crate::browser_catalogue_search::validate_tag(datastore_id, tag)
435            .map_err(|error| BrowserDomainDiscoveryError::InvalidReference(Box::new(error)))?;
436    }
437    let mut validation_request = BrowserDomainCollectionRequest {
438        datastore_id,
439        filters,
440        page: ahri_tre_protocol::pagination::PageRequest::default(),
441        requested_protocol_version: requested_version.map(str::to_owned),
442    };
443    validation_request
444        .normalize_and_validate()
445        .map_err(|_| BrowserDomainDiscoveryError::InvalidFilters)?;
446    filters = validation_request.filters;
447    let version = requested_version
448        .map(ProtocolVersion::parse)
449        .transpose()
450        .map_err(|_| BrowserDomainDiscoveryError::UnsupportedVersion)?
451        .unwrap_or_default();
452    version
453        .ensure_supported(&ProtocolVersion::current())
454        .map_err(|_| BrowserDomainDiscoveryError::UnsupportedVersion)?;
455    let limit = limit.unwrap_or(DEFAULT_LIMIT);
456    if limit == 0 || limit > MAX_LIMIT {
457        return Err(BrowserDomainDiscoveryError::InvalidPage);
458    }
459    source.retain(|record| domain_matches_filters(record, &filters));
460    source.sort_by(|a, b| {
461        a.domain
462            .name
463            .to_lowercase()
464            .cmp(&b.domain.name.to_lowercase())
465            .then(a.domain.domain_id.0.cmp(&b.domain.domain_id.0))
466    });
467    let snapshot = snapshot(&source);
468    let (start, direction) = match raw_cursor {
469        None => (0, Direction::Forward),
470        Some(raw) => {
471            let cursor = decode_cursor(raw)?;
472            if cursor.datastore_id != datastore_id
473                || cursor.collection != COLLECTION
474                || cursor.limit != limit
475                || cursor.ordering != ORDERING
476                || cursor.protocol_version != version.to_string()
477                || cursor.filters != filters
478                || cursor.domain_id.is_some()
479                || cursor.integrity != integrity(&cursor)
480            {
481                return Err(BrowserDomainDiscoveryError::InvalidCursor);
482            }
483            if cursor.snapshot != snapshot {
484                return Err(BrowserDomainDiscoveryError::StaleCursor);
485            }
486            if cursor.boundary == 0 || cursor.boundary >= source.len() {
487                return Err(BrowserDomainDiscoveryError::InvalidCursor);
488            }
489            (cursor.boundary, cursor.direction)
490        }
491    };
492    let start = match direction {
493        Direction::Forward => start.min(source.len()),
494        Direction::Backward => start.saturating_sub(limit as usize),
495    };
496    let end = (start + limit as usize).min(source.len());
497    let domains = source[start..end]
498        .iter()
499        .cloned()
500        .map(|value| project(datastore_id, value))
501        .collect::<Vec<_>>();
502    let returned_count = domains.len();
503    let previous_cursor = (start > 0).then(|| {
504        encode_cursor(domain_cursor(
505            datastore_id,
506            limit,
507            Direction::Backward,
508            start,
509            &version,
510            &snapshot,
511            &filters,
512        ))
513    });
514    let next_cursor = (end < source.len()).then(|| {
515        encode_cursor(domain_cursor(
516            datastore_id,
517            limit,
518            Direction::Forward,
519            end,
520            &version,
521            &snapshot,
522            &filters,
523        ))
524    });
525    Ok(BrowserDomainCollectionResponse {
526        protocol_version: version.clone(),
527        datastore_id,
528        filters: filters.clone(),
529        returned_count,
530        has_more: next_cursor.is_some(),
531        empty_state: domains.is_empty().then(|| {
532            if filters == BrowserDomainCollectionFilters::default() {
533                BrowserDomainEmptyState {
534                    code: "no_domains".into(),
535                    message: "No Domains are available in this datastore.".into(),
536                }
537            } else {
538                BrowserDomainEmptyState {
539                    code: "no_matching_browser_visible_domains".into(),
540                    message: "No browser-visible Domains match the active filters.".into(),
541                }
542            }
543        }),
544        page: BrowserDomainPageMetadata {
545            limit,
546            returned: returned_count,
547            ordering: ORDERING.into(),
548            ordering_description: ORDERING_DESCRIPTION.into(),
549            previous_cursor,
550            next_cursor,
551        },
552        domains,
553    })
554}
555
556fn domain_matches_filters(
557    record: &BrowserDomainSourceRecord,
558    filters: &BrowserDomainCollectionFilters,
559) -> bool {
560    let text_matches = filters.text.as_ref().is_none_or(|text| {
561        domain_text_values(record).any(|candidate| domain_text_matches(candidate, text))
562    });
563    let tag_matches = filters.any_tags.is_empty()
564        || record.tags.iter().any(|tag| {
565            filters
566                .any_tags
567                .iter()
568                .any(|selector| domain_tag_matches(tag, selector))
569        });
570    text_matches && tag_matches
571}
572
573fn domain_text_values(record: &BrowserDomainSourceRecord) -> impl Iterator<Item = &str> {
574    std::iter::once(record.domain.name.as_str())
575        .chain(record.domain.description.as_deref())
576        .chain(record.domain.uri.as_deref())
577}
578
579fn domain_text_matches(candidate: &str, filter: &BrowserDomainTextFilter) -> bool {
580    let candidate = candidate.to_lowercase();
581    let value = filter.value.to_lowercase();
582    match filter.mode {
583        ahri_tre_protocol::search::TextMode::Exact => candidate == value,
584        ahri_tre_protocol::search::TextMode::Prefix => candidate.starts_with(&value),
585        ahri_tre_protocol::search::TextMode::Contains => candidate.contains(&value),
586    }
587}
588
589fn domain_tag_matches(
590    tag: &ahri_tre_pgmeta::PgTagReadModel,
591    selector: &ahri_tre_protocol::refs::TagSelector,
592) -> bool {
593    match selector {
594        ahri_tre_protocol::refs::TagSelector::Id { tag: expected } => {
595            ahri_tre_protocol::refs::encode_scoped_integer_ref("tag", tag.tag_id) == expected.id
596        }
597        ahri_tre_protocol::refs::TagSelector::Label { scope, label } => {
598            *scope == TagScope::Global && tag.name == label.as_str()
599        }
600    }
601}
602
603pub fn detail_response(
604    source: Vec<BrowserDomainSourceRecord>,
605    datastore_id: PublicUuid,
606    domain_id: PublicUuid,
607    requested_version: Option<&str>,
608) -> Result<BrowserDomainDetailResponse, BrowserDomainDiscoveryError> {
609    let version = requested_version
610        .map(ProtocolVersion::parse)
611        .transpose()
612        .map_err(|_| BrowserDomainDiscoveryError::UnsupportedVersion)?
613        .unwrap_or_default();
614    version
615        .ensure_supported(&ProtocolVersion::current())
616        .map_err(|_| BrowserDomainDiscoveryError::UnsupportedVersion)?;
617    let record = source
618        .into_iter()
619        .find(|record| public_domain_id(record.domain.domain_id) == domain_id)
620        .ok_or(BrowserDomainDiscoveryError::DomainNotFound)?;
621    Ok(BrowserDomainDetailResponse {
622        protocol_version: version,
623        datastore_id,
624        domain: project(datastore_id, record),
625    })
626}
627
628pub fn study_collection_response(
629    source: BrowserStudyDiscoverySource,
630    domain_id: PublicUuid,
631    limit: Option<u16>,
632    raw_cursor: Option<&str>,
633    requested_version: Option<&str>,
634) -> Result<BrowserDomainStudyCollectionResponse, BrowserDomainDiscoveryError> {
635    let version = requested_version
636        .map(ProtocolVersion::parse)
637        .transpose()
638        .map_err(|_| BrowserDomainDiscoveryError::UnsupportedVersion)?
639        .unwrap_or_default();
640    version
641        .ensure_supported(&ProtocolVersion::current())
642        .map_err(|_| BrowserDomainDiscoveryError::UnsupportedVersion)?;
643    let limit = limit.unwrap_or(DEFAULT_LIMIT);
644    if limit == 0 || limit > MAX_LIMIT {
645        return Err(BrowserDomainDiscoveryError::InvalidPage);
646    }
647    let datastore_id = source.datastore_id;
648    let mut studies = source
649        .studies
650        .into_iter()
651        .map(|record| browser_study_facts(datastore_id, &record))
652        .collect::<Vec<_>>();
653    studies.sort_by(|a, b| {
654        a.name
655            .to_lowercase()
656            .cmp(&b.name.to_lowercase())
657            .then(a.study.id.to_string().cmp(&b.study.id.to_string()))
658    });
659    let snapshot = study_snapshot(&studies);
660    let (boundary, direction) = match raw_cursor {
661        None => (0, Direction::Forward),
662        Some(raw) => {
663            let cursor = decode_cursor(raw)?;
664            if cursor.datastore_id != datastore_id
665                || cursor.collection != DOMAIN_STUDIES_COLLECTION
666                || cursor.domain_id != Some(domain_id)
667                || cursor.limit != limit
668                || cursor.ordering != ORDERING
669                || cursor.protocol_version != version.to_string()
670                || cursor.integrity != integrity(&cursor)
671            {
672                return Err(BrowserDomainDiscoveryError::InvalidCursor);
673            }
674            if cursor.snapshot != snapshot {
675                return Err(BrowserDomainDiscoveryError::StaleCursor);
676            }
677            if cursor.boundary == 0 || cursor.boundary >= studies.len() {
678                return Err(BrowserDomainDiscoveryError::InvalidCursor);
679            }
680            (cursor.boundary, cursor.direction)
681        }
682    };
683    let start = match direction {
684        Direction::Forward => boundary.min(studies.len()),
685        Direction::Backward => boundary.saturating_sub(limit as usize),
686    };
687    let end = (start + limit as usize).min(studies.len());
688    let page_studies = studies[start..end].to_vec();
689    let make_cursor = |direction, boundary| {
690        let mut value = cursor(
691            datastore_id,
692            limit,
693            direction,
694            boundary,
695            &version,
696            &snapshot,
697        );
698        value.collection = DOMAIN_STUDIES_COLLECTION.into();
699        value.domain_id = Some(domain_id);
700        value.integrity = integrity(&value);
701        encode_cursor(value)
702    };
703    Ok(BrowserDomainStudyCollectionResponse {
704        protocol_version: version.clone(),
705        datastore_id,
706        domain: DomainRef {
707            datastore_id,
708            kind: ahri_tre_protocol::refs::ObjectKind::Domain,
709            id: domain_id,
710        },
711        empty_state: page_studies.is_empty().then(|| BrowserDomainEmptyState {
712            code: "no_domain_studies".into(),
713            message: "No Studies are associated with this Domain.".into(),
714        }),
715        page: BrowserDomainPageMetadata {
716            limit,
717            returned: page_studies.len(),
718            ordering: ORDERING.into(),
719            ordering_description: STUDY_ORDERING_DESCRIPTION.into(),
720            previous_cursor: (start > 0).then(|| make_cursor(Direction::Backward, start)),
721            next_cursor: (end < studies.len()).then(|| make_cursor(Direction::Forward, end)),
722        },
723        studies: page_studies,
724    })
725}
726
727pub fn variable_collection_response(
728    mut source: Vec<BrowserDomainVariableSourceRecord>,
729    datastore_id: PublicUuid,
730    domain_id: PublicUuid,
731    limit: Option<u16>,
732    raw_cursor: Option<&str>,
733    requested_version: Option<&str>,
734) -> Result<BrowserDomainVariableCollectionResponse, BrowserDomainDiscoveryError> {
735    let version = requested_version
736        .map(ProtocolVersion::parse)
737        .transpose()
738        .map_err(|_| BrowserDomainDiscoveryError::UnsupportedVersion)?
739        .unwrap_or_default();
740    version
741        .ensure_supported(&ProtocolVersion::current())
742        .map_err(|_| BrowserDomainDiscoveryError::UnsupportedVersion)?;
743    let limit = limit.unwrap_or(DEFAULT_LIMIT);
744    if limit == 0 || limit > MAX_LIMIT {
745        return Err(BrowserDomainDiscoveryError::InvalidPage);
746    }
747    source.sort_by(|a, b| {
748        a.variable
749            .name
750            .to_lowercase()
751            .cmp(&b.variable.name.to_lowercase())
752            .then(a.variable.variable_id.0.cmp(&b.variable.variable_id.0))
753    });
754    let projected = source
755        .iter()
756        .map(|source| variable_record(datastore_id, source))
757        .collect::<Vec<_>>();
758    let snapshot = digest(&projected);
759    let (boundary, direction) = match raw_cursor {
760        None => (0, Direction::Forward),
761        Some(raw) => {
762            let cursor = decode_cursor(raw)?;
763            if cursor.datastore_id != datastore_id
764                || cursor.collection != DOMAIN_VARIABLES_COLLECTION
765                || cursor.domain_id != Some(domain_id)
766                || cursor.limit != limit
767                || cursor.ordering != ORDERING
768                || cursor.protocol_version != version.to_string()
769                || cursor.integrity != integrity(&cursor)
770            {
771                return Err(BrowserDomainDiscoveryError::InvalidCursor);
772            }
773            if cursor.snapshot != snapshot {
774                return Err(BrowserDomainDiscoveryError::StaleCursor);
775            }
776            if cursor.boundary == 0 || cursor.boundary >= projected.len() {
777                return Err(BrowserDomainDiscoveryError::InvalidCursor);
778            }
779            (cursor.boundary, cursor.direction)
780        }
781    };
782    let start = match direction {
783        Direction::Forward => boundary.min(projected.len()),
784        Direction::Backward => boundary.saturating_sub(limit as usize),
785    };
786    let end = (start + limit as usize).min(projected.len());
787    let make_cursor = |direction, boundary| {
788        let mut value = cursor(
789            datastore_id,
790            limit,
791            direction,
792            boundary,
793            &version,
794            &snapshot,
795        );
796        value.collection = DOMAIN_VARIABLES_COLLECTION.into();
797        value.domain_id = Some(domain_id);
798        value.integrity = integrity(&value);
799        encode_cursor(value)
800    };
801    let variables = projected[start..end].to_vec();
802    Ok(BrowserDomainVariableCollectionResponse {
803        protocol_version: version.clone(),
804        datastore_id,
805        domain: DomainRef {
806            datastore_id,
807            kind: ahri_tre_protocol::refs::ObjectKind::Domain,
808            id: domain_id,
809        },
810        empty_state: variables.is_empty().then(|| BrowserDomainEmptyState {
811            code: "no_domain_variables".into(),
812            message: "No Variables belong to this Domain.".into(),
813        }),
814        page: BrowserDomainPageMetadata {
815            limit,
816            returned: variables.len(),
817            ordering: ORDERING.into(),
818            ordering_description: VARIABLE_ORDERING_DESCRIPTION.into(),
819            previous_cursor: (start > 0).then(|| make_cursor(Direction::Backward, start)),
820            next_cursor: (end < projected.len()).then(|| make_cursor(Direction::Forward, end)),
821        },
822        variables,
823    })
824}
825
826pub fn variable_detail_response(
827    source: Vec<BrowserDomainVariableSourceRecord>,
828    datastore_id: PublicUuid,
829    domain_id: PublicUuid,
830    variable_id: PublicUuid,
831    requested_version: Option<&str>,
832) -> Result<BrowserDomainVariableDetailResponse, BrowserDomainDiscoveryError> {
833    let version = requested_version
834        .map(ProtocolVersion::parse)
835        .transpose()
836        .map_err(|_| BrowserDomainDiscoveryError::UnsupportedVersion)?
837        .unwrap_or_default();
838    version
839        .ensure_supported(&ProtocolVersion::current())
840        .map_err(|_| BrowserDomainDiscoveryError::UnsupportedVersion)?;
841    let source = source
842        .into_iter()
843        .find(|record| public_variable_id(record.variable.variable_id) == variable_id)
844        .ok_or(BrowserDomainDiscoveryError::VariableNotFound)?;
845    let facts = variable_record(datastore_id, &source);
846    Ok(BrowserDomainVariableDetailResponse {
847        protocol_version: version,
848        datastore_id,
849        variable: BrowserDomainVariableDetail {
850            facts,
851            domain: DomainRef {
852                datastore_id,
853                kind: ahri_tre_protocol::refs::ObjectKind::Domain,
854                id: domain_id,
855            },
856            value_format: source.variable.value_format,
857            tags: project_tags(datastore_id, source.tags),
858            ontology_namespace: source.variable.ontology_namespace,
859            ontology_class: source.variable.ontology_class,
860        },
861    })
862}
863
864pub fn vocabulary_detail_response(
865    source: BrowserVocabularySource,
866    datastore_id: PublicUuid,
867    domain_id: PublicUuid,
868    vocabulary_id: PublicUuid,
869) -> Result<BrowserVocabularyDetailResponse, BrowserDomainDiscoveryError> {
870    if public_vocabulary_id(source.vocabulary.vocabulary_id) != vocabulary_id
871        || public_domain_id(source.vocabulary.domain_id) != domain_id
872    {
873        return Err(BrowserDomainDiscoveryError::VocabularyNotFound);
874    }
875    Ok(BrowserVocabularyDetailResponse {
876        protocol_version: ProtocolVersion::default(),
877        datastore_id,
878        vocabulary: BrowserVocabularyDetail {
879            vocabulary: VocabularyRef {
880                datastore_id,
881                kind: ahri_tre_protocol::refs::ObjectKind::Vocabulary,
882                id: vocabulary_id,
883            },
884            domain: DomainRef {
885                datastore_id,
886                kind: ahri_tre_protocol::refs::ObjectKind::Domain,
887                id: domain_id,
888            },
889            name: source.vocabulary.name,
890            description: source.vocabulary.description,
891        },
892    })
893}
894
895pub fn vocabulary_item_collection_response(
896    mut source: BrowserVocabularySource,
897    datastore_id: PublicUuid,
898    domain_id: PublicUuid,
899    vocabulary_id: PublicUuid,
900    limit: Option<u16>,
901    raw_cursor: Option<&str>,
902) -> Result<BrowserVocabularyItemCollectionResponse, BrowserDomainDiscoveryError> {
903    if public_vocabulary_id(source.vocabulary.vocabulary_id) != vocabulary_id
904        || public_domain_id(source.vocabulary.domain_id) != domain_id
905    {
906        return Err(BrowserDomainDiscoveryError::VocabularyNotFound);
907    }
908    let version = ProtocolVersion::default();
909    let limit = limit.unwrap_or(DEFAULT_LIMIT);
910    if limit == 0 || limit > MAX_LIMIT {
911        return Err(BrowserDomainDiscoveryError::InvalidPage);
912    }
913    source
914        .items
915        .sort_by_key(|item| (item.value, item.vocabulary_item_id.0));
916    let projected = source
917        .items
918        .into_iter()
919        .map(|item| BrowserVocabularyItemRecord {
920            item: ahri_tre_protocol::refs::ObjectRef {
921                datastore_id,
922                kind: ahri_tre_protocol::refs::ObjectKind::VocabularyItem,
923                id: ahri_tre_protocol::refs::encode_scoped_integer_ref(
924                    "vocabulary_item",
925                    item.vocabulary_item_id.0,
926                ),
927            },
928            code: item.value,
929            label: item.code,
930            definition: item.description,
931        })
932        .collect::<Vec<_>>();
933    let snapshot = digest(&projected);
934    let (boundary, direction) = match raw_cursor {
935        None => (0, Direction::Forward),
936        Some(raw) => {
937            let cursor = decode_cursor(raw)?;
938            if cursor.datastore_id != datastore_id
939                || cursor.collection != VOCABULARY_ITEMS_COLLECTION
940                || cursor.domain_id != Some(domain_id)
941                || cursor.vocabulary_id != Some(vocabulary_id)
942                || cursor.limit != limit
943                || cursor.ordering != VOCABULARY_ITEM_ORDERING
944                || cursor.protocol_version != version.to_string()
945                || cursor.integrity != integrity(&cursor)
946            {
947                return Err(BrowserDomainDiscoveryError::InvalidCursor);
948            }
949            if cursor.snapshot != snapshot {
950                return Err(BrowserDomainDiscoveryError::StaleCursor);
951            }
952            if cursor.boundary == 0 || cursor.boundary >= projected.len() {
953                return Err(BrowserDomainDiscoveryError::InvalidCursor);
954            }
955            (cursor.boundary, cursor.direction)
956        }
957    };
958    let start = match direction {
959        Direction::Forward => boundary.min(projected.len()),
960        Direction::Backward => boundary.saturating_sub(limit as usize),
961    };
962    let end = (start + limit as usize).min(projected.len());
963    let make_cursor = |direction, boundary| {
964        let mut value = cursor(
965            datastore_id,
966            limit,
967            direction,
968            boundary,
969            &version,
970            &snapshot,
971        );
972        value.collection = VOCABULARY_ITEMS_COLLECTION.into();
973        value.ordering = VOCABULARY_ITEM_ORDERING.into();
974        value.domain_id = Some(domain_id);
975        value.vocabulary_id = Some(vocabulary_id);
976        value.integrity = integrity(&value);
977        encode_cursor(value)
978    };
979    let items = projected[start..end].to_vec();
980    Ok(BrowserVocabularyItemCollectionResponse {
981        protocol_version: version.clone(),
982        datastore_id,
983        domain: DomainRef {
984            datastore_id,
985            kind: ahri_tre_protocol::refs::ObjectKind::Domain,
986            id: domain_id,
987        },
988        vocabulary: VocabularyRef {
989            datastore_id,
990            kind: ahri_tre_protocol::refs::ObjectKind::Vocabulary,
991            id: vocabulary_id,
992        },
993        empty_state: items.is_empty().then(|| BrowserDomainEmptyState {
994            code: "no_vocabulary_items".into(),
995            message: "This Vocabulary has no categories.".into(),
996        }),
997        page: BrowserDomainPageMetadata {
998            limit,
999            returned: items.len(),
1000            ordering: VOCABULARY_ITEM_ORDERING.into(),
1001            ordering_description: VOCABULARY_ITEM_ORDERING_DESCRIPTION.into(),
1002            previous_cursor: (start > 0).then(|| make_cursor(Direction::Backward, start)),
1003            next_cursor: (end < projected.len()).then(|| make_cursor(Direction::Forward, end)),
1004        },
1005        items,
1006    })
1007}
1008
1009pub fn vocabulary_mapping_collection_response(
1010    mut source: BrowserVocabularySource,
1011    datastore_id: PublicUuid,
1012    domain_id: PublicUuid,
1013    vocabulary_id: PublicUuid,
1014    limit: Option<u16>,
1015    raw_cursor: Option<&str>,
1016) -> Result<BrowserVocabularyMappingCollectionResponse, BrowserDomainDiscoveryError> {
1017    if public_vocabulary_id(source.vocabulary.vocabulary_id) != vocabulary_id
1018        || public_domain_id(source.vocabulary.domain_id) != domain_id
1019    {
1020        return Err(BrowserDomainDiscoveryError::VocabularyNotFound);
1021    }
1022    let version = ProtocolVersion::default();
1023    let limit = limit.unwrap_or(DEFAULT_LIMIT);
1024    if limit == 0 || limit > MAX_LIMIT {
1025        return Err(BrowserDomainDiscoveryError::InvalidPage);
1026    }
1027    source.mappings.retain(|mapping| {
1028        mapping.from_item.vocabulary_id == source.vocabulary.vocabulary_id
1029            || mapping.to_item.vocabulary_id == source.vocabulary.vocabulary_id
1030    });
1031    source.mappings.sort_by_key(|mapping| {
1032        (
1033            mapping.from_item.vocabulary_id.0,
1034            mapping.from_item.value,
1035            mapping.from_item.vocabulary_item_id.0,
1036            mapping.to_item.vocabulary_id.0,
1037            mapping.to_item.value,
1038            mapping.to_item.vocabulary_item_id.0,
1039            mapping.mapping.vocabulary_mapping_id,
1040        )
1041    });
1042    let projected = source
1043        .mappings
1044        .into_iter()
1045        .map(|mapping| BrowserVocabularyMappingRecord {
1046            mapping: ahri_tre_protocol::refs::ObjectRef {
1047                datastore_id,
1048                kind: ahri_tre_protocol::refs::ObjectKind::VocabularyMapping,
1049                id: ahri_tre_protocol::refs::encode_scoped_integer_ref(
1050                    "vocabulary_mapping",
1051                    mapping.mapping.vocabulary_mapping_id,
1052                ),
1053            },
1054            source: mapping_endpoint(datastore_id, mapping.from_item, mapping.from_domain_id),
1055            target: mapping_endpoint(datastore_id, mapping.to_item, mapping.to_domain_id),
1056        })
1057        .collect::<Vec<_>>();
1058    let snapshot = digest(&projected);
1059    let (boundary, direction) = match raw_cursor {
1060        None => (0, Direction::Forward),
1061        Some(raw) => {
1062            let cursor = decode_cursor(raw)?;
1063            if cursor.datastore_id != datastore_id
1064                || cursor.collection != VOCABULARY_MAPPINGS_COLLECTION
1065                || cursor.domain_id != Some(domain_id)
1066                || cursor.vocabulary_id != Some(vocabulary_id)
1067                || cursor.limit != limit
1068                || cursor.ordering != VOCABULARY_MAPPING_ORDERING
1069                || cursor.protocol_version != version.to_string()
1070                || cursor.integrity != integrity(&cursor)
1071            {
1072                return Err(BrowserDomainDiscoveryError::InvalidCursor);
1073            }
1074            if cursor.snapshot != snapshot {
1075                return Err(BrowserDomainDiscoveryError::StaleCursor);
1076            }
1077            if cursor.boundary == 0 || cursor.boundary >= projected.len() {
1078                return Err(BrowserDomainDiscoveryError::InvalidCursor);
1079            }
1080            (cursor.boundary, cursor.direction)
1081        }
1082    };
1083    let start = match direction {
1084        Direction::Forward => boundary.min(projected.len()),
1085        Direction::Backward => boundary.saturating_sub(limit as usize),
1086    };
1087    let end = (start + limit as usize).min(projected.len());
1088    let make_cursor = |direction, boundary| {
1089        let mut value = cursor(
1090            datastore_id,
1091            limit,
1092            direction,
1093            boundary,
1094            &version,
1095            &snapshot,
1096        );
1097        value.collection = VOCABULARY_MAPPINGS_COLLECTION.into();
1098        value.ordering = VOCABULARY_MAPPING_ORDERING.into();
1099        value.domain_id = Some(domain_id);
1100        value.vocabulary_id = Some(vocabulary_id);
1101        value.integrity = integrity(&value);
1102        encode_cursor(value)
1103    };
1104    let mappings = projected[start..end].to_vec();
1105    Ok(BrowserVocabularyMappingCollectionResponse {
1106        protocol_version: version.clone(),
1107        datastore_id,
1108        domain: DomainRef {
1109            datastore_id,
1110            kind: ahri_tre_protocol::refs::ObjectKind::Domain,
1111            id: domain_id,
1112        },
1113        vocabulary: VocabularyRef {
1114            datastore_id,
1115            kind: ahri_tre_protocol::refs::ObjectKind::Vocabulary,
1116            id: vocabulary_id,
1117        },
1118        empty_state: mappings.is_empty().then(|| BrowserDomainEmptyState {
1119            code: "no_vocabulary_mappings".into(),
1120            message: "This Vocabulary has no mappings.".into(),
1121        }),
1122        page: BrowserDomainPageMetadata {
1123            limit,
1124            returned: mappings.len(),
1125            ordering: VOCABULARY_MAPPING_ORDERING.into(),
1126            ordering_description: VOCABULARY_MAPPING_ORDERING_DESCRIPTION.into(),
1127            previous_cursor: (start > 0).then(|| make_cursor(Direction::Backward, start)),
1128            next_cursor: (end < projected.len()).then(|| make_cursor(Direction::Forward, end)),
1129        },
1130        mappings,
1131    })
1132}
1133
1134fn mapping_endpoint(
1135    datastore_id: PublicUuid,
1136    item: ahri_tre_pgmeta::PgVocabularyItemReadModel,
1137    domain_id: DomainId,
1138) -> BrowserVocabularyMappingEndpoint {
1139    BrowserVocabularyMappingEndpoint {
1140        domain: DomainRef {
1141            datastore_id,
1142            kind: ahri_tre_protocol::refs::ObjectKind::Domain,
1143            id: public_domain_id(domain_id),
1144        },
1145        vocabulary: VocabularyRef {
1146            datastore_id,
1147            kind: ahri_tre_protocol::refs::ObjectKind::Vocabulary,
1148            id: public_vocabulary_id(item.vocabulary_id),
1149        },
1150        item: ahri_tre_protocol::refs::ObjectRef {
1151            datastore_id,
1152            kind: ahri_tre_protocol::refs::ObjectKind::VocabularyItem,
1153            id: ahri_tre_protocol::refs::encode_scoped_integer_ref(
1154                "vocabulary_item",
1155                item.vocabulary_item_id.0,
1156            ),
1157        },
1158        code: item.value,
1159        label: item.code,
1160    }
1161}
1162
1163fn variable_record(
1164    datastore_id: PublicUuid,
1165    source: &BrowserDomainVariableSourceRecord,
1166) -> BrowserDomainVariableRecord {
1167    BrowserDomainVariableRecord {
1168        variable: VariableRef {
1169            datastore_id,
1170            kind: ahri_tre_protocol::refs::ObjectKind::Variable,
1171            id: public_variable_id(source.variable.variable_id),
1172        },
1173        name: source.variable.name.clone(),
1174        value_type: source.variable.value_type.clone(),
1175        description: source.variable.description.clone(),
1176        vocabulary: source
1177            .vocabulary
1178            .as_ref()
1179            .map(|v| BrowserDomainVariableVocabulary {
1180                vocabulary: VocabularyRef {
1181                    datastore_id,
1182                    kind: ahri_tre_protocol::refs::ObjectKind::Vocabulary,
1183                    id: ahri_tre_protocol::refs::encode_scoped_integer_ref(
1184                        "vocabulary",
1185                        v.vocabulary_id.0,
1186                    ),
1187                },
1188                name: v.name.clone(),
1189            }),
1190    }
1191}
1192
1193fn digest<T: Serialize>(value: &T) -> String {
1194    Sha256::digest(serde_json::to_vec(value).expect("snapshot serializes"))
1195        .iter()
1196        .map(|byte| format!("{byte:02x}"))
1197        .collect()
1198}
1199
1200fn project_tags(
1201    datastore_id: PublicUuid,
1202    tags: Vec<ahri_tre_pgmeta::PgTagReadModel>,
1203) -> Vec<TagSummary> {
1204    tags.into_iter()
1205        .map(|tag| TagSummary {
1206            tag: TagRef {
1207                datastore_id,
1208                kind: ahri_tre_protocol::refs::ObjectKind::Tag,
1209                id: ahri_tre_protocol::refs::encode_scoped_integer_ref("tag", tag.tag_id),
1210            },
1211            scope: TagScope::Global,
1212            label: tag.name,
1213        })
1214        .collect()
1215}
1216
1217pub fn public_variable_id(id: VariableId) -> PublicUuid {
1218    ahri_tre_protocol::refs::encode_scoped_integer_ref("variable", id.0)
1219}
1220pub fn public_vocabulary_id(id: VocabularyId) -> PublicUuid {
1221    ahri_tre_protocol::refs::encode_scoped_integer_ref("vocabulary", id.0)
1222}
1223pub fn internal_vocabulary_id(id: PublicUuid) -> Option<VocabularyId> {
1224    ahri_tre_protocol::refs::decode_scoped_integer_ref("vocabulary", id).map(VocabularyId)
1225}
1226
1227fn project(datastore_id: PublicUuid, record: BrowserDomainSourceRecord) -> BrowserDomainRecord {
1228    BrowserDomainRecord {
1229        domain: DomainRef {
1230            datastore_id,
1231            kind: ahri_tre_protocol::refs::ObjectKind::Domain,
1232            id: public_domain_id(record.domain.domain_id),
1233        },
1234        name: record.domain.name,
1235        description: record.domain.description,
1236        uri: record.domain.uri,
1237        tags: record
1238            .tags
1239            .into_iter()
1240            .map(|tag| TagSummary {
1241                tag: TagRef {
1242                    datastore_id,
1243                    kind: ahri_tre_protocol::refs::ObjectKind::Tag,
1244                    id: ahri_tre_protocol::refs::encode_scoped_integer_ref("tag", tag.tag_id),
1245                },
1246                scope: TagScope::Global,
1247                label: tag.name,
1248            })
1249            .collect(),
1250    }
1251}
1252pub fn public_domain_id(id: DomainId) -> PublicUuid {
1253    ahri_tre_protocol::refs::encode_scoped_integer_ref("domain", id.0)
1254}
1255pub fn internal_domain_id(id: PublicUuid) -> Option<DomainId> {
1256    ahri_tre_protocol::refs::decode_scoped_integer_ref("domain", id).map(DomainId)
1257}
1258
1259fn snapshot(source: &[BrowserDomainSourceRecord]) -> String {
1260    let mut hash = 0xcbf29ce484222325_u64;
1261    for byte in serde_json::to_vec(
1262        &source
1263            .iter()
1264            .map(|r| {
1265                (
1266                    &r.domain.name,
1267                    r.domain.domain_id.0,
1268                    &r.domain.description,
1269                    &r.domain.uri,
1270                    r.tags
1271                        .iter()
1272                        .map(|t| (&t.name, t.tag_id))
1273                        .collect::<Vec<_>>(),
1274                )
1275            })
1276            .collect::<Vec<_>>(),
1277    )
1278    .expect("snapshot serializes")
1279    {
1280        hash ^= u64::from(byte);
1281        hash = hash.wrapping_mul(0x100000001b3);
1282    }
1283    format!("{hash:016x}")
1284}
1285fn study_snapshot(source: &[ahri_tre_protocol::study::BrowserStudyFacts]) -> String {
1286    let bytes = serde_json::to_vec(source).expect("Study snapshot serializes");
1287    let digest = Sha256::digest(bytes);
1288    digest.iter().map(|byte| format!("{byte:02x}")).collect()
1289}
1290fn cursor(
1291    datastore_id: PublicUuid,
1292    limit: u16,
1293    direction: Direction,
1294    boundary: usize,
1295    version: &ProtocolVersion,
1296    snapshot: &str,
1297) -> Cursor {
1298    let mut value = Cursor {
1299        datastore_id,
1300        collection: COLLECTION.into(),
1301        limit,
1302        ordering: ORDERING.into(),
1303        direction,
1304        boundary,
1305        protocol_version: version.to_string(),
1306        snapshot: snapshot.into(),
1307        filters: BrowserDomainCollectionFilters::default(),
1308        domain_id: None,
1309        vocabulary_id: None,
1310        integrity: String::new(),
1311    };
1312    value.integrity = integrity(&value);
1313    value
1314}
1315
1316fn domain_cursor(
1317    datastore_id: PublicUuid,
1318    limit: u16,
1319    direction: Direction,
1320    boundary: usize,
1321    version: &ProtocolVersion,
1322    snapshot: &str,
1323    filters: &BrowserDomainCollectionFilters,
1324) -> Cursor {
1325    let mut value = cursor(datastore_id, limit, direction, boundary, version, snapshot);
1326    value.filters = filters.clone();
1327    value.integrity = integrity(&value);
1328    value
1329}
1330fn integrity(cursor: &Cursor) -> String {
1331    let text = format!(
1332        "{}|{}|{}|{}|{:?}|{}|{}|{}|{:?}|{:?}|{:?}",
1333        cursor.datastore_id,
1334        cursor.collection,
1335        cursor.limit,
1336        cursor.ordering,
1337        cursor.direction,
1338        cursor.boundary,
1339        cursor.protocol_version,
1340        cursor.snapshot,
1341        cursor.domain_id,
1342        cursor.vocabulary_id,
1343        cursor.filters
1344    );
1345    hmac_sha256(cursor_mac_key(), text.as_bytes())
1346        .into_iter()
1347        .map(|byte| format!("{byte:02x}"))
1348        .collect()
1349}
1350
1351fn cursor_mac_key() -> &'static [u8; 16] {
1352    CURSOR_MAC_KEY.get_or_init(|| *Uuid::new_v4().as_bytes())
1353}
1354
1355fn encode_cursor(cursor: Cursor) -> String {
1356    serde_json::to_vec(&cursor)
1357        .expect("cursor serializes")
1358        .into_iter()
1359        .map(|byte| format!("{byte:02x}"))
1360        .collect()
1361}
1362fn decode_cursor(raw: &str) -> Result<Cursor, BrowserDomainDiscoveryError> {
1363    if !raw.len().is_multiple_of(2) {
1364        return Err(BrowserDomainDiscoveryError::InvalidCursor);
1365    }
1366    let bytes = (0..raw.len())
1367        .step_by(2)
1368        .map(|index| {
1369            u8::from_str_radix(&raw[index..index + 2], 16)
1370                .map_err(|_| BrowserDomainDiscoveryError::InvalidCursor)
1371        })
1372        .collect::<Result<Vec<_>, _>>()?;
1373    serde_json::from_slice(&bytes).map_err(|_| BrowserDomainDiscoveryError::InvalidCursor)
1374}
1375
1376#[cfg(test)]
1377mod tests {
1378    use super::*;
1379    use ahri_tre_types::{KeyRole, StudyId, ValueTypeId, VocabularyId};
1380    fn record(id: i64, name: &str) -> BrowserDomainSourceRecord {
1381        BrowserDomainSourceRecord {
1382            domain: ahri_tre_pgmeta::PgDomainReadModel {
1383                domain_id: DomainId(id),
1384                name: name.into(),
1385                uri: Some(format!("https://example.test/{id}")),
1386                description: Some(format!("Domain {id}")),
1387            },
1388            tags: vec![],
1389        }
1390    }
1391    fn tagged_record(
1392        id: i64,
1393        name: &str,
1394        description: &str,
1395        tag_id: i64,
1396        tag: &str,
1397    ) -> BrowserDomainSourceRecord {
1398        let mut record = record(id, name);
1399        record.domain.description = Some(description.into());
1400        record.tags.push(ahri_tre_pgmeta::PgTagReadModel {
1401            tag_id,
1402            name: tag.into(),
1403        });
1404        record
1405    }
1406
1407    #[test]
1408    fn filters_domains_before_ordering_and_paging_with_bound_cursors() {
1409        let datastore_id = PublicUuid::from_uuid(Uuid::from_u128(42));
1410        let filters = ahri_tre_protocol::domain::BrowserDomainCollectionFilters {
1411            text: Some(ahri_tre_protocol::domain::BrowserDomainTextFilter {
1412                mode: ahri_tre_protocol::search::TextMode::Contains,
1413                value: "  health  ".into(),
1414            }),
1415            any_tags: vec![ahri_tre_protocol::refs::TagSelector::Label {
1416                scope: TagScope::Global,
1417                label: ahri_tre_protocol::PublicName::new("population").unwrap(),
1418            }],
1419        };
1420        let source = vec![
1421            tagged_record(1, "Alpha", "Health", 2, "other"),
1422            tagged_record(2, "Beta", "Other", 1, "population"),
1423            tagged_record(3, "Gamma", "Health records", 1, "population"),
1424            tagged_record(4, "Zulu", "Health systems", 1, "population"),
1425        ];
1426
1427        let response =
1428            collection_response(source.clone(), datastore_id, filters, Some(1), None, None)
1429                .unwrap();
1430
1431        assert_eq!(response.domains[0].name, "Gamma");
1432        assert_eq!(response.returned_count, 1);
1433        assert!(response.has_more);
1434        assert_eq!(response.filters.text.as_ref().unwrap().value, "health");
1435
1436        let mismatch = collection_response(
1437            source,
1438            datastore_id,
1439            ahri_tre_protocol::domain::BrowserDomainCollectionFilters::default(),
1440            Some(1),
1441            response.page.next_cursor.as_deref(),
1442            None,
1443        );
1444        assert_eq!(
1445            mismatch.unwrap_err(),
1446            BrowserDomainDiscoveryError::InvalidCursor
1447        );
1448
1449        let cursor = response.page.next_cursor.as_deref().unwrap();
1450        for mismatch in [
1451            collection_response(
1452                vec![
1453                    tagged_record(3, "Gamma", "Health records", 1, "population"),
1454                    tagged_record(4, "Zulu", "Health systems", 1, "population"),
1455                ],
1456                PublicUuid::from_uuid(Uuid::from_u128(43)),
1457                response.filters.clone(),
1458                Some(1),
1459                Some(cursor),
1460                None,
1461            ),
1462            collection_response(
1463                vec![
1464                    tagged_record(3, "Gamma", "Health records", 1, "population"),
1465                    tagged_record(4, "Zulu", "Health systems", 1, "population"),
1466                ],
1467                datastore_id,
1468                response.filters.clone(),
1469                Some(2),
1470                Some(cursor),
1471                None,
1472            ),
1473        ] {
1474            assert_eq!(
1475                mismatch.unwrap_err(),
1476                BrowserDomainDiscoveryError::InvalidCursor
1477            );
1478        }
1479
1480        let mut decoded = decode_cursor(cursor).unwrap();
1481        decoded.collection = DOMAIN_VARIABLES_COLLECTION.into();
1482        decoded.integrity = integrity(&decoded);
1483        assert_eq!(
1484            collection_response(
1485                vec![
1486                    tagged_record(3, "Gamma", "Health records", 1, "population"),
1487                    tagged_record(4, "Zulu", "Health systems", 1, "population"),
1488                ],
1489                datastore_id,
1490                response.filters.clone(),
1491                Some(1),
1492                Some(&encode_cursor(decoded)),
1493                None,
1494            )
1495            .unwrap_err(),
1496            BrowserDomainDiscoveryError::InvalidCursor
1497        );
1498
1499        let mut decoded = decode_cursor(cursor).unwrap();
1500        decoded.protocol_version = "1.0.1".into();
1501        decoded.integrity = integrity(&decoded);
1502        assert_eq!(
1503            collection_response(
1504                vec![
1505                    tagged_record(3, "Gamma", "Health records", 1, "population"),
1506                    tagged_record(4, "Zulu", "Health systems", 1, "population"),
1507                ],
1508                datastore_id,
1509                response.filters,
1510                Some(1),
1511                Some(&encode_cursor(decoded)),
1512                None,
1513            )
1514            .unwrap_err(),
1515            BrowserDomainDiscoveryError::InvalidCursor
1516        );
1517    }
1518
1519    #[test]
1520    fn domain_text_filter_is_unicode_case_insensitive() {
1521        let response = collection_response(
1522            vec![tagged_record(
1523                1,
1524                "Research",
1525                "ÉTUDE health",
1526                1,
1527                "population",
1528            )],
1529            PublicUuid::from_uuid(Uuid::from_u128(42)),
1530            BrowserDomainCollectionFilters {
1531                text: Some(BrowserDomainTextFilter {
1532                    mode: ahri_tre_protocol::search::TextMode::Contains,
1533                    value: "étude".into(),
1534                }),
1535                any_tags: vec![],
1536            },
1537            Some(10),
1538            None,
1539            None,
1540        )
1541        .unwrap();
1542
1543        assert_eq!(response.returned_count, 1);
1544    }
1545    #[test]
1546    fn traverses_forward_and_backward_without_totals() {
1547        let id = PublicUuid::from_uuid(Uuid::from_u128(42));
1548        let source = vec![record(3, "Zulu"), record(1, "Alpha"), record(2, "Beta")];
1549        let first = collection_response(
1550            source.clone(),
1551            id,
1552            BrowserDomainCollectionFilters::default(),
1553            Some(2),
1554            None,
1555            None,
1556        )
1557        .unwrap();
1558        assert_eq!(
1559            first
1560                .domains
1561                .iter()
1562                .map(|d| d.name.as_str())
1563                .collect::<Vec<_>>(),
1564            vec!["Alpha", "Beta"]
1565        );
1566        assert!(first.page.previous_cursor.is_none());
1567        let second = collection_response(
1568            source.clone(),
1569            id,
1570            BrowserDomainCollectionFilters::default(),
1571            Some(2),
1572            first.page.next_cursor.as_deref(),
1573            None,
1574        )
1575        .unwrap();
1576        assert_eq!(second.domains[0].name, "Zulu");
1577        assert!(second.page.next_cursor.is_none());
1578        let back = collection_response(
1579            source,
1580            id,
1581            BrowserDomainCollectionFilters::default(),
1582            Some(2),
1583            second.page.previous_cursor.as_deref(),
1584            None,
1585        )
1586        .unwrap();
1587        assert_eq!(back.domains[0].name, "Alpha");
1588    }
1589    #[test]
1590    fn rejects_mismatched_and_stale_cursors() {
1591        let id = PublicUuid::from_uuid(Uuid::from_u128(42));
1592        let first = collection_response(
1593            vec![record(1, "Alpha"), record(2, "Beta")],
1594            id,
1595            BrowserDomainCollectionFilters::default(),
1596            Some(1),
1597            None,
1598            None,
1599        )
1600        .unwrap();
1601        let raw = first.page.next_cursor.as_deref();
1602        assert_eq!(
1603            collection_response(
1604                vec![record(1, "Alpha"), record(2, "Beta")],
1605                id,
1606                BrowserDomainCollectionFilters::default(),
1607                Some(2),
1608                raw,
1609                None
1610            )
1611            .unwrap_err(),
1612            BrowserDomainDiscoveryError::InvalidCursor
1613        );
1614        assert_eq!(
1615            collection_response(
1616                vec![record(1, "Alpha"), record(2, "Changed")],
1617                id,
1618                BrowserDomainCollectionFilters::default(),
1619                Some(1),
1620                raw,
1621                None
1622            )
1623            .unwrap_err(),
1624            BrowserDomainDiscoveryError::StaleCursor
1625        );
1626    }
1627    #[test]
1628    fn detail_contains_only_canonical_facts() {
1629        let id = PublicUuid::from_uuid(Uuid::from_u128(42));
1630        let response = detail_response(
1631            vec![record(7, "Health")],
1632            id,
1633            public_domain_id(DomainId(7)),
1634            None,
1635        )
1636        .unwrap();
1637        let json = serde_json::to_value(response).unwrap();
1638        assert_eq!(json["domain"]["name"], "Health");
1639        assert!(json["domain"].get("studies").is_none());
1640        assert!(json["domain"].get("variables").is_none());
1641    }
1642
1643    #[test]
1644    fn domain_studies_are_association_scoped_and_parent_bound() {
1645        let datastore_id = PublicUuid::from_uuid(Uuid::from_u128(42));
1646        let selected = public_domain_id(DomainId(7));
1647        let other = public_domain_id(DomainId(8));
1648        let source = BrowserStudyDiscoverySource {
1649            datastore_id,
1650            studies: vec![study_record(3, "Zulu", 7), study_record(1, "Alpha", 7)],
1651        };
1652        let first =
1653            study_collection_response(source.clone(), selected, Some(1), None, None).unwrap();
1654        assert_eq!(first.studies[0].name, "Alpha");
1655        assert!(
1656            serde_json::to_value(&first.studies[0])
1657                .unwrap()
1658                .get("access_request")
1659                .is_none()
1660        );
1661        let cursor = first.page.next_cursor.as_deref().unwrap();
1662        let second =
1663            study_collection_response(source.clone(), selected, Some(1), Some(cursor), None)
1664                .unwrap();
1665        assert_eq!(second.studies[0].name, "Zulu");
1666        let previous = second.page.previous_cursor.as_deref().unwrap();
1667        let backward =
1668            study_collection_response(source.clone(), selected, Some(1), Some(previous), None)
1669                .unwrap();
1670        assert_eq!(backward.studies[0].name, "Alpha");
1671        assert_eq!(
1672            study_collection_response(source.clone(), other, Some(1), Some(cursor), None)
1673                .unwrap_err(),
1674            BrowserDomainDiscoveryError::InvalidCursor
1675        );
1676        let mut changed = source;
1677        changed.studies[1].study.name = "Changed".into();
1678        assert_eq!(
1679            study_collection_response(changed, selected, Some(1), Some(cursor), None).unwrap_err(),
1680            BrowserDomainDiscoveryError::StaleCursor
1681        );
1682    }
1683
1684    #[test]
1685    fn domain_variables_are_canonical_paginated_and_parent_bound() {
1686        let datastore = PublicUuid::from_uuid(Uuid::from_u128(42));
1687        let domain = public_domain_id(DomainId(7));
1688        let other = public_domain_id(DomainId(8));
1689        let source = vec![
1690            variable_record_source(2, "Zulu", 7),
1691            variable_record_source(1, "Alpha", 7),
1692        ];
1693        let first =
1694            variable_collection_response(source.clone(), datastore, domain, Some(1), None, None)
1695                .unwrap();
1696        assert_eq!(first.variables[0].name, "Alpha");
1697        let json = serde_json::to_value(&first.variables[0]).unwrap();
1698        assert!(json.get("key_role").is_none());
1699        assert!(json.get("row_role").is_none());
1700        assert!(json.get("note").is_none());
1701        let cursor = first.page.next_cursor.as_deref().unwrap();
1702        assert_eq!(
1703            variable_collection_response(
1704                source.clone(),
1705                datastore,
1706                other,
1707                Some(1),
1708                Some(cursor),
1709                None
1710            )
1711            .unwrap_err(),
1712            BrowserDomainDiscoveryError::InvalidCursor
1713        );
1714        let mut changed = source.clone();
1715        changed[1].variable.name = "Changed".into();
1716        assert_eq!(
1717            variable_collection_response(changed, datastore, domain, Some(1), Some(cursor), None)
1718                .unwrap_err(),
1719            BrowserDomainDiscoveryError::StaleCursor
1720        );
1721        let detail = variable_detail_response(
1722            source,
1723            datastore,
1724            domain,
1725            public_variable_id(VariableId(1)),
1726            None,
1727        )
1728        .unwrap();
1729        let json = serde_json::to_value(detail).unwrap();
1730        assert_eq!(json["variable"]["domain"]["id"], domain.to_string());
1731        assert!(json["variable"].get("dataset").is_none());
1732        assert!(json["variable"].get("note").is_none());
1733    }
1734
1735    #[test]
1736    fn vocabulary_categories_preserve_meaning_and_support_bound_traversal() {
1737        let datastore = PublicUuid::from_uuid(Uuid::from_u128(42));
1738        let domain = public_domain_id(DomainId(7));
1739        let vocabulary = public_vocabulary_id(VocabularyId(9));
1740        let source = vocabulary_source();
1741        let detail =
1742            vocabulary_detail_response(source.clone(), datastore, domain, vocabulary).unwrap();
1743        assert_eq!(detail.vocabulary.name, "Status");
1744        let first = vocabulary_item_collection_response(
1745            source.clone(),
1746            datastore,
1747            domain,
1748            vocabulary,
1749            Some(1),
1750            None,
1751        )
1752        .unwrap();
1753        assert_eq!(first.items[0].code, 10);
1754        assert_eq!(first.items[0].label, "Open");
1755        assert_eq!(first.items[0].definition.as_deref(), Some("Currently open"));
1756        let json = serde_json::to_value(&first.items[0]).unwrap();
1757        assert!(json.get("value").is_none());
1758        assert!(json.get("description").is_none());
1759        assert!(json.get("frequency").is_none());
1760        let next = first.page.next_cursor.as_deref().unwrap();
1761        let second = vocabulary_item_collection_response(
1762            source.clone(),
1763            datastore,
1764            domain,
1765            vocabulary,
1766            Some(1),
1767            Some(next),
1768        )
1769        .unwrap();
1770        assert_eq!(second.items[0].label, "Closed");
1771        assert!(second.items[0].definition.is_none());
1772        assert!(second.page.next_cursor.is_none());
1773        let previous = second.page.previous_cursor.as_deref().unwrap();
1774        assert_eq!(
1775            vocabulary_item_collection_response(
1776                source.clone(),
1777                datastore,
1778                domain,
1779                vocabulary,
1780                Some(1),
1781                Some(previous)
1782            )
1783            .unwrap()
1784            .items[0]
1785                .label,
1786            "Open"
1787        );
1788        let other = public_vocabulary_id(VocabularyId(8));
1789        assert_eq!(
1790            vocabulary_item_collection_response(
1791                source.clone(),
1792                datastore,
1793                domain,
1794                other,
1795                Some(1),
1796                Some(next)
1797            )
1798            .unwrap_err(),
1799            BrowserDomainDiscoveryError::VocabularyNotFound
1800        );
1801        let mut changed = source;
1802        changed.items[0].code = "Changed".into();
1803        assert_eq!(
1804            vocabulary_item_collection_response(
1805                changed,
1806                datastore,
1807                domain,
1808                vocabulary,
1809                Some(1),
1810                Some(next)
1811            )
1812            .unwrap_err(),
1813            BrowserDomainDiscoveryError::StaleCursor
1814        );
1815    }
1816
1817    #[test]
1818    fn vocabulary_mappings_preserve_direction_cross_domain_identity_and_traversal() {
1819        let datastore = PublicUuid::from_uuid(Uuid::from_u128(42));
1820        let domain = public_domain_id(DomainId(7));
1821        let vocabulary = public_vocabulary_id(VocabularyId(9));
1822        let source = vocabulary_mapping_source();
1823        let first = vocabulary_mapping_collection_response(
1824            source.clone(),
1825            datastore,
1826            domain,
1827            vocabulary,
1828            Some(1),
1829            None,
1830        )
1831        .unwrap();
1832        assert_eq!(first.mappings.len(), 1);
1833        assert_eq!(first.mappings[0].source.code, 10);
1834        assert_eq!(first.mappings[0].source.label, "Open");
1835        assert_eq!(first.mappings[0].target.code, 20);
1836        assert_eq!(first.mappings[0].target.label, "Done");
1837        assert_eq!(first.mappings[0].source.domain.id, domain);
1838        assert_eq!(
1839            first.mappings[0].target.domain.id,
1840            public_domain_id(DomainId(8))
1841        );
1842        assert_ne!(
1843            first.mappings[0].source.vocabulary.id,
1844            first.mappings[0].target.vocabulary.id
1845        );
1846        let json = serde_json::to_value(&first.mappings[0]).unwrap();
1847        assert!(json.get("from_value").is_none());
1848        assert!(json.get("to_code").is_none());
1849        assert!(json["source"].get("definition").is_none());
1850        let next = first.page.next_cursor.as_deref().unwrap();
1851        let terminal = vocabulary_mapping_collection_response(
1852            source.clone(),
1853            datastore,
1854            domain,
1855            vocabulary,
1856            Some(1),
1857            Some(next),
1858        )
1859        .unwrap();
1860        assert_eq!(terminal.mappings[0].source.label, "Closed");
1861        assert!(terminal.page.previous_cursor.is_some());
1862        assert!(terminal.page.next_cursor.is_some());
1863        let final_cursor = terminal.page.next_cursor.as_deref().unwrap();
1864        let final_page = vocabulary_mapping_collection_response(
1865            source.clone(),
1866            datastore,
1867            domain,
1868            vocabulary,
1869            Some(1),
1870            Some(final_cursor),
1871        )
1872        .unwrap();
1873        assert_eq!(
1874            final_page.mappings[0].source.vocabulary.id,
1875            public_vocabulary_id(VocabularyId(12))
1876        );
1877        assert_eq!(final_page.mappings[0].target.vocabulary.id, vocabulary);
1878        assert_eq!(
1879            final_page.mappings[0].source.domain.id,
1880            public_domain_id(DomainId(8))
1881        );
1882        assert_eq!(final_page.mappings[0].target.domain.id, domain);
1883        assert!(final_page.page.next_cursor.is_none());
1884        let previous = final_page.page.previous_cursor.as_deref().unwrap();
1885        assert_eq!(
1886            vocabulary_mapping_collection_response(
1887                source.clone(),
1888                datastore,
1889                domain,
1890                vocabulary,
1891                Some(1),
1892                Some(previous),
1893            )
1894            .unwrap()
1895            .mappings[0]
1896                .source
1897                .label,
1898            "Closed"
1899        );
1900        let mut empty = source.clone();
1901        empty.mappings.clear();
1902        let empty = vocabulary_mapping_collection_response(
1903            empty, datastore, domain, vocabulary, None, None,
1904        )
1905        .unwrap();
1906        assert!(empty.mappings.is_empty());
1907        assert_eq!(empty.empty_state.unwrap().code, "no_vocabulary_mappings");
1908        assert_eq!(
1909            vocabulary_mapping_collection_response(
1910                source.clone(),
1911                datastore,
1912                public_domain_id(DomainId(8)),
1913                vocabulary,
1914                Some(1),
1915                None,
1916            )
1917            .unwrap_err(),
1918            BrowserDomainDiscoveryError::VocabularyNotFound
1919        );
1920        let other = public_vocabulary_id(VocabularyId(8));
1921        assert_eq!(
1922            vocabulary_mapping_collection_response(
1923                source.clone(),
1924                datastore,
1925                domain,
1926                other,
1927                Some(1),
1928                Some(next),
1929            )
1930            .unwrap_err(),
1931            BrowserDomainDiscoveryError::VocabularyNotFound
1932        );
1933        let mut changed = source;
1934        changed.mappings[0].to_item.code = "Changed".into();
1935        assert_eq!(
1936            vocabulary_mapping_collection_response(
1937                changed.clone(),
1938                datastore,
1939                domain,
1940                vocabulary,
1941                Some(1),
1942                Some(next),
1943            )
1944            .unwrap_err(),
1945            BrowserDomainDiscoveryError::StaleCursor
1946        );
1947        changed.mappings[0].to_item.code = "Done".into();
1948        changed.mappings[0].to_domain_id = DomainId(9);
1949        assert_eq!(
1950            vocabulary_mapping_collection_response(
1951                changed,
1952                datastore,
1953                domain,
1954                vocabulary,
1955                Some(1),
1956                Some(next),
1957            )
1958            .unwrap_err(),
1959            BrowserDomainDiscoveryError::StaleCursor
1960        );
1961    }
1962
1963    fn vocabulary_source() -> BrowserVocabularySource {
1964        BrowserVocabularySource {
1965            vocabulary: ahri_tre_pgmeta::PgVocabularyReadModel {
1966                vocabulary_id: VocabularyId(9),
1967                domain_id: DomainId(7),
1968                name: "Status".into(),
1969                description: Some("Canonical status".into()),
1970            },
1971            items: vec![
1972                ahri_tre_pgmeta::PgVocabularyItemReadModel {
1973                    vocabulary_item_id: ahri_tre_types::VocabularyItemId(2),
1974                    vocabulary_id: VocabularyId(9),
1975                    value: 10,
1976                    code: "Closed".into(),
1977                    description: None,
1978                },
1979                ahri_tre_pgmeta::PgVocabularyItemReadModel {
1980                    vocabulary_item_id: ahri_tre_types::VocabularyItemId(1),
1981                    vocabulary_id: VocabularyId(9),
1982                    value: 10,
1983                    code: "Open".into(),
1984                    description: Some("Currently open".into()),
1985                },
1986            ],
1987            mappings: vec![],
1988        }
1989    }
1990
1991    fn vocabulary_mapping_source() -> BrowserVocabularySource {
1992        let mut source = vocabulary_source();
1993        source.items = vec![];
1994        source.mappings = vec![
1995            BrowserVocabularyMappingSourceRecord {
1996                mapping: ahri_tre_pgmeta::PgVocabularyMappingReadModel {
1997                    vocabulary_mapping_id: 2,
1998                    from_vocabulary_item: ahri_tre_types::VocabularyItemId(1),
1999                    to_vocabulary_item: ahri_tre_types::VocabularyItemId(4),
2000                },
2001                from_item: ahri_tre_pgmeta::PgVocabularyItemReadModel {
2002                    vocabulary_item_id: ahri_tre_types::VocabularyItemId(1),
2003                    vocabulary_id: VocabularyId(9),
2004                    value: 10,
2005                    code: "Open".into(),
2006                    description: Some("Not projected here".into()),
2007                },
2008                from_domain_id: DomainId(7),
2009                to_item: ahri_tre_pgmeta::PgVocabularyItemReadModel {
2010                    vocabulary_item_id: ahri_tre_types::VocabularyItemId(4),
2011                    vocabulary_id: VocabularyId(12),
2012                    value: 20,
2013                    code: "Done".into(),
2014                    description: Some("Also not projected here".into()),
2015                },
2016                to_domain_id: DomainId(8),
2017            },
2018            BrowserVocabularyMappingSourceRecord {
2019                mapping: ahri_tre_pgmeta::PgVocabularyMappingReadModel {
2020                    vocabulary_mapping_id: 3,
2021                    from_vocabulary_item: ahri_tre_types::VocabularyItemId(3),
2022                    to_vocabulary_item: ahri_tre_types::VocabularyItemId(5),
2023                },
2024                from_item: ahri_tre_pgmeta::PgVocabularyItemReadModel {
2025                    vocabulary_item_id: ahri_tre_types::VocabularyItemId(3),
2026                    vocabulary_id: VocabularyId(9),
2027                    value: 10,
2028                    code: "Closed".into(),
2029                    description: None,
2030                },
2031                from_domain_id: DomainId(7),
2032                to_item: ahri_tre_pgmeta::PgVocabularyItemReadModel {
2033                    vocabulary_item_id: ahri_tre_types::VocabularyItemId(5),
2034                    vocabulary_id: VocabularyId(12),
2035                    value: 21,
2036                    code: "Pending".into(),
2037                    description: None,
2038                },
2039                to_domain_id: DomainId(8),
2040            },
2041            BrowserVocabularyMappingSourceRecord {
2042                mapping: ahri_tre_pgmeta::PgVocabularyMappingReadModel {
2043                    vocabulary_mapping_id: 1,
2044                    from_vocabulary_item: ahri_tre_types::VocabularyItemId(6),
2045                    to_vocabulary_item: ahri_tre_types::VocabularyItemId(2),
2046                },
2047                from_item: ahri_tre_pgmeta::PgVocabularyItemReadModel {
2048                    vocabulary_item_id: ahri_tre_types::VocabularyItemId(6),
2049                    vocabulary_id: VocabularyId(12),
2050                    value: 30,
2051                    code: "Referred".into(),
2052                    description: None,
2053                },
2054                from_domain_id: DomainId(8),
2055                to_item: ahri_tre_pgmeta::PgVocabularyItemReadModel {
2056                    vocabulary_item_id: ahri_tre_types::VocabularyItemId(2),
2057                    vocabulary_id: VocabularyId(9),
2058                    value: 10,
2059                    code: "Open".into(),
2060                    description: None,
2061                },
2062                to_domain_id: DomainId(7),
2063            },
2064        ];
2065        source
2066    }
2067
2068    fn variable_record_source(
2069        id: i64,
2070        name: &str,
2071        domain: i64,
2072    ) -> BrowserDomainVariableSourceRecord {
2073        BrowserDomainVariableSourceRecord {
2074            variable: ahri_tre_pgmeta::PgVariableReadModel {
2075                variable_id: VariableId(id),
2076                domain_id: DomainId(domain),
2077                name: name.into(),
2078                value_type_id: ValueTypeId(1),
2079                value_type: "xsd:string".into(),
2080                value_format: Some("token".into()),
2081                vocabulary_id: Some(VocabularyId(9)),
2082                key_role: KeyRole::Record,
2083                description: Some(format!("{name} description")),
2084                note: Some("protected".into()),
2085                ontology_namespace: Some("https://example.test/ontology/".into()),
2086                ontology_class: Some("C1".into()),
2087            },
2088            vocabulary: Some(ahri_tre_pgmeta::PgVocabularyReadModel {
2089                vocabulary_id: VocabularyId(9),
2090                domain_id: DomainId(domain),
2091                name: "Status".into(),
2092                description: None,
2093            }),
2094            tags: vec![],
2095        }
2096    }
2097
2098    fn study_record(
2099        id: u128,
2100        name: &str,
2101        domain_id: i64,
2102    ) -> crate::browser_study_discovery::BrowserStudyDiscoverySourceRecord {
2103        crate::browser_study_discovery::BrowserStudyDiscoverySourceRecord {
2104            study: ahri_tre_pgmeta::PgStudyReadModel {
2105                study_id: StudyId(Uuid::from_u128(id)),
2106                name: name.into(),
2107                description: Some(format!("{name} description")),
2108                documentation: None,
2109                agent_instructions: Some("private".into()),
2110                external_id: None,
2111                study_type_id: None,
2112                date_created: None,
2113                created_by: Some("private@example.org".into()),
2114            },
2115            domains: vec![ahri_tre_pgmeta::PgDomainReadModel {
2116                domain_id: DomainId(domain_id),
2117                name: format!("Domain {domain_id}"),
2118                uri: None,
2119                description: None,
2120            }],
2121            tags: vec![],
2122            access_conditions: vec![],
2123        }
2124    }
2125}