Skip to main content

spiral/
catalog_types.rs

1//! Validated conveniences for the SDK-local generated Catalog contract.
2
3use std::cmp::Ordering;
4use std::collections::HashSet;
5use std::hash::{Hash, Hasher};
6use std::str::FromStr;
7
8use uuid::{Uuid, Variant};
9
10pub use crate::api::catalog::*;
11
12/// Exact immutable project commit.
13pub type ProjectCommitId = CommitId;
14/// Stable project branch identity.
15pub type ProjectBranchId = BranchId;
16/// Stable branch event identity.
17pub type ProjectBranchEventId = BranchEventId;
18/// Validated project branch name.
19pub type ProjectBranchName = BranchName;
20/// Validated project tag name.
21pub type ProjectTagName = TagName;
22/// Stable publication transaction identity.
23pub type ProjectTransactionId = TransactionId;
24/// Live resource state at a project commit.
25pub type ProjectResourceState = ResourceState;
26/// Live resource selected by a snapshot.
27pub type ResourceAtSnapshot = ResourceState;
28/// Request to resolve a path within an explicitly selected commit.
29pub type ResolveJournalPathRequest = ResolvePathRequest;
30/// Request to read a page within an explicitly selected commit.
31pub type ReadJournalStateRequest = ReadStateRequest;
32/// Maximum number of segments in a resource path.
33pub const MAX_NAMESPACE_DEPTH: usize = 64;
34
35fn opaque(value: &str, field: &str, maximum: usize) -> Result<(), String> {
36    if value.is_empty() || value.len() > maximum || value.chars().any(char::is_control) {
37        return Err(format!(
38            "{field} must contain 1..={maximum} UTF-8 bytes without control characters"
39        ));
40    }
41    Ok(())
42}
43
44fn reference_name(value: &str, field: &str) -> Result<(), String> {
45    opaque(value, field, 255)?;
46    if value.split('/').any(|part| {
47        part.is_empty()
48            || part == "."
49            || part == ".."
50            || part.ends_with('.')
51            || part.ends_with(".lock")
52    }) {
53        return Err("reference names require nonempty segments without dot, trailing-dot, or `.lock` segments".into());
54    }
55    Ok(())
56}
57
58macro_rules! ordered {
59    ($($name:ident),* $(,)?) => {$(
60        impl Hash for $name {
61            fn hash<H: Hasher>(&self, state: &mut H) { self.0.hash(state); }
62        }
63        impl Ord for $name {
64            fn cmp(&self, other: &Self) -> Ordering { self.0.cmp(&other.0) }
65        }
66        impl PartialOrd for $name {
67            fn partial_cmp(&self, other: &Self) -> Option<Ordering> { Some(self.cmp(other)) }
68        }
69    )*};
70}
71
72macro_rules! uuid_id {
73    ($($name:ident => $version:literal),* $(,)?) => {$(
74        impl Copy for $name {}
75        impl $name {
76            /// Validate the UUID version required by this Catalog identifier.
77            pub fn new(value: Uuid) -> Result<Self, String> {
78                if value.get_variant() != Variant::RFC4122 || value.get_version_num() != $version {
79                    return Err(format!("{} must be a UUIDv{}", stringify!($name), $version));
80                }
81                Ok(Self(value))
82            }
83            /// Underlying UUID identity.
84            pub const fn as_uuid(self) -> Uuid { self.0 }
85        }
86        impl FromStr for $name {
87            type Err = String;
88            fn from_str(value: &str) -> Result<Self, Self::Err> {
89                let uuid = Uuid::parse_str(value).map_err(|error| error.to_string())?;
90                if uuid.hyphenated().to_string() != value {
91                    return Err("identifier must be a canonical lowercase hyphenated UUID".into());
92                }
93                Self::new(uuid)
94            }
95        }
96        impl TryFrom<String> for $name {
97            type Error = String;
98            fn try_from(value: String) -> Result<Self, Self::Error> { value.parse() }
99        }
100    )*};
101}
102uuid_id!(ResourceId => 4, VersionId => 7, CommitId => 7, BranchId => 7,
103    BranchEventId => 7, TransactionId => 7, OperationId => 7);
104ordered!(
105    ResourceId,
106    VersionId,
107    CommitId,
108    BranchId,
109    BranchEventId,
110    TransactionId,
111    OperationId,
112    ProjectId,
113    ActorId,
114    ResourceServiceId,
115    ResourceKind,
116    IdempotencyKey,
117    PageToken,
118    BranchName,
119    TagName,
120    NamespaceSegment,
121    NamespacePath
122);
123
124macro_rules! string_access {
125    ($($name:ident),* $(,)?) => {$(
126        impl $name {
127            /// Borrow the wire string.
128            pub fn as_str(&self) -> &str { &self.0 }
129            /// Consume the wrapper and return its wire string.
130            pub fn into_string(self) -> String { self.0 }
131        }
132        impl AsRef<str> for $name {
133            fn as_ref(&self) -> &str { self.as_str() }
134        }
135        impl FromStr for $name {
136            type Err = String;
137            fn from_str(value: &str) -> Result<Self, Self::Err> { Self::new(value) }
138        }
139        impl TryFrom<String> for $name {
140            type Error = String;
141            fn try_from(value: String) -> Result<Self, Self::Error> { Self::new(value) }
142        }
143    )*};
144}
145string_access!(
146    ProjectId,
147    ActorId,
148    ResourceServiceId,
149    ResourceKind,
150    IdempotencyKey,
151    PageToken,
152    BranchName,
153    TagName,
154    NamespaceSegment
155);
156
157macro_rules! opaque_string {
158    ($($name:ident => $maximum:literal),* $(,)?) => {$(
159        impl $name {
160            /// Validate a bounded, nonempty Catalog wire string.
161            pub fn new(value: impl Into<String>) -> Result<Self, String> {
162                let value = value.into();
163                opaque(&value, stringify!($name), $maximum)?;
164                Ok(Self(value))
165            }
166        }
167    )*};
168}
169opaque_string!(ProjectId => 128, ActorId => 128, ResourceServiceId => 64,
170    ResourceKind => 64, IdempotencyKey => 128, PageToken => 512);
171
172macro_rules! ref_name {
173    ($($name:ident),* $(,)?) => {$(
174        impl $name {
175            /// Validate a slash-separated reference name.
176            pub fn new(value: impl Into<String>) -> Result<Self, String> {
177                let value = value.into();
178                reference_name(&value, stringify!($name))?;
179                Ok(Self(value))
180            }
181        }
182    )*};
183}
184ref_name!(BranchName, TagName);
185impl BranchName {
186    /// Default project branch name.
187    pub const MAIN: &'static str = "main";
188    /// Select the default project branch.
189    pub fn main() -> Self {
190        Self(Self::MAIN.into())
191    }
192}
193impl Default for BranchName {
194    fn default() -> Self {
195        Self::main()
196    }
197}
198impl NamespaceSegment {
199    /// Validate one resource path segment.
200    pub fn new(value: impl Into<String>) -> Result<Self, String> {
201        let segment = Self(value.into());
202        segment.validate()?;
203        Ok(segment)
204    }
205    /// Validate a wire segment before retaining it as a namespace key.
206    pub fn validate(&self) -> Result<(), String> {
207        opaque(self.as_str(), "namespaceSegment", 255)?;
208        if self.as_str() == "." || self.as_str() == ".." || self.as_str().contains(['/', '@']) {
209            return Err("namespace segments cannot be `.`, `..`, or contain `/` or `@`".into());
210        }
211        Ok(())
212    }
213}
214impl NamespacePath {
215    /// Construct a nonempty resource path within the supported depth.
216    pub fn new(segments: Vec<NamespaceSegment>) -> Result<Self, String> {
217        if segments.is_empty() || segments.len() > MAX_NAMESPACE_DEPTH {
218            return Err(format!(
219                "namespace paths require 1..={MAX_NAMESPACE_DEPTH} segments"
220            ));
221        }
222        for segment in &segments {
223            segment.validate()?;
224        }
225        Ok(Self(segments))
226    }
227    /// Borrow the path's validated segments.
228    pub fn segments(&self) -> &[NamespaceSegment] {
229        &self.0
230    }
231    /// Consume the path and return its segments.
232    pub fn into_segments(self) -> Vec<NamespaceSegment> {
233        self.0
234    }
235}
236impl NamespaceParent {
237    /// Namespace anchored at the project root.
238    pub fn project_root() -> Self {
239        Self::ProjectRoot(Empty {})
240    }
241    /// Namespace anchored at a resource.
242    pub fn resource(resource_id: ResourceId) -> Self {
243        Self::Resource(ResourceParent { resource_id })
244    }
245    /// Stable resource anchoring this namespace, if present.
246    pub fn resource_id(&self) -> Option<&ResourceId> {
247        match self {
248            Self::ProjectRoot(_) => None,
249            Self::Resource(value) => Some(&value.resource_id),
250            Self::Prefix(value) => value.resource_id.as_ref(),
251            Self::Unknown { .. } => None,
252        }
253    }
254    /// Implicit path segments relative to the namespace anchor.
255    pub fn path_segments(&self) -> &[NamespaceSegment] {
256        match self {
257            Self::Prefix(value) => value.path.segments(),
258            _ => &[],
259        }
260    }
261    /// Extend a namespace prefix without allocating a Catalog resource.
262    pub fn child_prefix(&self, segment: NamespaceSegment) -> Result<Self, String> {
263        self.validate()?;
264        let mut segments = self.path_segments().to_vec();
265        segments.push(segment);
266        let parent = Self::Prefix(PrefixParent {
267            resource_id: self.resource_id().copied(),
268            path: NamespacePath::new(segments)?,
269        });
270        parent.validate()?;
271        Ok(parent)
272    }
273    /// Bound namespace nesting and encoded size, leaving room for one leaf segment.
274    pub fn validate(&self) -> Result<(), String> {
275        match self {
276            Self::Unknown { .. } => return Err("unknown namespace parent".into()),
277            Self::Prefix(value) if value.path.segments().is_empty() => {
278                return Err("namespace prefixes require a nonempty path".into());
279            }
280            _ => {}
281        }
282        if let Some(anchor) = self.resource_id() {
283            ResourceId::new(anchor.0)?;
284        }
285        for segment in self.path_segments() {
286            segment.validate()?;
287        }
288        if self.path_segments().len() >= MAX_NAMESPACE_DEPTH
289            || serde_json::to_vec(self)
290                .map_err(|error| error.to_string())?
291                .len()
292                > 1500
293        {
294            return Err("namespace parent exceeds 63 segments or 1500 encoded bytes".into());
295        }
296        Ok(())
297    }
298}
299impl Hash for NamespaceParent {
300    fn hash<H: Hasher>(&self, state: &mut H) {
301        match self {
302            Self::ProjectRoot(_) => 0u8.hash(state),
303            Self::Resource(value) => {
304                1u8.hash(state);
305                value.resource_id.hash(state);
306            }
307            Self::Prefix(value) => {
308                2u8.hash(state);
309                value.resource_id.hash(state);
310                value.path.hash(state);
311            }
312            Self::Unknown { tag, .. } => {
313                3u8.hash(state);
314                tag.hash(state);
315            }
316        }
317    }
318}
319macro_rules! bounded_count {
320    ($($name:ident),* $(,)?) => {$(
321        impl Copy for $name {}
322        impl $name {
323            /// Default page or log limit.
324            pub const DEFAULT: Self = Self(100);
325            /// Validate a server-supported page or log limit.
326            pub fn new(value: u32) -> Result<Self, String> {
327                if !(1..=1000).contains(&value) { return Err("limit must be between 1 and 1000".into()); }
328                Ok(Self(value))
329            }
330            /// Underlying numeric limit.
331            pub const fn get(self) -> u32 { self.0 }
332        }
333        impl Default for $name { fn default() -> Self { Self::DEFAULT } }
334    )*};
335}
336bounded_count!(PageSize, CommitLogLimit);
337
338impl PageToken {
339    /// Validate a server continuation before making its next bounded request.
340    pub fn validate(&self) -> Result<(), String> {
341        opaque(self.as_str(), "pageToken", 512)
342    }
343}
344
345impl Branch {
346    /// Validate a live branch response before creating an SDK snapshot.
347    pub fn validate(&self) -> Result<(), String> {
348        BranchId::new(self.branch_id.0)?;
349        CommitId::new(self.head_commit_id.0)?;
350        BranchEventId::new(self.last_branch_event_id.0)?;
351        reference_name(self.name.as_str(), "branchName")
352    }
353}
354impl ResourceDescriptor {
355    /// Validate the immutable identity and owner of a resolved resource.
356    pub fn validate(&self) -> Result<(), String> {
357        ResourceId::new(self.resource_id.0)?;
358        if let Some(root) = self.namespace_root {
359            ResourceId::new(root.0)?;
360        }
361        opaque(self.service.as_str(), "resourceServiceId", 64)?;
362        opaque(self.kind.as_str(), "resourceKind", 64)
363    }
364}
365impl ResourceState {
366    /// Validate a resolved resource before retaining it in an SDK snapshot.
367    pub fn validate(&self) -> Result<(), String> {
368        self.descriptor.validate()?;
369        VersionId::new(self.version_id.0)?;
370        Ok(())
371    }
372}
373
374/// Atomic publication request at an explicitly selected project commit.
375pub type PublishProjectRequest = PublishRequest;
376/// Typed outcome of an atomic publication.
377pub type PublishProjectResponse = PublishResponse;
378
379impl PublishRequest {
380    /// Validate bounded structural changes before publication or durable resumption.
381    pub fn validate(&self) -> Result<(), String> {
382        CommitId::new(self.base_commit_id.0)?;
383        BranchId::new(self.branch_id.0)?;
384        TransactionId::new(self.transaction_id.0)?;
385        if let Some(head) = self.expected_head_commit_id {
386            CommitId::new(head.0)?;
387        }
388        IdempotencyKey::new(self.idempotency_key.as_str())?;
389        if self
390            .message
391            .as_ref()
392            .is_some_and(|message| message.len() > 16_384)
393        {
394            return Err("commit message exceeds 16384 UTF-8 bytes".into());
395        }
396        if self.resources.is_empty() && self.namespace.is_empty() {
397            return Err("publication requires at least one change".into());
398        }
399        if self.resources.len() > 1000 || self.namespace.len() > 1000 {
400            return Err("publication supports at most 1000 changes per collection".into());
401        }
402        let mut resources = HashSet::with_capacity(self.resources.len());
403        for change in &self.resources {
404            let id = match change {
405                ResourceChange::Create(change) => {
406                    let descriptor = &change.descriptor;
407                    descriptor.validate()?;
408                    VersionId::new(change.version_id.0)?;
409                    descriptor.resource_id
410                }
411                ResourceChange::Advance(change) => {
412                    VersionId::new(change.expected.0)?;
413                    VersionId::new(change.replacement.0)?;
414                    if change.expected == change.replacement {
415                        return Err(
416                            "advancing to the expected version requires an assertion".into()
417                        );
418                    }
419                    change.resource_id
420                }
421                ResourceChange::Retire(change) => {
422                    VersionId::new(change.expected.0)?;
423                    change.resource_id
424                }
425                ResourceChange::Assert(change) => {
426                    if let Some(expected) = change.expected {
427                        VersionId::new(expected.0)?;
428                    }
429                    change.resource_id
430                }
431                ResourceChange::Unknown { .. } => return Err("unknown resource change".into()),
432            };
433            ResourceId::new(id.0)?;
434            if !resources.insert(id) {
435                return Err("duplicate resource change".into());
436            }
437        }
438        let mut bindings = HashSet::with_capacity(self.namespace.len());
439        for change in &self.namespace {
440            let (parent, name, expected, replacement) = match change {
441                NamespaceChange::Bind(change) => {
442                    if change.expected == Some(change.replacement) {
443                        return Err("binding to the expected resource requires an assertion".into());
444                    }
445                    (
446                        &change.parent,
447                        &change.name,
448                        change.expected,
449                        Some(change.replacement),
450                    )
451                }
452                NamespaceChange::Unbind(change) => {
453                    (&change.parent, &change.name, Some(change.expected), None)
454                }
455                NamespaceChange::Assert(change) => {
456                    (&change.parent, &change.name, change.expected, None)
457                }
458                NamespaceChange::Unknown { .. } => return Err("unknown namespace change".into()),
459            };
460            parent.validate()?;
461            if let Some(anchor) = parent.resource_id() {
462                ResourceId::new(anchor.0)?;
463            }
464            for part in parent.path_segments() {
465                part.validate()?;
466            }
467            name.validate()?;
468            if let Some(expected) = expected {
469                ResourceId::new(expected.0)?;
470            }
471            if let Some(replacement) = replacement {
472                ResourceId::new(replacement.0)?;
473            }
474            if !bindings.insert((parent, name)) {
475                return Err("duplicate namespace change".into());
476            }
477        }
478        Ok(())
479    }
480}
481
482#[cfg(test)]
483mod tests {
484    use uuid::Uuid;
485
486    use super::{
487        AdvanceResourceChange, AssertResourceChange, BranchId, CommitId, IdempotencyKey,
488        NamespaceChange, NamespaceParent, NamespaceSegment, PublishRequest, ResourceChange,
489        ResourceId, TransactionId, VersionId,
490    };
491
492    fn publication(
493        resources: Vec<ResourceChange>,
494        namespace: Vec<NamespaceChange>,
495    ) -> PublishRequest {
496        PublishRequest {
497            base_commit_id: CommitId(Uuid::now_v7()),
498            branch_id: BranchId(Uuid::now_v7()),
499            transaction_id: TransactionId(Uuid::now_v7()),
500            expected_head_commit_id: None,
501            idempotency_key: IdempotencyKey::new("test-publication").unwrap(),
502            message: None,
503            resources,
504            namespace,
505        }
506    }
507
508    #[test]
509    fn publication_rejects_duplicate_resource_domains_and_no_op_advances() {
510        let resource_id = ResourceId(Uuid::new_v4());
511        let assertion = ResourceChange::Assert(AssertResourceChange {
512            resource_id,
513            expected: None,
514        });
515        assert!(
516            publication(vec![assertion.clone()], vec![])
517                .validate()
518                .is_ok()
519        );
520        assert!(
521            publication(vec![assertion.clone(), assertion], vec![])
522                .validate()
523                .is_err()
524        );
525        let version = VersionId(Uuid::now_v7());
526        assert!(
527            publication(
528                vec![ResourceChange::Advance(AdvanceResourceChange {
529                    resource_id,
530                    expected: version,
531                    replacement: version,
532                })],
533                vec![]
534            )
535            .validate()
536            .is_err()
537        );
538    }
539
540    #[test]
541    fn namespace_prefix_leaves_room_for_the_leaf_segment() {
542        let mut parent = NamespaceParent::project_root();
543        for _ in 0..63 {
544            parent = parent
545                .child_prefix(NamespaceSegment::new("a").unwrap())
546                .unwrap();
547        }
548        assert!(
549            parent
550                .child_prefix(NamespaceSegment::new("a").unwrap())
551                .is_err()
552        );
553    }
554}