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}
985
986#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
989pub struct ControlStreamGeneration {
990 pub stream_id: u32,
991 pub created_at_micros: u64,
992}
993
994impl Validate for ProjectionId {
995 fn validate(&self) -> Result<(), InvalidError> {
996 validate_projection_id(&self.0)
997 }
998}
999
1000impl Validate for Projection {
1001 fn validate(&self) -> Result<(), InvalidError> {
1002 self.id.validate()?;
1003 if !self.name.is_empty() {
1004 validate_bounded_text("projection name", &self.name, MAX_PROJECTION_NAME_BYTES)?;
1005 }
1006 for field in &self.extraction.fields {
1007 validate_bounded_text("index field name", &field.name, MAX_INDEX_FIELD_BYTES)?;
1008 validate_bounded_text("index field pointer", &field.pointer, MAX_INDEX_FIELD_BYTES)?;
1009 }
1010 if let Some(vector_field) = &self.extraction.vector_field {
1011 validate_bounded_text("index vector field", vector_field, MAX_INDEX_FIELD_BYTES)?;
1012 }
1013 if let Some(entity_schema) = &self.entity_schema {
1014 for node in &entity_schema.nodes {
1015 validate_bounded_text("graph node label", &node.label, MAX_GRAPH_LABEL_BYTES)?;
1016 }
1017 for edge in &entity_schema.edges {
1018 validate_bounded_text("graph edge type", &edge.edge_type, MAX_GRAPH_LABEL_BYTES)?;
1019 }
1020 }
1021 Ok(())
1022 }
1023}
1024
1025impl Validate for ProjectionBinding {
1026 fn validate(&self) -> Result<(), InvalidError> {
1027 for projection in &self.allowed_projections {
1028 projection.validate()?;
1029 }
1030 if let Some(projection) = &self.default_projection {
1031 projection.validate()?;
1032 }
1033 Ok(())
1034 }
1035}
1036
1037impl Validate for SchemaDef {
1038 fn validate(&self) -> Result<(), InvalidError> {
1039 if let Some(name) = &self.name {
1040 validate_bounded_text("schema name", name, MAX_SCHEMA_NAME_BYTES)?;
1041 }
1042 Ok(())
1043 }
1044}
1045
1046impl Validate for ControlCommand {
1047 fn validate(&self) -> Result<(), InvalidError> {
1048 match self {
1049 Self::RegisterProjection(projection) | Self::RegisterGraph(projection) => {
1050 projection.validate()
1051 }
1052 Self::DropProjection(id) | Self::DropGraph(id) => validate_projection_id(id),
1053 Self::ApplyBinding(binding) => binding.validate(),
1054 Self::RemoveBinding { projection_ref, .. } => projection_ref
1055 .as_deref()
1056 .map_or(Ok(()), validate_projection_id),
1057 Self::RegisterSchema(schema) => schema.validate(),
1058 Self::FilterCatalog(command) => command.mutation.validate(),
1059 Self::DropSchema(_)
1060 | Self::RegisterSessionSource { .. }
1061 | Self::RemoveSessionSource { .. }
1062 | Self::AcceptGap(_)
1063 | Self::SkipRecord(_) => Ok(()),
1064 }
1065 }
1066}
1067
1068fn validate_projection_id(id: &str) -> Result<(), InvalidError> {
1069 validate_bounded_text("projection id", id, MAX_PROJECTION_ID_BYTES)?;
1070 if id.starts_with(RESERVED_PROJECTION_ID_PREFIX) {
1071 return Err(InvalidError::new(format!(
1072 "projection id must not start with {RESERVED_PROJECTION_ID_PREFIX:?}"
1073 )));
1074 }
1075 Ok(())
1076}
1077
1078#[cfg(test)]
1079mod tests {
1080 use super::*;
1081 use crate::destination::ProjectionRef;
1082 use crate::limits::{
1083 MAX_GRAPH_LABEL_BYTES, MAX_INDEX_FIELD_BYTES, MAX_PROJECTION_ID_BYTES,
1084 MAX_PROJECTION_NAME_BYTES, MAX_SCHEMA_NAME_BYTES,
1085 };
1086
1087 #[test]
1088 fn given_an_invalid_projection_id_when_any_command_names_it_then_should_refuse_it() {
1089 let projection = |id: &str| {
1090 Projection::builder(id)
1091 .name("reading")
1092 .field("host")
1093 .build()
1094 };
1095 let at_cap = "p".repeat(MAX_PROJECTION_ID_BYTES);
1096 projection(&at_cap)
1097 .validate()
1098 .expect("a projection id at the cap is valid");
1099 ControlCommand::DropProjection(at_cap.clone())
1100 .validate()
1101 .expect("a drop of a projection id at the cap is valid");
1102 ControlCommand::RemoveBinding {
1103 source: SourceSelector::new("fleet", "readings"),
1104 projection_ref: None,
1105 }
1106 .validate()
1107 .expect("a whole-binding removal names no projection");
1108 for invalid in [
1109 String::new(),
1110 "p".repeat(MAX_PROJECTION_ID_BYTES + 1),
1111 "reading\n.v1".to_owned(),
1112 format!("{RESERVED_PROJECTION_ID_PREFIX}reading"),
1113 ] {
1114 assert!(projection(&invalid).validate().is_err(), "{invalid:?}");
1115 assert!(
1116 ControlCommand::RegisterProjection(projection(&invalid))
1117 .validate()
1118 .is_err()
1119 );
1120 assert!(
1121 ControlCommand::DropProjection(invalid.clone())
1122 .validate()
1123 .is_err()
1124 );
1125 assert!(
1126 ControlCommand::DropGraph(invalid.clone())
1127 .validate()
1128 .is_err()
1129 );
1130 let allowed = ProjectionBinding::builder()
1131 .source("fleet", "readings")
1132 .allow(invalid.as_str())
1133 .build();
1134 assert!(ControlCommand::ApplyBinding(allowed).validate().is_err());
1135 let defaulted = ProjectionBinding::builder()
1136 .source("fleet", "readings")
1137 .default_projection(invalid.as_str())
1138 .build();
1139 assert!(defaulted.validate().is_err());
1140 assert!(
1141 ControlCommand::RemoveBinding {
1142 source: SourceSelector::new("fleet", "readings"),
1143 projection_ref: Some(invalid.clone()),
1144 }
1145 .validate()
1146 .is_err()
1147 );
1148 assert!(
1149 ProjectionRef {
1150 id: ProjectionId::new(invalid.clone()),
1151 version: 1,
1152 }
1153 .validate()
1154 .is_err()
1155 );
1156 assert!(invalid.parse::<ProjectionId>().is_err());
1157 }
1158 }
1159
1160 #[test]
1161 fn given_a_projection_name_over_the_cap_when_validated_then_should_refuse_it_and_accept_none() {
1162 let named = |name: &str| Projection::builder("reading.v1").name(name).build();
1163 named("").validate().expect("a projection name is optional");
1164 named(&"n".repeat(MAX_PROJECTION_NAME_BYTES))
1165 .validate()
1166 .expect("a projection name at the cap is valid");
1167 assert!(
1168 named(&"n".repeat(MAX_PROJECTION_NAME_BYTES + 1))
1169 .validate()
1170 .is_err()
1171 );
1172 assert!(named("read\ting").validate().is_err());
1173 }
1174
1175 #[test]
1176 fn given_an_invalid_index_field_when_a_projection_is_validated_then_should_refuse_it() {
1177 let with_field = |name: &str, pointer: &str| {
1178 Projection::builder("reading.v1")
1179 .field_at(name, pointer)
1180 .build()
1181 };
1182 with_field("host", "/host")
1183 .validate()
1184 .expect("a plain index field is valid");
1185 let long = "f".repeat(MAX_INDEX_FIELD_BYTES + 1);
1186 assert!(with_field(&long, "/host").validate().is_err());
1187 assert!(with_field("host", &long).validate().is_err());
1188 assert!(with_field("", "/host").validate().is_err());
1189 assert!(with_field("host", "").validate().is_err());
1190 assert!(with_field("ho\u{7}st", "/host").validate().is_err());
1191 assert!(
1192 Projection::builder("reading.v1")
1193 .vector_field(long)
1194 .build()
1195 .validate()
1196 .is_err()
1197 );
1198 assert!(
1199 Projection::builder("reading.v1")
1200 .vector_field("")
1201 .build()
1202 .validate()
1203 .is_err()
1204 );
1205 }
1206
1207 #[test]
1208 fn given_an_invalid_schema_name_when_registered_then_should_refuse_it() {
1209 let schema = |name: Option<String>| SchemaDef {
1210 id: 7,
1211 source: SchemaSource::JsonSchema {
1212 schema: "{}".to_owned(),
1213 },
1214 name,
1215 version: None,
1216 };
1217 schema(None)
1218 .validate()
1219 .expect("a schema without a name is valid");
1220 ControlCommand::RegisterSchema(schema(Some("s".repeat(MAX_SCHEMA_NAME_BYTES))))
1221 .validate()
1222 .expect("a schema name at the cap is valid");
1223 for invalid in [
1224 String::new(),
1225 "s".repeat(MAX_SCHEMA_NAME_BYTES + 1),
1226 "read\ning".to_owned(),
1227 ] {
1228 assert!(schema(Some(invalid.clone())).validate().is_err());
1229 assert!(
1230 ControlCommand::RegisterSchema(schema(Some(invalid)))
1231 .validate()
1232 .is_err()
1233 );
1234 }
1235 }
1236
1237 #[test]
1238 fn given_an_invalid_graph_label_when_a_graph_projection_is_validated_then_should_refuse_it() {
1239 let graph = |label: &str, edge_type: &str| {
1240 Projection::builder("people.v1")
1241 .graph(EntitySchema {
1242 nodes: vec![NodeExtract {
1243 label: label.to_owned(),
1244 value_pointer: "/name".to_owned(),
1245 embedding_pointer: None,
1246 }],
1247 edges: vec![EdgeExtract {
1248 edge_type: edge_type.to_owned(),
1249 from_pointer: "/name".to_owned(),
1250 to_pointer: "/company".to_owned(),
1251 valid_from_pointer: None,
1252 valid_to_pointer: None,
1253 }],
1254 })
1255 .build()
1256 };
1257 let at_cap = "l".repeat(MAX_GRAPH_LABEL_BYTES);
1258 ControlCommand::RegisterGraph(graph(&at_cap, &at_cap))
1259 .validate()
1260 .expect("a label and an edge type at the cap are valid");
1261 let long = "l".repeat(MAX_GRAPH_LABEL_BYTES + 1);
1262 assert!(graph(&long, "works_at").validate().is_err());
1263 assert!(graph("Person", &long).validate().is_err());
1264 assert!(graph("", "works_at").validate().is_err());
1265 assert!(graph("Person", "").validate().is_err());
1266 assert!(
1267 ControlCommand::RegisterGraph(graph("Per\nson", "works_at"))
1268 .validate()
1269 .is_err()
1270 );
1271 }
1272
1273 #[test]
1274 fn given_a_binding_that_skips_rejected_records_when_encoded_then_should_carry_the_opt_in_only_when_set()
1275 {
1276 let plain = ProjectionBinding::builder().source("s", "t").build();
1277 assert!(!plain.skip_rejected);
1278 let plain_json = serde_json::to_value(&plain).unwrap();
1279 assert!(plain_json.get("skip_rejected").is_none());
1280 let opted = ProjectionBinding::builder()
1281 .source("s", "t")
1282 .skip_rejected()
1283 .build();
1284 let json = serde_json::to_value(&opted).unwrap();
1285 assert_eq!(json["skip_rejected"], true);
1286 let decoded: ProjectionBinding = serde_json::from_value(json).unwrap();
1287 assert_eq!(decoded, opted);
1288 }
1289
1290 #[test]
1291 fn given_a_record_skip_when_matched_then_should_cover_only_its_exact_record() {
1292 let skip = RecordSkip {
1293 source: SourceSelector::new("s", "t"),
1294 partition_id: 2,
1295 topic_created_at_micros: 7,
1296 offset: 40,
1297 target: "rows".to_owned(),
1298 reason: "rejected".to_owned(),
1299 };
1300 assert!(skip.covers("s", "t", 2, 7, 40, "rows"));
1301 assert!(
1302 !skip.covers("s", "t", 2, 8, 40, "rows"),
1303 "another incarnation"
1304 );
1305 assert!(!skip.covers("s", "t", 2, 7, 41, "rows"));
1306 assert!(!skip.covers("s", "t", 3, 7, 40, "rows"));
1307 assert!(!skip.covers("s", "t", 2, 7, 40, "other"));
1308 assert!(!skip.covers("s", "u", 2, 7, 40, "rows"));
1309 }
1310
1311 #[test]
1312 fn given_an_unknown_schema_source_kind_when_decoded_then_should_degrade_not_fail() {
1313 let source: SchemaSource =
1317 serde_json::from_str(r#"{"kind":"parquet_descriptor","blob":[1,2,3]}"#)
1318 .expect("an unknown schema kind decodes to Unknown, not an error");
1319 assert_eq!(source, SchemaSource::Unknown);
1320 let def = SchemaDef {
1322 id: 1,
1323 source,
1324 name: None,
1325 version: None,
1326 };
1327 assert_eq!(def.content_type(), ContentType::Any);
1328 }
1329
1330 #[test]
1331 fn given_an_unknown_retention_kind_when_decoded_then_should_degrade_not_fail() {
1332 let policy: RetentionPolicy = serde_json::from_str(r#"{"kind":"keep_for_eras","eras":3}"#)
1333 .expect("an unknown retention kind decodes to Unknown, not an error");
1334 assert_eq!(policy, RetentionPolicy::Unknown);
1335 }
1336
1337 #[test]
1338 fn given_an_explicit_operational_index_when_built_then_should_keep_one_index() {
1339 let binding = ProjectionBinding::builder()
1340 .source("fleet", "readings")
1341 .allow("reading.v1")
1342 .index("readings_rows")
1343 .build();
1344 assert_eq!(binding.index, "readings_rows");
1345 assert_eq!(binding.backend, None);
1346 }
1347
1348 #[test]
1349 fn given_an_operational_backend_when_built_then_should_keep_exact_generation() {
1350 let backend = BackendBinding {
1351 resource_id: crate::destination::BackendResourceId::from_u128(7),
1352 generation: 3,
1353 };
1354 let binding = ProjectionBinding::builder()
1355 .source("fleet", "readings")
1356 .backend(backend.clone())
1357 .build();
1358 assert_eq!(binding.backend, Some(backend));
1359 }
1360
1361 #[test]
1362 fn given_no_operational_index_when_built_then_should_use_the_topic_name() {
1363 let binding = ProjectionBinding::builder()
1364 .source("fleet", "readings")
1365 .allow("reading.v1")
1366 .build();
1367 assert_eq!(binding.index, "readings");
1368 }
1369
1370 #[test]
1371 fn given_no_retention_when_built_then_should_default_to_none() {
1372 let binding = ProjectionBinding::builder()
1373 .source("fleet", "readings")
1374 .index("readings_rows")
1375 .build();
1376 assert_eq!(binding.retention, None);
1377 }
1378
1379 #[test]
1380 fn given_no_source_when_try_built_then_should_error() {
1381 assert!(ProjectionBinding::builder().try_build().is_err());
1382 }
1383
1384 #[test]
1385 fn given_a_projection_built_with_the_default_when_inspected_then_should_inline_payload() {
1386 let projection = Projection::builder("api.call.v1")
1387 .name("api.call")
1388 .version(1)
1389 .fields(["endpoint", "status"])
1390 .build();
1391 assert!(
1392 projection.inline_payload_default,
1393 "Projection::builder default should inline payload"
1394 );
1395 assert!(
1396 projection.extraction.inline_payload,
1397 "Projection::builder default should mark extraction.inline_payload too"
1398 );
1399 }
1400
1401 #[test]
1402 fn given_a_projection_with_index_only_when_inspected_then_should_skip_inlining() {
1403 let projection = Projection::builder("api.call.v1")
1404 .name("api.call")
1405 .version(1)
1406 .fields(["endpoint"])
1407 .index_only()
1408 .build();
1409 assert!(
1410 !projection.inline_payload_default,
1411 "index_only must clear inline_payload_default"
1412 );
1413 assert!(
1414 !projection.extraction.inline_payload,
1415 "index_only must clear extraction.inline_payload"
1416 );
1417 }
1418
1419 #[test]
1420 fn given_an_empty_projection_id_when_parsed_then_should_error() {
1421 assert!("".parse::<ProjectionId>().is_err());
1422 assert_eq!(
1423 "reading.v1"
1424 .parse::<ProjectionId>()
1425 .expect("non-empty id parses")
1426 .as_str(),
1427 "reading.v1"
1428 );
1429 }
1430}
1431
1432#[cfg(all(test, feature = "cbor"))]
1433mod wire_tests {
1434 use super::*;
1435 use crate::codes::CONTROL_OP_VERSION;
1436 use crate::content::ContentType;
1437 use crate::framing::{decode_named, encode_named};
1438
1439 #[test]
1440 fn given_an_apply_binding_when_round_tripped_then_should_preserve_index_and_version() {
1441 let binding = ProjectionBinding::builder()
1442 .source("fleet", "readings")
1443 .allow("reading.v1")
1444 .default_projection("reading.v1")
1445 .index("readings_rows")
1446 .build();
1447 let envelope = ControlEnvelope {
1448 v: CONTROL_OP_VERSION,
1449 timestamp_micros: 42,
1450 command: ControlCommand::ApplyBinding(binding),
1451 stream: None,
1452 stamp: None,
1453 };
1454 let bytes = encode_named(&envelope).expect("envelope serializes");
1455 let back: ControlEnvelope = decode_named(&bytes).expect("envelope deserializes");
1456 assert_eq!(back.v, CONTROL_OP_VERSION);
1457 let ControlCommand::ApplyBinding(decoded) = back.command else {
1458 panic!("expected ApplyBinding");
1459 };
1460 assert_eq!(decoded.index, "readings_rows");
1461 assert_eq!(decoded.backend, None);
1462 assert_eq!(decoded.retention, None);
1465 }
1466
1467 #[test]
1468 fn given_a_register_schema_when_round_tripped_then_should_preserve_source() {
1469 let envelope = ControlEnvelope {
1470 v: CONTROL_OP_VERSION,
1471 timestamp_micros: 7,
1472 command: ControlCommand::RegisterSchema(SchemaDef {
1473 id: 11,
1474 source: SchemaSource::Avro {
1475 schema: r#"{"type":"record","name":"Reading","fields":[]}"#.to_owned(),
1476 },
1477 name: None,
1478 version: None,
1479 }),
1480 stream: None,
1481 stamp: None,
1482 };
1483 let bytes = encode_named(&envelope).expect("envelope serializes");
1484 let back: ControlEnvelope = decode_named(&bytes).expect("envelope deserializes");
1485 assert_eq!(back.stream, None);
1486 let ControlCommand::RegisterSchema(decoded) = back.command else {
1487 panic!("expected RegisterSchema");
1488 };
1489 assert_eq!(decoded.id, 11);
1490 assert_eq!(decoded.content_type(), ContentType::Avro);
1491 }
1492
1493 #[test]
1494 fn given_a_stream_scoped_drop_schema_when_round_tripped_then_should_preserve_the_stream() {
1495 let envelope = ControlEnvelope {
1496 v: CONTROL_OP_VERSION,
1497 timestamp_micros: 7,
1498 command: ControlCommand::DropSchema(11),
1499 stream: Some("acme".to_owned()),
1500 stamp: None,
1501 };
1502 let back: ControlEnvelope =
1503 decode_named(&encode_named(&envelope).expect("envelope serializes"))
1504 .expect("envelope deserializes");
1505 assert_eq!(back.stream.as_deref(), Some("acme"));
1506 assert!(matches!(back.command, ControlCommand::DropSchema(11)));
1507 let unscoped = ControlEnvelope {
1508 stream: None,
1509 ..envelope
1510 };
1511 let json = serde_json::to_string(&unscoped).expect("envelope serializes");
1512 assert!(
1513 !json.contains("stream"),
1514 "an unset stream is omitted: {json}"
1515 );
1516 }
1517
1518 #[test]
1519 fn given_a_stamped_envelope_when_round_tripped_then_should_keep_the_identity_and_omit_an_unset_stamp()
1520 {
1521 let envelope = ControlEnvelope {
1522 v: CONTROL_OP_VERSION,
1523 timestamp_micros: 7,
1524 command: ControlCommand::DropSchema(11),
1525 stream: Some("acme".to_owned()),
1526 stamp: Some(ControlStamp {
1527 deployment_id: 42,
1528 actor_user_id: 3,
1529 operation_id: u128::MAX - 1,
1530 stream_generation: Some(ControlStreamGeneration {
1531 stream_id: 9,
1532 created_at_micros: 1_700_000_000_000_000,
1533 }),
1534 key_id: u64::MAX,
1535 }),
1536 };
1537 let back: ControlEnvelope =
1538 decode_named(&encode_named(&envelope).expect("envelope serializes"))
1539 .expect("envelope deserializes");
1540 assert_eq!(back.stamp, envelope.stamp);
1541 let unstamped = ControlEnvelope {
1542 stamp: None,
1543 ..envelope
1544 };
1545 let json = serde_json::to_string(&unstamped).expect("envelope serializes");
1546 assert!(
1547 !json.contains("\"stamp\""),
1548 "an unset stamp is omitted: {json}"
1549 );
1550 }
1551
1552 #[test]
1553 fn given_a_protobuf_schema_source_when_round_tripped_then_should_preserve_bytes() {
1554 let source = SchemaSource::Protobuf {
1555 descriptor_set: vec![10, 20, 30],
1556 message_type: "fleet.Reading".to_owned(),
1557 };
1558 let bytes = encode_named(&source).expect("serializes");
1559 let back: SchemaSource = decode_named(&bytes).expect("deserializes");
1560 assert_eq!(back, source);
1561 }
1562
1563 #[test]
1564 fn given_retention_set_when_round_tripped_then_should_preserve_policy() {
1565 for policy in [
1566 RetentionPolicy::MirrorLog,
1570 RetentionPolicy::Keep,
1571 RetentionPolicy::KeepUntilSourceDeleted,
1572 RetentionPolicy::TimeToLive {
1573 ttl_micros: 3_600_000_000,
1574 },
1575 RetentionPolicy::MaxRows { rows: 10_000 },
1576 ] {
1577 let binding = ProjectionBinding::builder()
1578 .source("fleet", "telemetry")
1579 .allow("telemetry.v1")
1580 .index("telemetry_rows")
1581 .retention(policy)
1582 .build();
1583 assert_eq!(binding.retention, Some(policy));
1584 let bytes = encode_named(&binding).expect("binding serializes");
1585 let back: ProjectionBinding = decode_named(&bytes).expect("binding deserializes");
1586 assert_eq!(back.retention, Some(policy));
1587 }
1588 }
1589}
1590
1591#[cfg(all(test, feature = "codecs"))]
1592mod schema_tests {
1593 use super::*;
1594 use crate::framing::{decode_named, encode_named};
1595
1596 #[test]
1597 fn given_a_schema_def_with_version_when_round_tripped_then_should_preserve_it() {
1598 let def = SchemaDef {
1599 id: 7,
1600 source: SchemaSource::Avro {
1601 schema: "{}".to_owned(),
1602 },
1603 name: Some("readings".to_owned()),
1604 version: Some(2),
1605 };
1606 let bytes = encode_named(&def).expect("serializes");
1607 let back: SchemaDef = decode_named(&bytes).expect("deserializes");
1608 assert_eq!(back.version, Some(2));
1609 let unversioned = SchemaDef {
1612 name: None,
1613 version: None,
1614 ..def
1615 };
1616 let json = serde_json::to_string(&unversioned).expect("serializes");
1617 assert!(
1618 !json.contains("version"),
1619 "unset version must be omitted: {json}"
1620 );
1621 }
1622}