Skip to main content

syncbat/subscription_runtime/
registry.rs

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
14/// Maximum bytes accepted for a subscription id under the subscription-id
15/// grammar. This is the single source the boundary crates (`hostbat`, `netbat`)
16/// share so their id caps can never disagree with the runtime registry's.
17pub const MAX_SUBSCRIPTION_ID_BYTES: usize = 128;
18
19/// Binding fields needed to open an operation-status subscription session.
20#[derive(Clone, Debug)]
21pub struct OperationStatusRouteBinding {
22    /// Globally unique subscription id.
23    pub subscription_id: String,
24    /// Route-declared operation name.
25    pub operation: OperationName,
26    /// Entity coordinate bound to the operation-status facts.
27    pub entity: String,
28    /// Wire payload schema ref token.
29    pub wire_payload_schema_ref: String,
30    /// Optional inner status schema ref.
31    pub inner_status_schema_ref: Option<String>,
32    /// Freshness mode for status materialization.
33    pub freshness: Freshness,
34    /// Optional route-specific queue clamp.
35    pub backpressure_capacity: Option<usize>,
36}
37
38/// Binding fields needed to open an entity-stream subscription session.
39#[derive(Clone, Debug)]
40pub struct EntityStreamRouteBinding {
41    /// Globally unique subscription id.
42    pub subscription_id: String,
43    /// Entity coordinate bound to the stream.
44    pub entity: String,
45    /// Scope coordinate bound to the stream.
46    pub scope: String,
47    /// Wire payload schema ref token.
48    pub wire_payload_schema_ref: String,
49    /// Optional inner event payload schema ref.
50    pub inner_event_payload_schema_ref: Option<String>,
51    /// Optional route-specific queue clamp.
52    pub backpressure_capacity: Option<usize>,
53}
54
55/// Binding fields needed to open a receipt-stream subscription session.
56#[derive(Clone, Debug)]
57pub struct ReceiptStreamRouteBinding {
58    /// Globally unique subscription id.
59    pub subscription_id: String,
60    /// Route-declared receipt kind filter.
61    pub receipt_kind: String,
62    /// Wire payload schema ref token.
63    pub wire_payload_schema_ref: String,
64    /// Optional inner receipt schema ref.
65    pub inner_receipt_schema_ref: Option<String>,
66    /// Optional route-specific queue clamp.
67    pub backpressure_capacity: Option<usize>,
68}
69
70/// Globally unique subscription id (`orders.open.v1` grammar).
71#[derive(Clone, Debug, Eq, PartialEq, Ord, PartialOrd)]
72pub struct SubscriptionId(String);
73
74impl SubscriptionId {
75    /// Construct and validate a subscription id.
76    ///
77    /// # Errors
78    /// [`SubscriptionRuntimeError::InvalidSubscriptionId`].
79    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    /// Borrow the id string.
87    #[must_use]
88    pub fn as_str(&self) -> &str {
89        &self.0
90    }
91}
92
93/// Declared route for one subscription id.
94#[derive(Clone)]
95pub enum SubscriptionRoute {
96    /// Category-wide event stream.
97    EventCategory {
98        /// Exported 4-bit event category filter.
99        category: u8,
100        /// Wire `payload_schema_ref` token for stream envelopes.
101        wire_payload_schema_ref: String,
102        /// Optional inner payload schema ref carried inside the envelope.
103        inner_event_payload_schema_ref: Option<String>,
104        /// Optional route-specific queue clamp.
105        backpressure_capacity: Option<usize>,
106    },
107    /// Exact coordinate event stream.
108    EntityStream {
109        /// Entity coordinate bound to the stream.
110        entity: String,
111        /// Scope coordinate bound to the stream.
112        scope: String,
113        /// Wire `payload_schema_ref` token for stream envelopes.
114        wire_payload_schema_ref: String,
115        /// Optional inner payload schema ref carried inside the envelope.
116        inner_event_payload_schema_ref: Option<String>,
117        /// Optional route-specific queue clamp.
118        backpressure_capacity: Option<usize>,
119    },
120    /// Entity-scoped projection stream.
121    Projection {
122        /// Route-declared projection id.
123        projection_id: String,
124        /// Entity coordinate bound to the projection.
125        entity: String,
126        /// Wire `payload_schema_ref` token for stream envelopes.
127        wire_payload_schema_ref: String,
128        /// Optional inner projection schema ref carried inside the envelope.
129        inner_projection_schema_ref: Option<String>,
130        /// Freshness mode for projection materialization.
131        freshness: Freshness,
132        /// Optional route-specific queue clamp.
133        backpressure_capacity: Option<usize>,
134        /// syncbat-owned projector that opens typed projection sessions.
135        projector: Arc<dyn ProjectionProjector>,
136    },
137    /// Operation-scoped status stream.
138    OperationStatus {
139        /// Route-declared operation name.
140        operation: OperationName,
141        /// Entity coordinate bound to the operation-status facts.
142        entity: String,
143        /// Wire `payload_schema_ref` token for stream envelopes.
144        wire_payload_schema_ref: String,
145        /// Optional inner status schema ref carried inside the envelope.
146        inner_status_schema_ref: Option<String>,
147        /// Freshness mode for status materialization.
148        freshness: Freshness,
149        /// Optional route-specific queue clamp.
150        backpressure_capacity: Option<usize>,
151    },
152    /// Receipt-kind filtered append stream.
153    ReceiptStream {
154        /// Receipt kind whose durable append events form the stream.
155        receipt_kind: String,
156        /// Wire `payload_schema_ref` token for stream envelopes.
157        wire_payload_schema_ref: String,
158        /// Optional inner receipt schema ref carried inside the envelope.
159        inner_receipt_schema_ref: Option<String>,
160        /// Optional route-specific queue clamp.
161        backpressure_capacity: Option<usize>,
162    },
163}
164
165impl SubscriptionRoute {
166    /// Return the event category for an event-category route.
167    #[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    /// Build an entity-stream route binding for session open.
179    #[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    /// Build a projection route binding for session open.
204    #[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    /// Build an operation-status route binding for session open.
231    #[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    /// Build a receipt-stream route binding for session open.
261    #[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/// Typed subscription route table for the runtime engine.
544#[derive(Clone, Debug, Default, Eq, PartialEq)]
545pub struct SubscriptionRegistry {
546    routes: BTreeMap<String, SubscriptionRoute>,
547}
548
549impl SubscriptionRegistry {
550    /// Empty registry.
551    #[must_use]
552    pub fn new() -> Self {
553        Self {
554            routes: BTreeMap::new(),
555        }
556    }
557
558    /// Register one route.
559    ///
560    /// # Errors
561    /// [`SubscriptionRuntimeError::DuplicateSubscription`] or
562    /// [`SubscriptionRuntimeError::InvalidRoute`].
563    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    /// Look up a route by subscription id text.
577    #[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
777/// Validate subscription id grammar:
778/// `^[a-z0-9][a-z0-9._-]*\.v[1-9][0-9]*$` with length and dot rules.
779fn 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}