1use std::collections::BTreeMap;
2use std::sync::Arc;
3
4use batpak::coordinate::{Coordinate, EventCategory};
5use batpak::store::Freshness;
6
7use crate::operation::MAX_DESCRIPTOR_REF_BYTES;
8use crate::operation_name::OperationName;
9use crate::operation_status_sink::operation_status_entity;
10
11use super::error::SubscriptionRuntimeError;
12use super::projector::{ProjectionProjector, ProjectionRouteBinding};
13
14pub const MAX_SUBSCRIPTION_ID_BYTES: usize = 128;
18
19#[derive(Clone, Debug)]
21pub struct OperationStatusRouteBinding {
22 pub subscription_id: String,
24 pub operation: OperationName,
26 pub entity: String,
28 pub wire_payload_schema_ref: String,
30 pub inner_status_schema_ref: Option<String>,
32 pub freshness: Freshness,
34 pub backpressure_capacity: Option<usize>,
36}
37
38#[derive(Clone, Debug)]
40pub struct EntityStreamRouteBinding {
41 pub subscription_id: String,
43 pub entity: String,
45 pub scope: String,
47 pub wire_payload_schema_ref: String,
49 pub inner_event_payload_schema_ref: Option<String>,
51 pub backpressure_capacity: Option<usize>,
53}
54
55#[derive(Clone, Debug)]
57pub struct ReceiptStreamRouteBinding {
58 pub subscription_id: String,
60 pub receipt_kind: String,
62 pub wire_payload_schema_ref: String,
64 pub inner_receipt_schema_ref: Option<String>,
66 pub backpressure_capacity: Option<usize>,
68}
69
70#[derive(Clone, Debug, Eq, PartialEq, Ord, PartialOrd)]
72pub struct SubscriptionId(String);
73
74impl SubscriptionId {
75 pub fn new(id: impl Into<String>) -> Result<Self, SubscriptionRuntimeError> {
80 let id = id.into();
81 validate_subscription_id(&id)
82 .map_err(|reason| SubscriptionRuntimeError::InvalidSubscriptionId { reason })?;
83 Ok(Self(id))
84 }
85
86 #[must_use]
88 pub fn as_str(&self) -> &str {
89 &self.0
90 }
91}
92
93#[derive(Clone)]
95pub enum SubscriptionRoute {
96 EventCategory {
98 category: u8,
100 wire_payload_schema_ref: String,
102 inner_event_payload_schema_ref: Option<String>,
104 backpressure_capacity: Option<usize>,
106 },
107 EntityStream {
109 entity: String,
111 scope: String,
113 wire_payload_schema_ref: String,
115 inner_event_payload_schema_ref: Option<String>,
117 backpressure_capacity: Option<usize>,
119 },
120 Projection {
122 projection_id: String,
124 entity: String,
126 wire_payload_schema_ref: String,
128 inner_projection_schema_ref: Option<String>,
130 freshness: Freshness,
132 backpressure_capacity: Option<usize>,
134 projector: Arc<dyn ProjectionProjector>,
136 },
137 OperationStatus {
139 operation: OperationName,
141 entity: String,
143 wire_payload_schema_ref: String,
145 inner_status_schema_ref: Option<String>,
147 freshness: Freshness,
149 backpressure_capacity: Option<usize>,
151 },
152 ReceiptStream {
154 receipt_kind: String,
156 wire_payload_schema_ref: String,
158 inner_receipt_schema_ref: Option<String>,
160 backpressure_capacity: Option<usize>,
162 },
163}
164
165impl SubscriptionRoute {
166 #[must_use]
168 pub fn event_category(&self) -> Option<u8> {
169 match self {
170 Self::EventCategory { category, .. } => Some(*category),
171 Self::Projection { .. }
172 | Self::OperationStatus { .. }
173 | Self::ReceiptStream { .. }
174 | Self::EntityStream { .. } => None,
175 }
176 }
177
178 #[must_use]
180 pub fn entity_stream_binding(&self, subscription_id: &str) -> Option<EntityStreamRouteBinding> {
181 match self {
182 Self::EntityStream {
183 entity,
184 scope,
185 wire_payload_schema_ref,
186 inner_event_payload_schema_ref,
187 backpressure_capacity,
188 } => Some(EntityStreamRouteBinding {
189 subscription_id: subscription_id.to_owned(),
190 entity: entity.clone(),
191 scope: scope.clone(),
192 wire_payload_schema_ref: wire_payload_schema_ref.clone(),
193 inner_event_payload_schema_ref: inner_event_payload_schema_ref.clone(),
194 backpressure_capacity: *backpressure_capacity,
195 }),
196 Self::EventCategory { .. }
197 | Self::Projection { .. }
198 | Self::OperationStatus { .. }
199 | Self::ReceiptStream { .. } => None,
200 }
201 }
202
203 #[must_use]
205 pub fn projection_binding(&self, subscription_id: &str) -> Option<ProjectionRouteBinding> {
206 match self {
207 Self::Projection {
208 projection_id,
209 entity,
210 wire_payload_schema_ref,
211 inner_projection_schema_ref,
212 freshness,
213 backpressure_capacity,
214 ..
215 } => Some(ProjectionRouteBinding {
216 subscription_id: subscription_id.to_owned(),
217 projection_id: projection_id.clone(),
218 entity: entity.clone(),
219 wire_payload_schema_ref: wire_payload_schema_ref.clone(),
220 inner_projection_schema_ref: inner_projection_schema_ref.clone(),
221 freshness: freshness.clone(),
222 backpressure_capacity: *backpressure_capacity,
223 }),
224 Self::EventCategory { .. } => None,
225 Self::EntityStream { .. } => None,
226 Self::OperationStatus { .. } | Self::ReceiptStream { .. } => None,
227 }
228 }
229
230 #[must_use]
232 pub fn operation_status_binding(
233 &self,
234 subscription_id: &str,
235 ) -> Option<OperationStatusRouteBinding> {
236 match self {
237 Self::OperationStatus {
238 operation,
239 entity,
240 wire_payload_schema_ref,
241 inner_status_schema_ref,
242 freshness,
243 backpressure_capacity,
244 } => Some(OperationStatusRouteBinding {
245 subscription_id: subscription_id.to_owned(),
246 operation: operation.clone(),
247 entity: entity.clone(),
248 wire_payload_schema_ref: wire_payload_schema_ref.clone(),
249 inner_status_schema_ref: inner_status_schema_ref.clone(),
250 freshness: freshness.clone(),
251 backpressure_capacity: *backpressure_capacity,
252 }),
253 Self::EventCategory { .. }
254 | Self::Projection { .. }
255 | Self::ReceiptStream { .. }
256 | Self::EntityStream { .. } => None,
257 }
258 }
259
260 #[must_use]
262 pub fn receipt_stream_binding(
263 &self,
264 subscription_id: &str,
265 ) -> Option<ReceiptStreamRouteBinding> {
266 match self {
267 Self::ReceiptStream {
268 receipt_kind,
269 wire_payload_schema_ref,
270 inner_receipt_schema_ref,
271 backpressure_capacity,
272 } => Some(ReceiptStreamRouteBinding {
273 subscription_id: subscription_id.to_owned(),
274 receipt_kind: receipt_kind.clone(),
275 wire_payload_schema_ref: wire_payload_schema_ref.clone(),
276 inner_receipt_schema_ref: inner_receipt_schema_ref.clone(),
277 backpressure_capacity: *backpressure_capacity,
278 }),
279 Self::EventCategory { .. }
280 | Self::Projection { .. }
281 | Self::OperationStatus { .. }
282 | Self::EntityStream { .. } => None,
283 }
284 }
285}
286
287impl std::fmt::Debug for SubscriptionRoute {
288 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
289 match self {
290 Self::EventCategory {
291 category,
292 wire_payload_schema_ref,
293 inner_event_payload_schema_ref,
294 backpressure_capacity,
295 } => f
296 .debug_struct("EventCategory")
297 .field("category", category)
298 .field("wire_payload_schema_ref", wire_payload_schema_ref)
299 .field(
300 "inner_event_payload_schema_ref",
301 inner_event_payload_schema_ref,
302 )
303 .field("backpressure_capacity", backpressure_capacity)
304 .finish(),
305 Self::EntityStream {
306 entity,
307 scope,
308 wire_payload_schema_ref,
309 inner_event_payload_schema_ref,
310 backpressure_capacity,
311 } => f
312 .debug_struct("EntityStream")
313 .field("entity", entity)
314 .field("scope", scope)
315 .field("wire_payload_schema_ref", wire_payload_schema_ref)
316 .field(
317 "inner_event_payload_schema_ref",
318 inner_event_payload_schema_ref,
319 )
320 .field("backpressure_capacity", backpressure_capacity)
321 .finish(),
322 Self::Projection {
323 projection_id,
324 entity,
325 wire_payload_schema_ref,
326 inner_projection_schema_ref,
327 freshness,
328 backpressure_capacity,
329 ..
330 } => f
331 .debug_struct("Projection")
332 .field("projection_id", projection_id)
333 .field("entity", entity)
334 .field("wire_payload_schema_ref", wire_payload_schema_ref)
335 .field("inner_projection_schema_ref", inner_projection_schema_ref)
336 .field("freshness", freshness)
337 .field("backpressure_capacity", backpressure_capacity)
338 .field("projector", &"Arc<dyn ProjectionProjector>")
339 .finish(),
340 Self::OperationStatus {
341 operation,
342 entity,
343 wire_payload_schema_ref,
344 inner_status_schema_ref,
345 freshness,
346 backpressure_capacity,
347 } => f
348 .debug_struct("OperationStatus")
349 .field("operation", operation)
350 .field("entity", entity)
351 .field("wire_payload_schema_ref", wire_payload_schema_ref)
352 .field("inner_status_schema_ref", inner_status_schema_ref)
353 .field("freshness", freshness)
354 .field("backpressure_capacity", backpressure_capacity)
355 .finish(),
356 Self::ReceiptStream {
357 receipt_kind,
358 wire_payload_schema_ref,
359 inner_receipt_schema_ref,
360 backpressure_capacity,
361 } => f
362 .debug_struct("ReceiptStream")
363 .field("receipt_kind", receipt_kind)
364 .field("wire_payload_schema_ref", wire_payload_schema_ref)
365 .field("inner_receipt_schema_ref", inner_receipt_schema_ref)
366 .field("backpressure_capacity", backpressure_capacity)
367 .finish(),
368 }
369 }
370}
371
372impl PartialEq for SubscriptionRoute {
373 fn eq(&self, other: &Self) -> bool {
374 self.event_category_eq(other)
375 || self.entity_stream_eq(other)
376 || self.projection_eq(other)
377 || self.operation_status_eq(other)
378 || self.receipt_stream_eq(other)
379 }
380}
381
382impl Eq for SubscriptionRoute {}
383
384impl SubscriptionRoute {
385 fn event_category_eq(&self, other: &Self) -> bool {
386 match (self, other) {
387 (
388 Self::EventCategory {
389 category: left_category,
390 wire_payload_schema_ref: left_wire,
391 inner_event_payload_schema_ref: left_inner,
392 backpressure_capacity: left_cap,
393 },
394 Self::EventCategory {
395 category: right_category,
396 wire_payload_schema_ref: right_wire,
397 inner_event_payload_schema_ref: right_inner,
398 backpressure_capacity: right_cap,
399 },
400 ) => {
401 left_category == right_category
402 && left_wire == right_wire
403 && left_inner == right_inner
404 && left_cap == right_cap
405 }
406 _ => false,
407 }
408 }
409
410 fn entity_stream_eq(&self, other: &Self) -> bool {
411 match (self, other) {
412 (
413 Self::EntityStream {
414 entity: left_entity,
415 scope: left_scope,
416 wire_payload_schema_ref: left_wire,
417 inner_event_payload_schema_ref: left_inner,
418 backpressure_capacity: left_cap,
419 },
420 Self::EntityStream {
421 entity: right_entity,
422 scope: right_scope,
423 wire_payload_schema_ref: right_wire,
424 inner_event_payload_schema_ref: right_inner,
425 backpressure_capacity: right_cap,
426 },
427 ) => {
428 left_entity == right_entity
429 && left_scope == right_scope
430 && left_wire == right_wire
431 && left_inner == right_inner
432 && left_cap == right_cap
433 }
434 _ => false,
435 }
436 }
437
438 fn projection_eq(&self, other: &Self) -> bool {
439 match (self, other) {
440 (
441 Self::Projection {
442 projection_id: left_projection,
443 entity: left_entity,
444 wire_payload_schema_ref: left_wire,
445 inner_projection_schema_ref: left_inner,
446 freshness: left_freshness,
447 backpressure_capacity: left_cap,
448 ..
449 },
450 Self::Projection {
451 projection_id: right_projection,
452 entity: right_entity,
453 wire_payload_schema_ref: right_wire,
454 inner_projection_schema_ref: right_inner,
455 freshness: right_freshness,
456 backpressure_capacity: right_cap,
457 ..
458 },
459 ) => {
460 left_projection == right_projection
461 && left_entity == right_entity
462 && left_wire == right_wire
463 && left_inner == right_inner
464 && freshness_same(left_freshness, right_freshness)
465 && left_cap == right_cap
466 }
467 _ => false,
468 }
469 }
470
471 fn operation_status_eq(&self, other: &Self) -> bool {
472 match (self, other) {
473 (
474 Self::OperationStatus {
475 operation: left_operation,
476 entity: left_entity,
477 wire_payload_schema_ref: left_wire,
478 inner_status_schema_ref: left_inner,
479 freshness: left_freshness,
480 backpressure_capacity: left_cap,
481 },
482 Self::OperationStatus {
483 operation: right_operation,
484 entity: right_entity,
485 wire_payload_schema_ref: right_wire,
486 inner_status_schema_ref: right_inner,
487 freshness: right_freshness,
488 backpressure_capacity: right_cap,
489 },
490 ) => {
491 left_operation == right_operation
492 && left_entity == right_entity
493 && left_wire == right_wire
494 && left_inner == right_inner
495 && freshness_same(left_freshness, right_freshness)
496 && left_cap == right_cap
497 }
498 _ => false,
499 }
500 }
501
502 fn receipt_stream_eq(&self, other: &Self) -> bool {
503 match (self, other) {
504 (
505 Self::ReceiptStream {
506 receipt_kind: left_kind,
507 wire_payload_schema_ref: left_wire,
508 inner_receipt_schema_ref: left_inner,
509 backpressure_capacity: left_cap,
510 },
511 Self::ReceiptStream {
512 receipt_kind: right_kind,
513 wire_payload_schema_ref: right_wire,
514 inner_receipt_schema_ref: right_inner,
515 backpressure_capacity: right_cap,
516 },
517 ) => {
518 left_kind == right_kind
519 && left_wire == right_wire
520 && left_inner == right_inner
521 && left_cap == right_cap
522 }
523 _ => false,
524 }
525 }
526}
527
528fn freshness_same(left: &Freshness, right: &Freshness) -> bool {
529 match (left, right) {
530 (Freshness::Consistent, Freshness::Consistent) => true,
531 (
532 Freshness::MaybeStale {
533 max_stale_ms: left_ms,
534 },
535 Freshness::MaybeStale {
536 max_stale_ms: right_ms,
537 },
538 ) => left_ms == right_ms,
539 _ => false,
540 }
541}
542
543#[derive(Clone, Debug, Default, Eq, PartialEq)]
545pub struct SubscriptionRegistry {
546 routes: BTreeMap<String, SubscriptionRoute>,
547}
548
549impl SubscriptionRegistry {
550 #[must_use]
552 pub fn new() -> Self {
553 Self {
554 routes: BTreeMap::new(),
555 }
556 }
557
558 pub fn insert(
564 &mut self,
565 id: SubscriptionId,
566 route: SubscriptionRoute,
567 ) -> Result<(), SubscriptionRuntimeError> {
568 validate_route(&route)?;
569 if self.routes.contains_key(id.as_str()) {
570 return Err(SubscriptionRuntimeError::DuplicateSubscription { id: id.0 });
571 }
572 self.routes.insert(id.0, route);
573 Ok(())
574 }
575
576 #[must_use]
578 pub fn get(&self, subscription_id: &str) -> Option<&SubscriptionRoute> {
579 self.routes.get(subscription_id)
580 }
581}
582
583fn validate_route(route: &SubscriptionRoute) -> Result<(), SubscriptionRuntimeError> {
584 match route {
585 SubscriptionRoute::EventCategory {
586 category,
587 wire_payload_schema_ref,
588 backpressure_capacity,
589 ..
590 } => {
591 validate_event_category_route(*category, wire_payload_schema_ref, backpressure_capacity)
592 }
593 SubscriptionRoute::Projection {
594 projection_id,
595 entity,
596 wire_payload_schema_ref,
597 backpressure_capacity,
598 ..
599 } => validate_projection_route(
600 projection_id,
601 entity,
602 wire_payload_schema_ref,
603 backpressure_capacity,
604 ),
605 SubscriptionRoute::OperationStatus {
606 operation,
607 entity,
608 wire_payload_schema_ref,
609 backpressure_capacity,
610 ..
611 } => validate_operation_status_route(
612 operation,
613 entity,
614 wire_payload_schema_ref,
615 backpressure_capacity,
616 ),
617 SubscriptionRoute::EntityStream {
618 entity,
619 scope,
620 wire_payload_schema_ref,
621 backpressure_capacity,
622 ..
623 } => validate_entity_stream_route(
624 entity,
625 scope,
626 wire_payload_schema_ref,
627 backpressure_capacity,
628 ),
629 SubscriptionRoute::ReceiptStream {
630 receipt_kind,
631 wire_payload_schema_ref,
632 backpressure_capacity,
633 ..
634 } => validate_receipt_stream_route(
635 receipt_kind,
636 wire_payload_schema_ref,
637 backpressure_capacity,
638 ),
639 }
640}
641
642fn validate_event_category_route(
643 category: u8,
644 wire_payload_schema_ref: &str,
645 backpressure_capacity: &Option<usize>,
646) -> Result<(), SubscriptionRuntimeError> {
647 EventCategory::new(category).map_err(|_| SubscriptionRuntimeError::InvalidRoute {
648 reason: "event category out of range",
649 })?;
650 validate_wire_and_backpressure(wire_payload_schema_ref, backpressure_capacity)
651}
652
653fn validate_projection_route(
654 projection_id: &str,
655 entity: &str,
656 wire_payload_schema_ref: &str,
657 backpressure_capacity: &Option<usize>,
658) -> Result<(), SubscriptionRuntimeError> {
659 if projection_id.is_empty() {
660 return Err(SubscriptionRuntimeError::InvalidRoute {
661 reason: "projection id is empty",
662 });
663 }
664 if entity.is_empty() {
665 return Err(SubscriptionRuntimeError::InvalidRoute {
666 reason: "entity is empty",
667 });
668 }
669 validate_wire_and_backpressure(wire_payload_schema_ref, backpressure_capacity)
670}
671
672fn validate_operation_status_route(
673 operation: &OperationName,
674 entity: &str,
675 wire_payload_schema_ref: &str,
676 backpressure_capacity: &Option<usize>,
677) -> Result<(), SubscriptionRuntimeError> {
678 if entity.is_empty() {
679 return Err(SubscriptionRuntimeError::InvalidRoute {
680 reason: "entity is empty",
681 });
682 }
683 let expected = operation_status_entity(operation.as_str()).map_err(|_| {
684 SubscriptionRuntimeError::InvalidRoute {
685 reason: "operation name produces invalid status entity",
686 }
687 })?;
688 if entity != expected {
689 return Err(SubscriptionRuntimeError::InvalidRoute {
690 reason: "entity does not match operation-status entity helper",
691 });
692 }
693 validate_wire_and_backpressure(wire_payload_schema_ref, backpressure_capacity)
694}
695
696fn validate_entity_stream_route(
697 entity: &str,
698 scope: &str,
699 wire_payload_schema_ref: &str,
700 backpressure_capacity: &Option<usize>,
701) -> Result<(), SubscriptionRuntimeError> {
702 Coordinate::new(entity, scope).map_err(|_| SubscriptionRuntimeError::InvalidRoute {
703 reason: "entity coordinate is invalid",
704 })?;
705 validate_wire_and_backpressure(wire_payload_schema_ref, backpressure_capacity)
706}
707
708fn validate_receipt_stream_route(
709 receipt_kind: &str,
710 wire_payload_schema_ref: &str,
711 backpressure_capacity: &Option<usize>,
712) -> Result<(), SubscriptionRuntimeError> {
713 validate_receipt_kind(receipt_kind)?;
714 validate_wire_and_backpressure(wire_payload_schema_ref, backpressure_capacity)
715}
716
717fn validate_wire_and_backpressure(
718 wire_payload_schema_ref: &str,
719 backpressure_capacity: &Option<usize>,
720) -> Result<(), SubscriptionRuntimeError> {
721 if let Err(reason) = validate_wire_payload_schema_ref(wire_payload_schema_ref) {
722 return Err(SubscriptionRuntimeError::InvalidRoute { reason });
723 }
724 if matches!(backpressure_capacity, Some(0)) {
725 return Err(SubscriptionRuntimeError::InvalidRoute {
726 reason: "backpressure capacity is zero",
727 });
728 }
729 Ok(())
730}
731
732fn validate_wire_payload_schema_ref(reference: &str) -> Result<(), &'static str> {
733 if reference.is_empty() {
734 return Err("wire payload schema ref is empty");
735 }
736 if reference.len() > MAX_DESCRIPTOR_REF_BYTES {
737 return Err("wire payload schema ref longer than 256 bytes");
738 }
739 if reference.contains(char::is_whitespace) {
740 return Err("wire payload schema ref contains whitespace");
741 }
742 if !reference.bytes().all(|byte| {
743 byte.is_ascii_lowercase() || byte.is_ascii_digit() || matches!(byte, b'.' | b'_' | b'-')
744 }) {
745 return Err("wire payload schema ref has characters outside [a-z0-9._-]");
746 }
747 Ok(())
748}
749
750fn validate_receipt_kind(value: &str) -> Result<(), SubscriptionRuntimeError> {
751 if value.is_empty() {
752 return Err(SubscriptionRuntimeError::InvalidRoute {
753 reason: "receipt kind is empty",
754 });
755 }
756 if value.len() > MAX_DESCRIPTOR_REF_BYTES {
757 return Err(SubscriptionRuntimeError::InvalidRoute {
758 reason: "receipt kind is too long",
759 });
760 }
761 if value
762 .bytes()
763 .any(|byte| !matches!(byte, b'a'..=b'z' | b'A'..=b'Z' | b'0'..=b'9' | b'.' | b'_' | b'-'))
764 {
765 return Err(SubscriptionRuntimeError::InvalidRoute {
766 reason: "receipt kind has invalid characters",
767 });
768 }
769 if value.starts_with('.') || value.ends_with('.') || value.contains("..") {
770 return Err(SubscriptionRuntimeError::InvalidRoute {
771 reason: "receipt kind has invalid dot placement",
772 });
773 }
774 Ok(())
775}
776
777fn validate_subscription_id(id: &str) -> Result<(), &'static str> {
780 if id.is_empty() {
781 return Err("empty subscription id");
782 }
783 if id.len() > MAX_SUBSCRIPTION_ID_BYTES {
784 return Err("subscription id longer than 128 bytes");
785 }
786 if id.starts_with('.') || id.ends_with('.') {
787 return Err("subscription id has a leading or trailing '.'");
788 }
789 if id.contains("..") {
790 return Err("subscription id has a doubled '.'");
791 }
792 let Some(dot_v) = id.rfind(".v") else {
793 return Err("subscription id must contain a .v version suffix");
794 };
795 let name = &id[..dot_v];
796 let version = &id[dot_v + 2..];
797 if name.is_empty() {
798 return Err("subscription id name prefix is empty");
799 }
800 if !name
801 .bytes()
802 .next()
803 .is_some_and(|b| b.is_ascii_lowercase() || b.is_ascii_digit())
804 {
805 return Err("subscription id must start with [a-z0-9]");
806 }
807 if !name
808 .bytes()
809 .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || matches!(b, b'.' | b'_' | b'-'))
810 {
811 return Err("subscription id has characters outside [a-z0-9._-]");
812 }
813 if version.is_empty() {
814 return Err("subscription id missing version digits after .v");
815 }
816 let first = version.as_bytes()[0];
817 if !first.is_ascii_digit() || first == b'0' {
818 return Err("subscription id version must start with 1-9");
819 }
820 if !version.chars().all(|c| c.is_ascii_digit()) {
821 return Err("subscription id version must be digits only");
822 }
823 Ok(())
824}