1use crate::content::ContentType;
2use crate::destination::BackendBinding;
3use crate::error::InvalidError;
4use crate::limits::{
5 MAX_GRAPH_LABEL_BYTES, MAX_INDEX_FIELD_BYTES, MAX_PROJECTION_ID_BYTES,
6 MAX_PROJECTION_NAME_BYTES, MAX_SCHEMA_NAME_BYTES,
7};
8use crate::validate::{Validate, validate_bounded_text};
9use serde::{Deserialize, Serialize};
10use std::str::FromStr;
11
12pub const RESERVED_PROJECTION_ID_PREFIX: &str = "index:";
15
16#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
28pub struct IndexField {
29 pub name: String,
30 pub pointer: String,
31 #[serde(default, skip_serializing_if = "Option::is_none")]
36 pub field_type: Option<FieldType>,
37}
38
39impl IndexField {
40 pub fn new(name: impl Into<String>, pointer: impl Into<String>) -> Self {
42 Self {
43 name: name.into(),
44 pointer: pointer.into(),
45 field_type: None,
46 }
47 }
48
49 pub fn typed(
51 name: impl Into<String>,
52 pointer: impl Into<String>,
53 field_type: FieldType,
54 ) -> Self {
55 Self {
56 name: name.into(),
57 pointer: pointer.into(),
58 field_type: Some(field_type),
59 }
60 }
61}
62
63#[derive(
67 Clone,
68 Copy,
69 Debug,
70 PartialEq,
71 Eq,
72 Serialize,
73 Deserialize,
74 strum::Display,
75 strum::EnumString,
76 strum::VariantArray,
77)]
78#[serde(rename_all = "snake_case")]
79#[strum(serialize_all = "snake_case")]
80#[non_exhaustive]
81pub enum FieldType {
82 Text,
83 Int,
84 Float,
85 Bool,
86}
87
88#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
90pub struct IndexSchema {
91 pub fields: Vec<IndexField>,
92 #[serde(default, skip_serializing_if = "Option::is_none")]
96 pub vector_field: Option<String>,
97 #[serde(default)]
102 pub inline_payload: bool,
103}
104
105impl IndexSchema {
106 pub fn builder() -> IndexSchemaBuilder {
108 IndexSchemaBuilder::default()
109 }
110}
111
112#[derive(Default)]
114pub struct IndexSchemaBuilder {
115 schema: IndexSchema,
116}
117
118impl IndexSchemaBuilder {
119 pub fn field(mut self, name: impl Into<String>) -> Self {
125 let name = name.into();
126 let pointer = format!("/{name}");
127 self.schema.fields.push(IndexField::new(name, pointer));
128 self
129 }
130
131 pub fn field_at(mut self, name: impl Into<String>, pointer: impl Into<String>) -> Self {
137 self.schema.fields.push(IndexField::new(name, pointer));
138 self
139 }
140
141 pub fn vector_field(mut self, pointer: impl Into<String>) -> Self {
144 self.schema.vector_field = Some(pointer.into());
145 self
146 }
147
148 pub fn inline_payload(mut self) -> Self {
151 self.schema.inline_payload = true;
152 self
153 }
154
155 pub fn build(self) -> IndexSchema {
157 self.schema
158 }
159}
160
161#[derive(Clone, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
167#[serde(transparent)]
168pub struct ProjectionId(String);
169
170impl ProjectionId {
171 pub fn new(value: impl Into<String>) -> Self {
173 Self(value.into())
174 }
175
176 pub fn as_str(&self) -> &str {
178 &self.0
179 }
180}
181
182impl std::fmt::Display for ProjectionId {
183 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
184 f.write_str(&self.0)
185 }
186}
187
188impl AsRef<str> for ProjectionId {
189 fn as_ref(&self) -> &str {
190 &self.0
191 }
192}
193
194impl std::borrow::Borrow<str> for ProjectionId {
195 fn borrow(&self) -> &str {
196 &self.0
197 }
198}
199
200impl FromStr for ProjectionId {
201 type Err = InvalidError;
202
203 fn from_str(s: &str) -> Result<Self, Self::Err> {
204 validate_projection_id(s)?;
205 Ok(Self(s.to_owned()))
206 }
207}
208
209impl From<&str> for ProjectionId {
210 fn from(value: &str) -> Self {
211 Self(value.to_owned())
212 }
213}
214
215impl From<String> for ProjectionId {
216 fn from(value: String) -> Self {
217 Self(value)
218 }
219}
220
221#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
226#[serde(from = "u8", into = "u8")]
227pub enum ProjectionKind {
228 #[default]
230 Row,
231 Graph,
233 Unrecognized(u8),
235}
236
237impl ProjectionKind {
238 pub const fn code(self) -> u8 {
240 match self {
241 ProjectionKind::Row => 0,
242 ProjectionKind::Graph => 1,
243 ProjectionKind::Unrecognized(code) => code,
244 }
245 }
246
247 pub const fn from_code(code: u8) -> Self {
250 match code {
251 0 => ProjectionKind::Row,
252 1 => ProjectionKind::Graph,
253 other => ProjectionKind::Unrecognized(other),
254 }
255 }
256
257 pub const fn is_row(&self) -> bool {
259 matches!(self, ProjectionKind::Row)
260 }
261}
262
263impl From<u8> for ProjectionKind {
264 fn from(code: u8) -> Self {
265 Self::from_code(code)
266 }
267}
268
269impl From<ProjectionKind> for u8 {
270 fn from(kind: ProjectionKind) -> u8 {
271 kind.code()
272 }
273}
274
275#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
280pub struct EntitySchema {
281 pub nodes: Vec<NodeExtract>,
282 #[serde(default, skip_serializing_if = "Vec::is_empty")]
283 pub edges: Vec<EdgeExtract>,
284}
285
286#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
289pub struct NodeExtract {
290 pub label: String,
291 pub value_pointer: String,
292 #[serde(default, skip_serializing_if = "Option::is_none")]
293 pub embedding_pointer: Option<String>,
294}
295
296#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
299pub struct EdgeExtract {
300 pub edge_type: String,
301 pub from_pointer: String,
302 pub to_pointer: String,
303 #[serde(default, skip_serializing_if = "Option::is_none")]
306 pub valid_from_pointer: Option<String>,
307 #[serde(default, skip_serializing_if = "Option::is_none")]
310 pub valid_to_pointer: Option<String>,
311}
312
313#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
334pub struct Projection {
335 pub id: ProjectionId,
337 pub name: String,
340 pub version: u32,
343 #[serde(default, skip_serializing_if = "ProjectionKind::is_row")]
345 pub kind: ProjectionKind,
346 pub content_type: ContentType,
349 pub extraction: IndexSchema,
351 #[serde(default, skip_serializing_if = "Option::is_none")]
354 pub entity_schema: Option<EntitySchema>,
355 #[serde(default)]
358 pub inline_payload_default: bool,
359}
360
361impl Projection {
362 pub fn builder(id: impl Into<ProjectionId>) -> ProjectionBuilder {
364 ProjectionBuilder {
365 projection: Self {
366 id: id.into(),
367 name: String::new(),
368 version: 1,
369 kind: ProjectionKind::Row,
370 content_type: ContentType::Any,
371 extraction: IndexSchema {
376 fields: Vec::new(),
377 vector_field: None,
378 inline_payload: true,
379 },
380 entity_schema: None,
381 inline_payload_default: true,
382 },
383 }
384 }
385}
386
387pub struct ProjectionBuilder {
389 projection: Projection,
390}
391
392impl ProjectionBuilder {
393 pub fn name(mut self, value: impl Into<String>) -> Self {
395 self.projection.name = value.into();
396 self
397 }
398
399 pub fn version(mut self, value: u32) -> Self {
401 self.projection.version = value;
402 self
403 }
404
405 pub fn content_type(mut self, value: ContentType) -> Self {
407 self.projection.content_type = value;
408 self
409 }
410
411 pub fn extraction(mut self, value: IndexSchema) -> Self {
413 self.projection.extraction = value;
414 self
415 }
416
417 pub fn graph(mut self, schema: EntitySchema) -> Self {
419 self.projection.kind = ProjectionKind::Graph;
420 self.projection.entity_schema = Some(schema);
421 self
422 }
423
424 pub fn field(mut self, name: impl Into<String>) -> Self {
426 let name = name.into();
427 let pointer = format!("/{name}");
428 self.projection
429 .extraction
430 .fields
431 .push(IndexField::new(name, pointer));
432 self
433 }
434
435 pub fn fields<I, S>(mut self, names: I) -> Self
439 where
440 I: IntoIterator<Item = S>,
441 S: Into<String>,
442 {
443 for name in names {
444 let name = name.into();
445 let pointer = format!("/{name}");
446 self.projection
447 .extraction
448 .fields
449 .push(IndexField::new(name, pointer));
450 }
451 self
452 }
453
454 pub fn field_at(mut self, name: impl Into<String>, pointer: impl Into<String>) -> Self {
456 self.projection
457 .extraction
458 .fields
459 .push(IndexField::new(name, pointer));
460 self
461 }
462
463 pub fn field_typed(mut self, name: impl Into<String>, field_type: FieldType) -> Self {
467 let name = name.into();
468 let pointer = format!("/{name}");
469 self.projection
470 .extraction
471 .fields
472 .push(IndexField::typed(name, pointer, field_type));
473 self
474 }
475
476 pub fn field_at_typed(
478 mut self,
479 name: impl Into<String>,
480 pointer: impl Into<String>,
481 field_type: FieldType,
482 ) -> Self {
483 self.projection
484 .extraction
485 .fields
486 .push(IndexField::typed(name, pointer, field_type));
487 self
488 }
489
490 pub fn vector_field(mut self, pointer: impl Into<String>) -> Self {
492 self.projection.extraction.vector_field = Some(pointer.into());
493 self
494 }
495
496 pub fn inline_payload(mut self) -> Self {
501 self.projection.inline_payload_default = true;
502 self.projection.extraction.inline_payload = true;
503 self
504 }
505
506 pub fn index_only(mut self) -> Self {
521 self.projection.inline_payload_default = false;
522 self.projection.extraction.inline_payload = false;
523 self
524 }
525
526 pub fn build(self) -> Projection {
528 self.projection
529 }
530}
531
532#[derive(Clone, Copy, Debug, PartialEq, Eq, Default, Serialize, Deserialize)]
540#[serde(tag = "kind", rename_all = "snake_case")]
541#[non_exhaustive]
542pub enum RetentionPolicy {
543 #[default]
547 MirrorLog,
548 Keep,
550 KeepUntilSourceDeleted,
554 TimeToLive { ttl_micros: u64 },
557 MaxRows { rows: u64 },
559 #[serde(other)]
565 Unknown,
566}
567
568#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
576pub struct ProjectionBinding {
577 pub source: SourceSelector,
578 #[serde(default)]
582 pub allowed_projections: Vec<ProjectionId>,
583 #[serde(default)]
586 pub default_projection: Option<ProjectionId>,
587 #[serde(default, skip_serializing_if = "Option::is_none")]
590 pub backend: Option<BackendBinding>,
591 pub index: String,
593 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
598 pub notify: bool,
599 #[serde(default, skip_serializing_if = "Option::is_none")]
604 pub retention: Option<RetentionPolicy>,
605 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
614 pub skip_rejected: bool,
615}
616
617impl ProjectionBinding {
618 pub fn builder() -> ProjectionBindingBuilder {
620 ProjectionBindingBuilder::default()
621 }
622}
623
624#[derive(Default)]
626pub struct ProjectionBindingBuilder {
627 source: Option<SourceSelector>,
628 allowed: Vec<ProjectionId>,
629 default_projection: Option<ProjectionId>,
630 backend: Option<BackendBinding>,
631 index: Option<String>,
632 notify: bool,
633 retention: Option<RetentionPolicy>,
634 skip_rejected: bool,
635}
636
637impl ProjectionBindingBuilder {
638 pub fn source(mut self, stream: impl Into<String>, topic: impl Into<String>) -> Self {
641 self.source = Some(SourceSelector::new(stream, topic));
642 self
643 }
644
645 pub fn selector(mut self, source: SourceSelector) -> Self {
647 self.source = Some(source);
648 self
649 }
650
651 pub fn allow(mut self, projection: impl Into<ProjectionId>) -> Self {
653 self.allowed.push(projection.into());
654 self
655 }
656
657 pub fn default_projection(mut self, projection: impl Into<ProjectionId>) -> Self {
659 self.default_projection = Some(projection.into());
660 self
661 }
662
663 pub fn backend(mut self, backend: BackendBinding) -> Self {
666 self.backend = Some(backend);
667 self
668 }
669
670 pub fn retention(mut self, retention: RetentionPolicy) -> Self {
684 self.retention = Some(retention);
685 self
686 }
687
688 pub fn index(mut self, index: impl Into<String>) -> Self {
691 self.index = Some(index.into());
692 self
693 }
694
695 #[must_use]
698 pub fn notify(mut self) -> Self {
699 self.notify = true;
700 self
701 }
702
703 #[must_use]
707 pub fn skip_rejected(mut self) -> Self {
708 self.skip_rejected = true;
709 self
710 }
711
712 pub fn build(self) -> ProjectionBinding {
716 self.try_build()
717 .expect("ProjectionBinding requires a source - call .source(stream, topic)")
718 }
719
720 pub fn try_build(self) -> Result<ProjectionBinding, InvalidError> {
723 let source = self
724 .source
725 .ok_or_else(|| InvalidError::new("ProjectionBinding requires a source"))?;
726 let index = self.index.unwrap_or_else(|| source.topic.clone());
727 Ok(ProjectionBinding {
728 source,
729 allowed_projections: self.allowed,
730 default_projection: self.default_projection,
731 backend: self.backend,
732 index,
733 notify: self.notify,
734 retention: self.retention,
735 skip_rejected: self.skip_rejected,
736 })
737 }
738}
739
740#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
745pub struct SourceSelector {
746 pub stream: String,
747 pub topic: String,
748}
749
750impl SourceSelector {
751 pub fn new(stream: impl Into<String>, topic: impl Into<String>) -> Self {
753 Self {
754 stream: stream.into(),
755 topic: topic.into(),
756 }
757 }
758}
759
760#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
767pub struct SchemaDef {
768 pub id: u32,
769 pub source: SchemaSource,
770 #[serde(default, skip_serializing_if = "Option::is_none")]
774 pub name: Option<String>,
775 #[serde(default, skip_serializing_if = "Option::is_none")]
780 pub version: Option<u32>,
781}
782
783impl SchemaDef {
784 pub fn content_type(&self) -> ContentType {
787 match self.source {
788 SchemaSource::Avro { .. } => ContentType::Avro,
789 SchemaSource::Protobuf { .. } => ContentType::Protobuf,
790 SchemaSource::JsonSchema { .. } => ContentType::Json,
791 SchemaSource::Unknown => ContentType::Any,
793 }
794 }
795}
796
797#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
802#[serde(tag = "kind", rename_all = "snake_case")]
803#[non_exhaustive]
804pub enum SchemaSource {
805 Avro { schema: String },
807 Protobuf {
810 #[serde(with = "crate::encoding::bin_bytes")]
811 descriptor_set: Vec<u8>,
812 message_type: String,
813 },
814 JsonSchema { schema: String },
819 #[serde(other)]
825 Unknown,
826}
827
828#[derive(Clone, Debug, Serialize, Deserialize)]
832pub enum ControlCommand {
833 RegisterProjection(Projection),
834 DropProjection(String),
835 ApplyBinding(ProjectionBinding),
836 RemoveBinding {
837 source: SourceSelector,
838 projection_ref: Option<String>,
839 },
840 RegisterSchema(SchemaDef),
842 DropSchema(u32),
844 RegisterGraph(Projection),
849 DropGraph(String),
851 RegisterSessionSource {
853 stream: String,
854 topics: SessionTopics,
855 },
856 RemoveSessionSource {
858 stream: String,
859 },
860 FilterCatalog(Box<crate::filter::FilterCatalogCommand>),
863 AcceptGap(GapAcceptance),
869 SkipRecord(RecordSkip),
875}
876
877#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
879pub struct RecordSkip {
880 pub source: SourceSelector,
881 pub partition_id: u32,
882 pub topic_created_at_micros: u64,
885 pub offset: u64,
886 pub target: String,
889 pub reason: String,
891}
892
893impl RecordSkip {
894 pub fn covers(
897 &self,
898 stream: &str,
899 topic: &str,
900 partition_id: u32,
901 generation: u64,
902 offset: u64,
903 target: &str,
904 ) -> bool {
905 self.source.stream == stream
906 && self.source.topic == topic
907 && self.partition_id == partition_id
908 && self.topic_created_at_micros == generation
909 && self.offset == offset
910 && self.target == target
911 }
912}
913
914#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
916pub struct GapAcceptance {
917 pub source: SourceSelector,
918 pub partition_id: u32,
919 pub topic_created_at_micros: u64,
922 pub from_offset: u64,
924 pub to_offset: u64,
926}
927
928impl GapAcceptance {
929 pub fn covers(&self, generation: u64, cursor: u64, first_available: u64) -> bool {
932 self.topic_created_at_micros == generation
933 && self.from_offset <= cursor
934 && self.to_offset >= first_available
935 }
936}
937
938#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
940pub enum SessionTopics {
941 All,
942 Named(Vec<String>),
943}
944
945#[derive(Clone, Debug, Serialize, Deserialize)]
947pub struct ControlEnvelope {
948 pub v: u32,
949 pub timestamp_micros: u64,
950 pub command: ControlCommand,
951 #[serde(default, skip_serializing_if = "Option::is_none")]
957 pub stream: Option<String>,
958 #[serde(default, skip_serializing_if = "Option::is_none")]
963 pub stamp: Option<ControlStamp>,
964}
965
966#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
968pub struct ControlStamp {
969 pub deployment_id: u64,
972 pub actor_user_id: u32,
975 pub operation_id: u128,
978 #[serde(default, skip_serializing_if = "Option::is_none")]
981 pub stream_generation: Option<ControlStreamGeneration>,
982 pub key_id: u64,
984 #[serde(default, skip_serializing_if = "Option::is_none")]
988 pub caller: Option<crate::forward::Caller>,
989}
990
991#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
994pub struct ControlStreamGeneration {
995 pub stream_id: u32,
996 pub created_at_micros: u64,
997}
998
999impl Validate for ProjectionId {
1000 fn validate(&self) -> Result<(), InvalidError> {
1001 validate_projection_id(&self.0)
1002 }
1003}
1004
1005impl Validate for Projection {
1006 fn validate(&self) -> Result<(), InvalidError> {
1007 self.id.validate()?;
1008 if !self.name.is_empty() {
1009 validate_bounded_text("projection name", &self.name, MAX_PROJECTION_NAME_BYTES)?;
1010 }
1011 for field in &self.extraction.fields {
1012 validate_bounded_text("index field name", &field.name, MAX_INDEX_FIELD_BYTES)?;
1013 validate_bounded_text("index field pointer", &field.pointer, MAX_INDEX_FIELD_BYTES)?;
1014 }
1015 if let Some(vector_field) = &self.extraction.vector_field {
1016 validate_bounded_text("index vector field", vector_field, MAX_INDEX_FIELD_BYTES)?;
1017 }
1018 if let Some(entity_schema) = &self.entity_schema {
1019 for node in &entity_schema.nodes {
1020 validate_bounded_text("graph node label", &node.label, MAX_GRAPH_LABEL_BYTES)?;
1021 }
1022 for edge in &entity_schema.edges {
1023 validate_bounded_text("graph edge type", &edge.edge_type, MAX_GRAPH_LABEL_BYTES)?;
1024 }
1025 }
1026 Ok(())
1027 }
1028}
1029
1030impl Validate for ProjectionBinding {
1031 fn validate(&self) -> Result<(), InvalidError> {
1032 for projection in &self.allowed_projections {
1033 projection.validate()?;
1034 }
1035 if let Some(projection) = &self.default_projection {
1036 projection.validate()?;
1037 }
1038 Ok(())
1039 }
1040}
1041
1042impl Validate for SchemaDef {
1043 fn validate(&self) -> Result<(), InvalidError> {
1044 if let Some(name) = &self.name {
1045 validate_bounded_text("schema name", name, MAX_SCHEMA_NAME_BYTES)?;
1046 }
1047 Ok(())
1048 }
1049}
1050
1051impl Validate for ControlCommand {
1052 fn validate(&self) -> Result<(), InvalidError> {
1053 match self {
1054 Self::RegisterProjection(projection) | Self::RegisterGraph(projection) => {
1055 projection.validate()
1056 }
1057 Self::DropProjection(id) | Self::DropGraph(id) => validate_projection_id(id),
1058 Self::ApplyBinding(binding) => binding.validate(),
1059 Self::RemoveBinding { projection_ref, .. } => projection_ref
1060 .as_deref()
1061 .map_or(Ok(()), validate_projection_id),
1062 Self::RegisterSchema(schema) => schema.validate(),
1063 Self::FilterCatalog(command) => command.mutation.validate(),
1064 Self::DropSchema(_)
1065 | Self::RegisterSessionSource { .. }
1066 | Self::RemoveSessionSource { .. }
1067 | Self::AcceptGap(_)
1068 | Self::SkipRecord(_) => Ok(()),
1069 }
1070 }
1071}
1072
1073fn validate_projection_id(id: &str) -> Result<(), InvalidError> {
1074 validate_bounded_text("projection id", id, MAX_PROJECTION_ID_BYTES)?;
1075 if id.starts_with(RESERVED_PROJECTION_ID_PREFIX) {
1076 return Err(InvalidError::new(format!(
1077 "projection id must not start with {RESERVED_PROJECTION_ID_PREFIX:?}"
1078 )));
1079 }
1080 Ok(())
1081}
1082
1083#[cfg(test)]
1084mod tests {
1085 use super::*;
1086 use crate::destination::ProjectionRef;
1087 use crate::limits::{
1088 MAX_GRAPH_LABEL_BYTES, MAX_INDEX_FIELD_BYTES, MAX_PROJECTION_ID_BYTES,
1089 MAX_PROJECTION_NAME_BYTES, MAX_SCHEMA_NAME_BYTES,
1090 };
1091
1092 #[test]
1093 fn given_an_invalid_projection_id_when_any_command_names_it_then_should_refuse_it() {
1094 let projection = |id: &str| {
1095 Projection::builder(id)
1096 .name("reading")
1097 .field("host")
1098 .build()
1099 };
1100 let at_cap = "p".repeat(MAX_PROJECTION_ID_BYTES);
1101 projection(&at_cap)
1102 .validate()
1103 .expect("a projection id at the cap is valid");
1104 ControlCommand::DropProjection(at_cap.clone())
1105 .validate()
1106 .expect("a drop of a projection id at the cap is valid");
1107 ControlCommand::RemoveBinding {
1108 source: SourceSelector::new("fleet", "readings"),
1109 projection_ref: None,
1110 }
1111 .validate()
1112 .expect("a whole-binding removal names no projection");
1113 for invalid in [
1114 String::new(),
1115 "p".repeat(MAX_PROJECTION_ID_BYTES + 1),
1116 "reading\n.v1".to_owned(),
1117 format!("{RESERVED_PROJECTION_ID_PREFIX}reading"),
1118 ] {
1119 assert!(projection(&invalid).validate().is_err(), "{invalid:?}");
1120 assert!(
1121 ControlCommand::RegisterProjection(projection(&invalid))
1122 .validate()
1123 .is_err()
1124 );
1125 assert!(
1126 ControlCommand::DropProjection(invalid.clone())
1127 .validate()
1128 .is_err()
1129 );
1130 assert!(
1131 ControlCommand::DropGraph(invalid.clone())
1132 .validate()
1133 .is_err()
1134 );
1135 let allowed = ProjectionBinding::builder()
1136 .source("fleet", "readings")
1137 .allow(invalid.as_str())
1138 .build();
1139 assert!(ControlCommand::ApplyBinding(allowed).validate().is_err());
1140 let defaulted = ProjectionBinding::builder()
1141 .source("fleet", "readings")
1142 .default_projection(invalid.as_str())
1143 .build();
1144 assert!(defaulted.validate().is_err());
1145 assert!(
1146 ControlCommand::RemoveBinding {
1147 source: SourceSelector::new("fleet", "readings"),
1148 projection_ref: Some(invalid.clone()),
1149 }
1150 .validate()
1151 .is_err()
1152 );
1153 assert!(
1154 ProjectionRef {
1155 id: ProjectionId::new(invalid.clone()),
1156 version: 1,
1157 }
1158 .validate()
1159 .is_err()
1160 );
1161 assert!(invalid.parse::<ProjectionId>().is_err());
1162 }
1163 }
1164
1165 #[test]
1166 fn given_a_projection_name_over_the_cap_when_validated_then_should_refuse_it_and_accept_none() {
1167 let named = |name: &str| Projection::builder("reading.v1").name(name).build();
1168 named("").validate().expect("a projection name is optional");
1169 named(&"n".repeat(MAX_PROJECTION_NAME_BYTES))
1170 .validate()
1171 .expect("a projection name at the cap is valid");
1172 assert!(
1173 named(&"n".repeat(MAX_PROJECTION_NAME_BYTES + 1))
1174 .validate()
1175 .is_err()
1176 );
1177 assert!(named("read\ting").validate().is_err());
1178 }
1179
1180 #[test]
1181 fn given_an_invalid_index_field_when_a_projection_is_validated_then_should_refuse_it() {
1182 let with_field = |name: &str, pointer: &str| {
1183 Projection::builder("reading.v1")
1184 .field_at(name, pointer)
1185 .build()
1186 };
1187 with_field("host", "/host")
1188 .validate()
1189 .expect("a plain index field is valid");
1190 let long = "f".repeat(MAX_INDEX_FIELD_BYTES + 1);
1191 assert!(with_field(&long, "/host").validate().is_err());
1192 assert!(with_field("host", &long).validate().is_err());
1193 assert!(with_field("", "/host").validate().is_err());
1194 assert!(with_field("host", "").validate().is_err());
1195 assert!(with_field("ho\u{7}st", "/host").validate().is_err());
1196 assert!(
1197 Projection::builder("reading.v1")
1198 .vector_field(long)
1199 .build()
1200 .validate()
1201 .is_err()
1202 );
1203 assert!(
1204 Projection::builder("reading.v1")
1205 .vector_field("")
1206 .build()
1207 .validate()
1208 .is_err()
1209 );
1210 }
1211
1212 #[test]
1213 fn given_an_invalid_schema_name_when_registered_then_should_refuse_it() {
1214 let schema = |name: Option<String>| SchemaDef {
1215 id: 7,
1216 source: SchemaSource::JsonSchema {
1217 schema: "{}".to_owned(),
1218 },
1219 name,
1220 version: None,
1221 };
1222 schema(None)
1223 .validate()
1224 .expect("a schema without a name is valid");
1225 ControlCommand::RegisterSchema(schema(Some("s".repeat(MAX_SCHEMA_NAME_BYTES))))
1226 .validate()
1227 .expect("a schema name at the cap is valid");
1228 for invalid in [
1229 String::new(),
1230 "s".repeat(MAX_SCHEMA_NAME_BYTES + 1),
1231 "read\ning".to_owned(),
1232 ] {
1233 assert!(schema(Some(invalid.clone())).validate().is_err());
1234 assert!(
1235 ControlCommand::RegisterSchema(schema(Some(invalid)))
1236 .validate()
1237 .is_err()
1238 );
1239 }
1240 }
1241
1242 #[test]
1243 fn given_an_invalid_graph_label_when_a_graph_projection_is_validated_then_should_refuse_it() {
1244 let graph = |label: &str, edge_type: &str| {
1245 Projection::builder("people.v1")
1246 .graph(EntitySchema {
1247 nodes: vec![NodeExtract {
1248 label: label.to_owned(),
1249 value_pointer: "/name".to_owned(),
1250 embedding_pointer: None,
1251 }],
1252 edges: vec![EdgeExtract {
1253 edge_type: edge_type.to_owned(),
1254 from_pointer: "/name".to_owned(),
1255 to_pointer: "/company".to_owned(),
1256 valid_from_pointer: None,
1257 valid_to_pointer: None,
1258 }],
1259 })
1260 .build()
1261 };
1262 let at_cap = "l".repeat(MAX_GRAPH_LABEL_BYTES);
1263 ControlCommand::RegisterGraph(graph(&at_cap, &at_cap))
1264 .validate()
1265 .expect("a label and an edge type at the cap are valid");
1266 let long = "l".repeat(MAX_GRAPH_LABEL_BYTES + 1);
1267 assert!(graph(&long, "works_at").validate().is_err());
1268 assert!(graph("Person", &long).validate().is_err());
1269 assert!(graph("", "works_at").validate().is_err());
1270 assert!(graph("Person", "").validate().is_err());
1271 assert!(
1272 ControlCommand::RegisterGraph(graph("Per\nson", "works_at"))
1273 .validate()
1274 .is_err()
1275 );
1276 }
1277
1278 #[test]
1279 fn given_a_binding_that_skips_rejected_records_when_encoded_then_should_carry_the_opt_in_only_when_set()
1280 {
1281 let plain = ProjectionBinding::builder().source("s", "t").build();
1282 assert!(!plain.skip_rejected);
1283 let plain_json = serde_json::to_value(&plain).unwrap();
1284 assert!(plain_json.get("skip_rejected").is_none());
1285 let opted = ProjectionBinding::builder()
1286 .source("s", "t")
1287 .skip_rejected()
1288 .build();
1289 let json = serde_json::to_value(&opted).unwrap();
1290 assert_eq!(json["skip_rejected"], true);
1291 let decoded: ProjectionBinding = serde_json::from_value(json).unwrap();
1292 assert_eq!(decoded, opted);
1293 }
1294
1295 #[test]
1296 fn given_a_record_skip_when_matched_then_should_cover_only_its_exact_record() {
1297 let skip = RecordSkip {
1298 source: SourceSelector::new("s", "t"),
1299 partition_id: 2,
1300 topic_created_at_micros: 7,
1301 offset: 40,
1302 target: "rows".to_owned(),
1303 reason: "rejected".to_owned(),
1304 };
1305 assert!(skip.covers("s", "t", 2, 7, 40, "rows"));
1306 assert!(
1307 !skip.covers("s", "t", 2, 8, 40, "rows"),
1308 "another incarnation"
1309 );
1310 assert!(!skip.covers("s", "t", 2, 7, 41, "rows"));
1311 assert!(!skip.covers("s", "t", 3, 7, 40, "rows"));
1312 assert!(!skip.covers("s", "t", 2, 7, 40, "other"));
1313 assert!(!skip.covers("s", "u", 2, 7, 40, "rows"));
1314 }
1315
1316 #[test]
1317 fn given_an_unknown_schema_source_kind_when_decoded_then_should_degrade_not_fail() {
1318 let source: SchemaSource =
1322 serde_json::from_str(r#"{"kind":"parquet_descriptor","blob":[1,2,3]}"#)
1323 .expect("an unknown schema kind decodes to Unknown, not an error");
1324 assert_eq!(source, SchemaSource::Unknown);
1325 let def = SchemaDef {
1327 id: 1,
1328 source,
1329 name: None,
1330 version: None,
1331 };
1332 assert_eq!(def.content_type(), ContentType::Any);
1333 }
1334
1335 #[test]
1336 fn given_an_unknown_retention_kind_when_decoded_then_should_degrade_not_fail() {
1337 let policy: RetentionPolicy = serde_json::from_str(r#"{"kind":"keep_for_eras","eras":3}"#)
1338 .expect("an unknown retention kind decodes to Unknown, not an error");
1339 assert_eq!(policy, RetentionPolicy::Unknown);
1340 }
1341
1342 #[test]
1343 fn given_an_explicit_operational_index_when_built_then_should_keep_one_index() {
1344 let binding = ProjectionBinding::builder()
1345 .source("fleet", "readings")
1346 .allow("reading.v1")
1347 .index("readings_rows")
1348 .build();
1349 assert_eq!(binding.index, "readings_rows");
1350 assert_eq!(binding.backend, None);
1351 }
1352
1353 #[test]
1354 fn given_an_operational_backend_when_built_then_should_keep_exact_generation() {
1355 let backend = BackendBinding {
1356 resource_id: crate::destination::BackendResourceId::from_u128(7),
1357 generation: 3,
1358 };
1359 let binding = ProjectionBinding::builder()
1360 .source("fleet", "readings")
1361 .backend(backend.clone())
1362 .build();
1363 assert_eq!(binding.backend, Some(backend));
1364 }
1365
1366 #[test]
1367 fn given_no_operational_index_when_built_then_should_use_the_topic_name() {
1368 let binding = ProjectionBinding::builder()
1369 .source("fleet", "readings")
1370 .allow("reading.v1")
1371 .build();
1372 assert_eq!(binding.index, "readings");
1373 }
1374
1375 #[test]
1376 fn given_no_retention_when_built_then_should_default_to_none() {
1377 let binding = ProjectionBinding::builder()
1378 .source("fleet", "readings")
1379 .index("readings_rows")
1380 .build();
1381 assert_eq!(binding.retention, None);
1382 }
1383
1384 #[test]
1385 fn given_no_source_when_try_built_then_should_error() {
1386 assert!(ProjectionBinding::builder().try_build().is_err());
1387 }
1388
1389 #[test]
1390 fn given_a_projection_built_with_the_default_when_inspected_then_should_inline_payload() {
1391 let projection = Projection::builder("api.call.v1")
1392 .name("api.call")
1393 .version(1)
1394 .fields(["endpoint", "status"])
1395 .build();
1396 assert!(
1397 projection.inline_payload_default,
1398 "Projection::builder default should inline payload"
1399 );
1400 assert!(
1401 projection.extraction.inline_payload,
1402 "Projection::builder default should mark extraction.inline_payload too"
1403 );
1404 }
1405
1406 #[test]
1407 fn given_a_projection_with_index_only_when_inspected_then_should_skip_inlining() {
1408 let projection = Projection::builder("api.call.v1")
1409 .name("api.call")
1410 .version(1)
1411 .fields(["endpoint"])
1412 .index_only()
1413 .build();
1414 assert!(
1415 !projection.inline_payload_default,
1416 "index_only must clear inline_payload_default"
1417 );
1418 assert!(
1419 !projection.extraction.inline_payload,
1420 "index_only must clear extraction.inline_payload"
1421 );
1422 }
1423
1424 #[test]
1425 fn given_an_empty_projection_id_when_parsed_then_should_error() {
1426 assert!("".parse::<ProjectionId>().is_err());
1427 assert_eq!(
1428 "reading.v1"
1429 .parse::<ProjectionId>()
1430 .expect("non-empty id parses")
1431 .as_str(),
1432 "reading.v1"
1433 );
1434 }
1435}
1436
1437#[cfg(all(test, feature = "cbor"))]
1438mod wire_tests {
1439 use super::*;
1440 use crate::codes::CONTROL_OP_VERSION;
1441 use crate::content::ContentType;
1442 use crate::framing::{decode_named, encode_named};
1443
1444 #[test]
1445 fn given_an_apply_binding_when_round_tripped_then_should_preserve_index_and_version() {
1446 let binding = ProjectionBinding::builder()
1447 .source("fleet", "readings")
1448 .allow("reading.v1")
1449 .default_projection("reading.v1")
1450 .index("readings_rows")
1451 .build();
1452 let envelope = ControlEnvelope {
1453 v: CONTROL_OP_VERSION,
1454 timestamp_micros: 42,
1455 command: ControlCommand::ApplyBinding(binding),
1456 stream: None,
1457 stamp: None,
1458 };
1459 let bytes = encode_named(&envelope).expect("envelope serializes");
1460 let back: ControlEnvelope = decode_named(&bytes).expect("envelope deserializes");
1461 assert_eq!(back.v, CONTROL_OP_VERSION);
1462 let ControlCommand::ApplyBinding(decoded) = back.command else {
1463 panic!("expected ApplyBinding");
1464 };
1465 assert_eq!(decoded.index, "readings_rows");
1466 assert_eq!(decoded.backend, None);
1467 assert_eq!(decoded.retention, None);
1470 }
1471
1472 #[test]
1473 fn given_a_register_schema_when_round_tripped_then_should_preserve_source() {
1474 let envelope = ControlEnvelope {
1475 v: CONTROL_OP_VERSION,
1476 timestamp_micros: 7,
1477 command: ControlCommand::RegisterSchema(SchemaDef {
1478 id: 11,
1479 source: SchemaSource::Avro {
1480 schema: r#"{"type":"record","name":"Reading","fields":[]}"#.to_owned(),
1481 },
1482 name: None,
1483 version: None,
1484 }),
1485 stream: None,
1486 stamp: None,
1487 };
1488 let bytes = encode_named(&envelope).expect("envelope serializes");
1489 let back: ControlEnvelope = decode_named(&bytes).expect("envelope deserializes");
1490 assert_eq!(back.stream, None);
1491 let ControlCommand::RegisterSchema(decoded) = back.command else {
1492 panic!("expected RegisterSchema");
1493 };
1494 assert_eq!(decoded.id, 11);
1495 assert_eq!(decoded.content_type(), ContentType::Avro);
1496 }
1497
1498 #[test]
1499 fn given_a_stream_scoped_drop_schema_when_round_tripped_then_should_preserve_the_stream() {
1500 let envelope = ControlEnvelope {
1501 v: CONTROL_OP_VERSION,
1502 timestamp_micros: 7,
1503 command: ControlCommand::DropSchema(11),
1504 stream: Some("acme".to_owned()),
1505 stamp: None,
1506 };
1507 let back: ControlEnvelope =
1508 decode_named(&encode_named(&envelope).expect("envelope serializes"))
1509 .expect("envelope deserializes");
1510 assert_eq!(back.stream.as_deref(), Some("acme"));
1511 assert!(matches!(back.command, ControlCommand::DropSchema(11)));
1512 let unscoped = ControlEnvelope {
1513 stream: None,
1514 ..envelope
1515 };
1516 let json = serde_json::to_string(&unscoped).expect("envelope serializes");
1517 assert!(
1518 !json.contains("stream"),
1519 "an unset stream is omitted: {json}"
1520 );
1521 }
1522
1523 #[test]
1524 fn given_a_stamped_envelope_when_round_tripped_then_should_keep_the_identity_and_omit_an_unset_stamp()
1525 {
1526 let envelope = ControlEnvelope {
1527 v: CONTROL_OP_VERSION,
1528 timestamp_micros: 7,
1529 command: ControlCommand::DropSchema(11),
1530 stream: Some("acme".to_owned()),
1531 stamp: Some(ControlStamp {
1532 deployment_id: 42,
1533 actor_user_id: 3,
1534 operation_id: u128::MAX - 1,
1535 stream_generation: Some(ControlStreamGeneration {
1536 stream_id: 9,
1537 created_at_micros: 1_700_000_000_000_000,
1538 }),
1539 key_id: u64::MAX,
1540 caller: None,
1541 }),
1542 };
1543 let back: ControlEnvelope =
1544 decode_named(&encode_named(&envelope).expect("envelope serializes"))
1545 .expect("envelope deserializes");
1546 assert_eq!(back.stamp, envelope.stamp);
1547 let unstamped = ControlEnvelope {
1548 stamp: None,
1549 ..envelope
1550 };
1551 let json = serde_json::to_string(&unstamped).expect("envelope serializes");
1552 assert!(
1553 !json.contains("\"stamp\""),
1554 "an unset stamp is omitted: {json}"
1555 );
1556 }
1557
1558 #[test]
1559 fn given_a_protobuf_schema_source_when_round_tripped_then_should_preserve_bytes() {
1560 let source = SchemaSource::Protobuf {
1561 descriptor_set: vec![10, 20, 30],
1562 message_type: "fleet.Reading".to_owned(),
1563 };
1564 let bytes = encode_named(&source).expect("serializes");
1565 let back: SchemaSource = decode_named(&bytes).expect("deserializes");
1566 assert_eq!(back, source);
1567 }
1568
1569 #[test]
1570 fn given_retention_set_when_round_tripped_then_should_preserve_policy() {
1571 for policy in [
1572 RetentionPolicy::MirrorLog,
1576 RetentionPolicy::Keep,
1577 RetentionPolicy::KeepUntilSourceDeleted,
1578 RetentionPolicy::TimeToLive {
1579 ttl_micros: 3_600_000_000,
1580 },
1581 RetentionPolicy::MaxRows { rows: 10_000 },
1582 ] {
1583 let binding = ProjectionBinding::builder()
1584 .source("fleet", "telemetry")
1585 .allow("telemetry.v1")
1586 .index("telemetry_rows")
1587 .retention(policy)
1588 .build();
1589 assert_eq!(binding.retention, Some(policy));
1590 let bytes = encode_named(&binding).expect("binding serializes");
1591 let back: ProjectionBinding = decode_named(&bytes).expect("binding deserializes");
1592 assert_eq!(back.retention, Some(policy));
1593 }
1594 }
1595}
1596
1597#[cfg(all(test, feature = "codecs"))]
1598mod schema_tests {
1599 use super::*;
1600 use crate::framing::{decode_named, encode_named};
1601
1602 #[test]
1603 fn given_a_schema_def_with_version_when_round_tripped_then_should_preserve_it() {
1604 let def = SchemaDef {
1605 id: 7,
1606 source: SchemaSource::Avro {
1607 schema: "{}".to_owned(),
1608 },
1609 name: Some("readings".to_owned()),
1610 version: Some(2),
1611 };
1612 let bytes = encode_named(&def).expect("serializes");
1613 let back: SchemaDef = decode_named(&bytes).expect("deserializes");
1614 assert_eq!(back.version, Some(2));
1615 let unversioned = SchemaDef {
1618 name: None,
1619 version: None,
1620 ..def
1621 };
1622 let json = serde_json::to_string(&unversioned).expect("serializes");
1623 assert!(
1624 !json.contains("version"),
1625 "unset version must be omitted: {json}"
1626 );
1627 }
1628}