1use 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
12pub type ProjectCommitId = CommitId;
14pub type ProjectBranchId = BranchId;
16pub type ProjectBranchEventId = BranchEventId;
18pub type ProjectBranchName = BranchName;
20pub type ProjectTagName = TagName;
22pub type ProjectTransactionId = TransactionId;
24pub type ProjectResourceState = ResourceState;
26pub type ResourceAtSnapshot = ResourceState;
28pub type ResolveJournalPathRequest = ResolvePathRequest;
30pub type ReadJournalStateRequest = ReadStateRequest;
32pub 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 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 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 pub fn as_str(&self) -> &str { &self.0 }
129 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 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 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 pub const MAIN: &'static str = "main";
188 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 pub fn new(value: impl Into<String>) -> Result<Self, String> {
201 let segment = Self(value.into());
202 segment.validate()?;
203 Ok(segment)
204 }
205 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 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 pub fn segments(&self) -> &[NamespaceSegment] {
229 &self.0
230 }
231 pub fn into_segments(self) -> Vec<NamespaceSegment> {
233 self.0
234 }
235}
236impl NamespaceParent {
237 pub fn project_root() -> Self {
239 Self::ProjectRoot(Empty {})
240 }
241 pub fn resource(resource_id: ResourceId) -> Self {
243 Self::Resource(ResourceParent { resource_id })
244 }
245 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 pub fn path_segments(&self) -> &[NamespaceSegment] {
256 match self {
257 Self::Prefix(value) => value.path.segments(),
258 _ => &[],
259 }
260 }
261 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 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 pub const DEFAULT: Self = Self(100);
325 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 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 pub fn validate(&self) -> Result<(), String> {
341 opaque(self.as_str(), "pageToken", 512)
342 }
343}
344
345impl Branch {
346 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 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 pub fn validate(&self) -> Result<(), String> {
368 self.descriptor.validate()?;
369 VersionId::new(self.version_id.0)?;
370 Ok(())
371 }
372}
373
374pub type PublishProjectRequest = PublishRequest;
376pub type PublishProjectResponse = PublishResponse;
378
379impl PublishRequest {
380 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}