1use crate::content::ContentType;
2use crate::destination::BackendBinding;
3use crate::error::InvalidError;
4use crate::limits::{
5 MAX_FILTER_SCHEMA_BYTES, MAX_FILTER_SCHEMA_DEPTH, MAX_GRAPH_LABEL_BYTES, MAX_INDEX_FIELD_BYTES,
6 MAX_PROJECTION_ID_BYTES, MAX_PROJECTION_NAME_BYTES, MAX_SCHEMA_NAME_BYTES,
7};
8use crate::validate::{Validate, json_depth_within, validate_bounded_text};
9use serde::{Deserialize, Serialize};
10use std::str::FromStr;
11
12pub const RESERVED_PROJECTION_ID_PREFIX: &str = "index:";
15
16pub const JSON_SCHEMA_DIALECT: &str = "https://json-schema.org/draft/2020-12/schema";
18
19#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
31pub struct IndexField {
32 pub name: String,
33 pub pointer: String,
34 #[serde(default, skip_serializing_if = "Option::is_none")]
38 pub field_type: Option<FieldType>,
39}
40
41impl IndexField {
42 pub fn new(name: impl Into<String>, pointer: impl Into<String>) -> Self {
44 Self {
45 name: name.into(),
46 pointer: pointer.into(),
47 field_type: None,
48 }
49 }
50
51 pub fn typed(
53 name: impl Into<String>,
54 pointer: impl Into<String>,
55 field_type: FieldType,
56 ) -> Self {
57 Self {
58 name: name.into(),
59 pointer: pointer.into(),
60 field_type: Some(field_type),
61 }
62 }
63}
64
65#[derive(
68 Clone,
69 Copy,
70 Debug,
71 PartialEq,
72 Eq,
73 Serialize,
74 Deserialize,
75 strum::Display,
76 strum::EnumString,
77 strum::VariantArray,
78)]
79#[serde(rename_all = "snake_case")]
80#[strum(serialize_all = "snake_case")]
81#[non_exhaustive]
82pub enum FieldType {
83 Text,
84 Int,
85 Float,
86 Bool,
87}
88
89#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
91pub struct IndexSchema {
92 pub fields: Vec<IndexField>,
93 #[serde(default, skip_serializing_if = "Option::is_none")]
97 pub vector_field: Option<String>,
98 #[serde(default)]
103 pub inline_payload: bool,
104}
105
106impl IndexSchema {
107 pub fn builder() -> IndexSchemaBuilder {
109 IndexSchemaBuilder::default()
110 }
111}
112
113#[derive(Default)]
115pub struct IndexSchemaBuilder {
116 schema: IndexSchema,
117}
118
119impl IndexSchemaBuilder {
120 pub fn field(mut self, name: impl Into<String>) -> Self {
126 let name = name.into();
127 let pointer = format!("/{name}");
128 self.schema.fields.push(IndexField::new(name, pointer));
129 self
130 }
131
132 pub fn field_at(mut self, name: impl Into<String>, pointer: impl Into<String>) -> Self {
138 self.schema.fields.push(IndexField::new(name, pointer));
139 self
140 }
141
142 pub fn vector_field(mut self, pointer: impl Into<String>) -> Self {
145 self.schema.vector_field = Some(pointer.into());
146 self
147 }
148
149 pub fn inline_payload(mut self) -> Self {
152 self.schema.inline_payload = true;
153 self
154 }
155
156 pub fn build(self) -> IndexSchema {
158 self.schema
159 }
160}
161
162#[derive(Clone, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
168#[serde(transparent)]
169pub struct ProjectionId(String);
170
171impl ProjectionId {
172 pub fn new(value: impl Into<String>) -> Self {
174 Self(value.into())
175 }
176
177 pub fn as_str(&self) -> &str {
179 &self.0
180 }
181}
182
183impl std::fmt::Display for ProjectionId {
184 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
185 f.write_str(&self.0)
186 }
187}
188
189impl AsRef<str> for ProjectionId {
190 fn as_ref(&self) -> &str {
191 &self.0
192 }
193}
194
195impl std::borrow::Borrow<str> for ProjectionId {
196 fn borrow(&self) -> &str {
197 &self.0
198 }
199}
200
201impl FromStr for ProjectionId {
202 type Err = InvalidError;
203
204 fn from_str(s: &str) -> Result<Self, Self::Err> {
205 validate_projection_id(s)?;
206 Ok(Self(s.to_owned()))
207 }
208}
209
210impl From<&str> for ProjectionId {
211 fn from(value: &str) -> Self {
212 Self(value.to_owned())
213 }
214}
215
216impl From<String> for ProjectionId {
217 fn from(value: String) -> Self {
218 Self(value)
219 }
220}
221
222#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
223#[serde(tag = "kind", rename_all = "snake_case")]
224pub enum ProjectionHistory {
225 #[default]
226 Append,
227 Keyed {
228 key: ProjectionKey,
229 #[serde(default, skip_serializing_if = "Option::is_none")]
231 delete_header: Option<String>,
232 },
233}
234
235impl ProjectionHistory {
236 pub fn is_append(&self) -> bool {
237 matches!(self, Self::Append)
238 }
239}
240
241#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
242#[serde(tag = "kind", rename_all = "snake_case")]
243pub enum ProjectionKey {
244 Header { name: String },
245 Pointer { pointer: String },
246}
247
248#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
253#[serde(from = "u8", into = "u8")]
254pub enum ProjectionKind {
255 #[default]
257 Row,
258 Graph,
260 Record,
263 Unrecognized(u8),
265}
266
267impl ProjectionKind {
268 pub const fn code(self) -> u8 {
270 match self {
271 ProjectionKind::Row => 0,
272 ProjectionKind::Graph => 1,
273 ProjectionKind::Record => 2,
274 ProjectionKind::Unrecognized(code) => code,
275 }
276 }
277
278 pub const fn from_code(code: u8) -> Self {
281 match code {
282 0 => ProjectionKind::Row,
283 1 => ProjectionKind::Graph,
284 2 => ProjectionKind::Record,
285 other => ProjectionKind::Unrecognized(other),
286 }
287 }
288
289 pub const fn is_row(&self) -> bool {
291 matches!(self, ProjectionKind::Row)
292 }
293}
294
295impl From<u8> for ProjectionKind {
296 fn from(code: u8) -> Self {
297 Self::from_code(code)
298 }
299}
300
301impl From<ProjectionKind> for u8 {
302 fn from(kind: ProjectionKind) -> u8 {
303 kind.code()
304 }
305}
306
307#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
312pub struct EntitySchema {
313 pub nodes: Vec<NodeExtract>,
314 #[serde(default, skip_serializing_if = "Vec::is_empty")]
315 pub edges: Vec<EdgeExtract>,
316}
317
318#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
321pub struct NodeExtract {
322 pub label: String,
323 pub value_pointer: String,
324 #[serde(default, skip_serializing_if = "Option::is_none")]
325 pub embedding_pointer: Option<String>,
326}
327
328#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
331pub struct EdgeExtract {
332 pub edge_type: String,
333 pub from_pointer: String,
334 pub to_pointer: String,
335 #[serde(default, skip_serializing_if = "Option::is_none")]
338 pub valid_from_pointer: Option<String>,
339 #[serde(default, skip_serializing_if = "Option::is_none")]
342 pub valid_to_pointer: Option<String>,
343}
344
345#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
366pub struct Projection {
367 pub id: ProjectionId,
369 pub name: String,
372 pub version: u32,
375 #[serde(default, skip_serializing_if = "ProjectionKind::is_row")]
377 pub kind: ProjectionKind,
378 #[serde(default, skip_serializing_if = "ProjectionHistory::is_append")]
379 pub history: ProjectionHistory,
380 pub content_type: ContentType,
383 pub extraction: IndexSchema,
385 #[serde(default, skip_serializing_if = "Option::is_none")]
388 pub entity_schema: Option<EntitySchema>,
389 #[serde(default)]
392 pub inline_payload_default: bool,
393}
394
395impl Projection {
396 pub fn builder(id: impl Into<ProjectionId>) -> ProjectionBuilder {
398 ProjectionBuilder {
399 projection: Self {
400 id: id.into(),
401 name: String::new(),
402 version: 1,
403 kind: ProjectionKind::Row,
404 history: ProjectionHistory::Append,
405 content_type: ContentType::Any,
406 extraction: IndexSchema {
411 fields: Vec::new(),
412 vector_field: None,
413 inline_payload: true,
414 },
415 entity_schema: None,
416 inline_payload_default: true,
417 },
418 }
419 }
420}
421
422pub struct ProjectionBuilder {
424 projection: Projection,
425}
426
427impl ProjectionBuilder {
428 pub fn keyed(mut self, key: ProjectionKey, delete_header: Option<&str>) -> Self {
430 self.projection.history = ProjectionHistory::Keyed {
431 key,
432 delete_header: delete_header.map(str::to_owned),
433 };
434 self
435 }
436
437 pub fn name(mut self, value: impl Into<String>) -> Self {
439 self.projection.name = value.into();
440 self
441 }
442
443 pub fn version(mut self, value: u32) -> Self {
445 self.projection.version = value;
446 self
447 }
448
449 pub fn content_type(mut self, value: ContentType) -> Self {
451 self.projection.content_type = value;
452 self
453 }
454
455 pub fn extraction(mut self, value: IndexSchema) -> Self {
457 self.projection.extraction = value;
458 self
459 }
460
461 pub fn record(mut self) -> Self {
464 self.projection.kind = ProjectionKind::Record;
465 self.projection.extraction = IndexSchema::default();
466 self.projection.entity_schema = None;
467 self.projection.inline_payload_default = false;
468 self
469 }
470
471 pub fn graph(mut self, schema: EntitySchema) -> Self {
473 self.projection.kind = ProjectionKind::Graph;
474 self.projection.entity_schema = Some(schema);
475 self
476 }
477
478 pub fn field(mut self, name: impl Into<String>) -> Self {
480 let name = name.into();
481 let pointer = format!("/{name}");
482 self.projection
483 .extraction
484 .fields
485 .push(IndexField::new(name, pointer));
486 self
487 }
488
489 pub fn fields<I, S>(mut self, names: I) -> Self
493 where
494 I: IntoIterator<Item = S>,
495 S: Into<String>,
496 {
497 for name in names {
498 let name = name.into();
499 let pointer = format!("/{name}");
500 self.projection
501 .extraction
502 .fields
503 .push(IndexField::new(name, pointer));
504 }
505 self
506 }
507
508 pub fn field_at(mut self, name: impl Into<String>, pointer: impl Into<String>) -> Self {
510 self.projection
511 .extraction
512 .fields
513 .push(IndexField::new(name, pointer));
514 self
515 }
516
517 pub fn field_typed(mut self, name: impl Into<String>, field_type: FieldType) -> Self {
521 let name = name.into();
522 let pointer = format!("/{name}");
523 self.projection
524 .extraction
525 .fields
526 .push(IndexField::typed(name, pointer, field_type));
527 self
528 }
529
530 pub fn field_at_typed(
532 mut self,
533 name: impl Into<String>,
534 pointer: impl Into<String>,
535 field_type: FieldType,
536 ) -> Self {
537 self.projection
538 .extraction
539 .fields
540 .push(IndexField::typed(name, pointer, field_type));
541 self
542 }
543
544 pub fn vector_field(mut self, pointer: impl Into<String>) -> Self {
546 self.projection.extraction.vector_field = Some(pointer.into());
547 self
548 }
549
550 pub fn inline_payload(mut self) -> Self {
555 self.projection.inline_payload_default = true;
556 self.projection.extraction.inline_payload = true;
557 self
558 }
559
560 pub fn index_only(mut self) -> Self {
575 self.projection.inline_payload_default = false;
576 self.projection.extraction.inline_payload = false;
577 self
578 }
579
580 pub fn build(self) -> Projection {
582 self.projection
583 }
584}
585
586#[derive(Clone, Copy, Debug, PartialEq, Eq, Default, Serialize, Deserialize)]
594#[serde(tag = "kind", rename_all = "snake_case")]
595#[non_exhaustive]
596pub enum RetentionPolicy {
597 #[default]
601 MirrorLog,
602 Keep,
604 KeepUntilSourceDeleted,
608 TimeToLive { ttl_micros: u64 },
611 MaxRows { rows: u64 },
613 #[serde(other)]
619 Unknown,
620}
621
622#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
628pub struct ProjectionBinding {
629 pub source: SourceSelector,
630 #[serde(default)]
634 pub allowed_projections: Vec<ProjectionId>,
635 #[serde(default)]
638 pub default_projection: Option<ProjectionId>,
639 #[serde(default, skip_serializing_if = "Option::is_none")]
642 pub backend: Option<BackendBinding>,
643 pub index: String,
645 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
650 pub notify: bool,
651 #[serde(default, skip_serializing_if = "Option::is_none")]
656 pub retention: Option<RetentionPolicy>,
657 #[serde(default, skip_serializing_if = "InvalidRecordPolicy::is_block")]
665 pub invalid_records: InvalidRecordPolicy,
666 #[serde(default, skip_serializing_if = "OversizePolicy::is_block")]
672 pub oversize: OversizePolicy,
673}
674
675#[derive(
677 Clone,
678 Copy,
679 Debug,
680 Default,
681 PartialEq,
682 Eq,
683 Serialize,
684 Deserialize,
685 strum::Display,
686 strum::EnumString,
687)]
688#[serde(rename_all = "snake_case")]
689#[strum(serialize_all = "snake_case")]
690#[non_exhaustive]
691pub enum InvalidRecordPolicy {
692 #[default]
694 Block,
695 DeadLetter,
697 #[serde(other)]
699 #[strum(disabled)]
700 Unknown,
701}
702
703impl InvalidRecordPolicy {
704 pub fn is_block(&self) -> bool {
706 matches!(self, Self::Block)
707 }
708}
709
710#[derive(
712 Clone,
713 Copy,
714 Debug,
715 Default,
716 PartialEq,
717 Eq,
718 Serialize,
719 Deserialize,
720 strum::Display,
721 strum::EnumString,
722)]
723#[serde(rename_all = "snake_case")]
724#[strum(serialize_all = "snake_case")]
725#[non_exhaustive]
726pub enum OversizePolicy {
727 #[default]
729 Block,
730 Reference,
732 #[serde(other)]
734 #[strum(disabled)]
735 Unknown,
736}
737
738impl OversizePolicy {
739 pub fn is_block(&self) -> bool {
741 matches!(self, Self::Block)
742 }
743}
744
745impl ProjectionBinding {
746 pub fn builder() -> ProjectionBindingBuilder {
748 ProjectionBindingBuilder::default()
749 }
750}
751
752#[derive(Default)]
754pub struct ProjectionBindingBuilder {
755 source: Option<SourceSelector>,
756 allowed: Vec<ProjectionId>,
757 default_projection: Option<ProjectionId>,
758 backend: Option<BackendBinding>,
759 index: Option<String>,
760 notify: bool,
761 retention: Option<RetentionPolicy>,
762 invalid_records: InvalidRecordPolicy,
763 oversize: OversizePolicy,
764}
765
766impl ProjectionBindingBuilder {
767 pub fn source(mut self, stream: impl Into<String>, topic: impl Into<String>) -> Self {
770 self.source = Some(SourceSelector::new(stream, topic));
771 self
772 }
773
774 pub fn selector(mut self, source: SourceSelector) -> Self {
776 self.source = Some(source);
777 self
778 }
779
780 pub fn allow(mut self, projection: impl Into<ProjectionId>) -> Self {
782 self.allowed.push(projection.into());
783 self
784 }
785
786 pub fn default_projection(mut self, projection: impl Into<ProjectionId>) -> Self {
788 self.default_projection = Some(projection.into());
789 self
790 }
791
792 pub fn backend(mut self, backend: BackendBinding) -> Self {
795 self.backend = Some(backend);
796 self
797 }
798
799 pub fn retention(mut self, retention: RetentionPolicy) -> Self {
813 self.retention = Some(retention);
814 self
815 }
816
817 pub fn index(mut self, index: impl Into<String>) -> Self {
819 self.index = Some(index.into());
820 self
821 }
822
823 #[must_use]
826 pub fn notify(mut self) -> Self {
827 self.notify = true;
828 self
829 }
830
831 #[must_use]
834 pub fn invalid_records(mut self, policy: InvalidRecordPolicy) -> Self {
835 self.invalid_records = policy;
836 self
837 }
838
839 #[must_use]
842 pub fn oversize(mut self, policy: OversizePolicy) -> Self {
843 self.oversize = policy;
844 self
845 }
846
847 pub fn build(self) -> ProjectionBinding {
851 self.try_build()
852 .expect("ProjectionBinding requires a source - call .source(stream, topic)")
853 }
854
855 pub fn try_build(self) -> Result<ProjectionBinding, InvalidError> {
858 let source = self
859 .source
860 .ok_or_else(|| InvalidError::new("ProjectionBinding requires a source"))?;
861 let index = self.index.unwrap_or_else(|| source.topic.clone());
862 Ok(ProjectionBinding {
863 source,
864 allowed_projections: self.allowed,
865 default_projection: self.default_projection,
866 backend: self.backend,
867 index,
868 notify: self.notify,
869 retention: self.retention,
870 invalid_records: self.invalid_records,
871 oversize: self.oversize,
872 })
873 }
874}
875
876#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
881pub struct SourceSelector {
882 pub stream: String,
883 pub topic: String,
884}
885
886impl SourceSelector {
887 pub fn new(stream: impl Into<String>, topic: impl Into<String>) -> Self {
889 Self {
890 stream: stream.into(),
891 topic: topic.into(),
892 }
893 }
894}
895
896#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
903pub struct SchemaDef {
904 pub id: u32,
905 pub source: SchemaSource,
906 #[serde(default, skip_serializing_if = "Option::is_none")]
910 pub name: Option<String>,
911 #[serde(default, skip_serializing_if = "Option::is_none")]
916 pub version: Option<u32>,
917}
918
919#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
924pub struct SchemaAllocation {
925 pub source: SchemaSource,
926 #[serde(default, skip_serializing_if = "Option::is_none")]
927 pub name: Option<String>,
928 #[serde(default, skip_serializing_if = "Option::is_none")]
929 pub version: Option<u32>,
930}
931
932impl SchemaAllocation {
933 pub fn definition(&self, id: u32) -> SchemaDef {
935 SchemaDef {
936 id,
937 source: self.source.clone(),
938 name: self.name.clone(),
939 version: self.version,
940 }
941 }
942}
943
944impl SchemaDef {
945 pub fn content_type(&self) -> ContentType {
948 match self.source {
949 SchemaSource::Avro { .. } => ContentType::Avro,
950 SchemaSource::Protobuf { .. } => ContentType::Protobuf,
951 SchemaSource::JsonSchema { .. } => ContentType::Json,
952 SchemaSource::Unknown => ContentType::Any,
954 }
955 }
956}
957
958#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
963#[serde(tag = "kind", rename_all = "snake_case")]
964#[non_exhaustive]
965pub enum SchemaSource {
966 Avro { schema: String },
968 Protobuf {
971 #[serde(with = "crate::encoding::bin_bytes")]
972 descriptor_set: Vec<u8>,
973 message_type: String,
974 },
975 JsonSchema { schema: String },
980 #[serde(other)]
986 Unknown,
987}
988
989#[derive(Clone, Debug, Serialize, Deserialize)]
993pub enum ControlCommand {
994 RegisterProjection(Projection),
995 DropProjection(String),
996 ApplyBinding(ProjectionBinding),
997 RemoveBinding {
998 source: SourceSelector,
999 projection_ref: Option<String>,
1000 #[serde(default, skip_serializing_if = "Option::is_none")]
1004 generation: Option<ControlStreamGeneration>,
1005 },
1006 RegisterSchema(SchemaDef),
1008 DropSchema(u32),
1010 AllocateSchema(SchemaAllocation),
1013 RegisterGraph(Projection),
1018 DropGraph(String),
1020 RegisterSessionSource {
1022 stream: String,
1023 topics: SessionTopics,
1024 },
1025 RemoveSessionSource {
1027 stream: String,
1028 },
1029 FilterCatalog(Box<crate::filter::FilterCatalogCommand>),
1032 AcceptGap(GapAcceptance),
1038 SkipRecord(RecordSkip),
1044 SourceRetained(SourceRetention),
1050}
1051
1052#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
1054pub struct SourceRetention {
1055 pub source: SourceSelector,
1056 pub partition_id: u32,
1057 pub topic_created_at_micros: u64,
1059 pub first_offset: u64,
1062}
1063
1064#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
1066pub struct RecordSkip {
1067 pub source: SourceSelector,
1068 pub partition_id: u32,
1069 pub topic_created_at_micros: u64,
1072 pub offset: u64,
1073 pub target: String,
1076 pub reason: String,
1078}
1079
1080impl RecordSkip {
1081 pub fn covers(
1084 &self,
1085 stream: &str,
1086 topic: &str,
1087 partition_id: u32,
1088 generation: u64,
1089 offset: u64,
1090 target: &str,
1091 ) -> bool {
1092 self.source.stream == stream
1093 && self.source.topic == topic
1094 && self.partition_id == partition_id
1095 && self.topic_created_at_micros == generation
1096 && self.offset == offset
1097 && self.target == target
1098 }
1099}
1100
1101#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
1103pub struct GapAcceptance {
1104 pub source: SourceSelector,
1105 pub partition_id: u32,
1106 pub topic_created_at_micros: u64,
1109 pub from_offset: u64,
1111 pub to_offset: u64,
1113}
1114
1115impl GapAcceptance {
1116 pub fn covers(&self, generation: u64, cursor: u64, first_available: u64) -> bool {
1119 self.topic_created_at_micros == generation
1120 && self.from_offset <= cursor
1121 && self.to_offset >= first_available
1122 }
1123}
1124
1125#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
1127pub enum SessionTopics {
1128 All,
1129 Named(Vec<String>),
1130}
1131
1132#[derive(Clone, Debug, Serialize, Deserialize)]
1134pub struct ControlEnvelope {
1135 pub v: u32,
1136 pub timestamp_micros: u64,
1137 pub command: ControlCommand,
1138 #[serde(default, skip_serializing_if = "Option::is_none")]
1144 pub stream: Option<String>,
1145 #[serde(default, skip_serializing_if = "Option::is_none")]
1150 pub stamp: Option<ControlStamp>,
1151}
1152
1153#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
1155pub struct ControlStamp {
1156 pub deployment_id: u64,
1159 pub operation_id: u128,
1162 #[serde(default, skip_serializing_if = "Option::is_none")]
1165 pub stream_generation: Option<ControlStreamGeneration>,
1166 pub key_id: u64,
1168 #[serde(default, skip_serializing_if = "Option::is_none")]
1172 pub caller: Option<crate::forward::Caller>,
1173}
1174
1175#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
1178pub struct ControlStreamGeneration {
1179 pub stream_id: u32,
1180 pub created_at_micros: u64,
1181}
1182
1183impl Validate for ProjectionId {
1184 fn validate(&self) -> Result<(), InvalidError> {
1185 validate_projection_id(&self.0)
1186 }
1187}
1188
1189impl Validate for Projection {
1190 fn validate(&self) -> Result<(), InvalidError> {
1191 self.id.validate()?;
1192 if let ProjectionHistory::Keyed { key, delete_header } = &self.history {
1193 if self.kind == ProjectionKind::Graph {
1194 return Err(InvalidError::new(
1195 "graph projections do not support keyed history",
1196 ));
1197 }
1198 if let Some(delete_header) = delete_header {
1199 validate_bounded_text("deletion header", delete_header, MAX_INDEX_FIELD_BYTES)?;
1200 }
1201 match key {
1202 ProjectionKey::Header { name } => {
1203 validate_bounded_text("key header", name, MAX_INDEX_FIELD_BYTES)?
1204 }
1205 ProjectionKey::Pointer { pointer } => {
1206 if self.kind != ProjectionKind::Row {
1207 return Err(InvalidError::new(
1208 "payload key pointers require a row projection",
1209 ));
1210 }
1211 if !pointer.is_empty() {
1212 validate_bounded_text("key pointer", pointer, MAX_INDEX_FIELD_BYTES)?;
1213 if !pointer.starts_with('/') {
1214 return Err(InvalidError::new("key pointer must be a JSON pointer"));
1215 }
1216 }
1217 let mut bytes = pointer.bytes();
1218 while let Some(byte) = bytes.next() {
1219 if byte == b'~' && !matches!(bytes.next(), Some(b'0' | b'1')) {
1220 return Err(InvalidError::new("key pointer has an invalid escape"));
1221 }
1222 }
1223 }
1224 }
1225 }
1226
1227 if self.kind == ProjectionKind::Record
1228 && (!self.extraction.fields.is_empty()
1229 || self.extraction.vector_field.is_some()
1230 || self.entity_schema.is_some())
1231 {
1232 return Err(InvalidError::new(
1233 "a whole-record projection extracts nothing, so it declares no fields, vector field or entity schema",
1234 ));
1235 }
1236 if !self.name.is_empty() {
1237 validate_bounded_text("projection name", &self.name, MAX_PROJECTION_NAME_BYTES)?;
1238 }
1239 for field in &self.extraction.fields {
1240 validate_bounded_text("index field name", &field.name, MAX_INDEX_FIELD_BYTES)?;
1241 let mut characters = field.name.chars();
1242 let leads = characters
1243 .next()
1244 .is_some_and(|first| first.is_ascii_alphabetic() || first == '_');
1245 if !leads || !characters.all(|rest| rest.is_ascii_alphanumeric() || rest == '_') {
1246 return Err(InvalidError::new(format!(
1247 "index field name `{}` must start with an ASCII letter or `_` and hold only ASCII letters, digits and `_`",
1248 field.name
1249 )));
1250 }
1251 validate_bounded_text("index field pointer", &field.pointer, MAX_INDEX_FIELD_BYTES)?;
1252 }
1253 if let Some(vector_field) = &self.extraction.vector_field {
1254 validate_bounded_text("index vector field", vector_field, MAX_INDEX_FIELD_BYTES)?;
1255 }
1256 if let Some(entity_schema) = &self.entity_schema {
1257 for node in &entity_schema.nodes {
1258 validate_bounded_text("graph node label", &node.label, MAX_GRAPH_LABEL_BYTES)?;
1259 }
1260 for edge in &entity_schema.edges {
1261 validate_bounded_text("graph edge type", &edge.edge_type, MAX_GRAPH_LABEL_BYTES)?;
1262 }
1263 }
1264 Ok(())
1265 }
1266}
1267
1268impl Validate for ProjectionBinding {
1269 fn validate(&self) -> Result<(), InvalidError> {
1270 for projection in &self.allowed_projections {
1271 projection.validate()?;
1272 }
1273 if let Some(projection) = &self.default_projection {
1274 projection.validate()?;
1275 }
1276 if self.invalid_records == InvalidRecordPolicy::Unknown
1277 || self.oversize == OversizePolicy::Unknown
1278 || self.retention == Some(RetentionPolicy::Unknown)
1279 {
1280 return Err(InvalidError::new(
1281 "a binding cannot apply a policy this build does not know",
1282 ));
1283 }
1284 if matches!(
1285 self.retention,
1286 Some(
1287 RetentionPolicy::TimeToLive { ttl_micros: 0 }
1288 | RetentionPolicy::MaxRows { rows: 0 }
1289 )
1290 ) {
1291 return Err(InvalidError::new(
1292 "a time to live retention needs a lifetime above zero, and a max rows retention at least one row",
1293 ));
1294 }
1295 if self.oversize == OversizePolicy::Reference
1296 && self
1297 .retention
1298 .is_some_and(|retention| retention != RetentionPolicy::MirrorLog)
1299 {
1300 return Err(InvalidError::new(
1301 "a binding that keeps body references needs mirror_log retention, so no row outlives its record",
1302 ));
1303 }
1304 Ok(())
1305 }
1306}
1307
1308impl Validate for SchemaDef {
1309 fn validate(&self) -> Result<(), InvalidError> {
1310 if self.id == 0 {
1311 return Err(InvalidError::new("writer schema ids start at 1"));
1312 }
1313 validate_schema_metadata(&self.source, self.name.as_deref())
1314 }
1315}
1316
1317impl Validate for SchemaAllocation {
1318 fn validate(&self) -> Result<(), InvalidError> {
1319 validate_schema_metadata(&self.source, self.name.as_deref())
1320 }
1321}
1322
1323impl Validate for SchemaSource {
1324 fn validate(&self) -> Result<(), InvalidError> {
1325 match self {
1326 Self::Avro { schema } => validate_schema_text("Avro schema", schema),
1327 Self::JsonSchema { schema } => {
1328 validate_schema_text("JSON Schema", schema)?;
1329 let value: serde_json::Value = serde_json::from_str(schema).map_err(|error| {
1330 InvalidError::new(format!("JSON Schema is not valid JSON: {error}"))
1331 })?;
1332 match value.get("$schema") {
1333 None => Ok(()),
1334 Some(serde_json::Value::String(dialect))
1335 if dialect.trim_end_matches('#') == JSON_SCHEMA_DIALECT =>
1336 {
1337 Ok(())
1338 }
1339 Some(other) => Err(InvalidError::new(format!(
1340 "a JSON Schema uses draft 2020-12, not {other}"
1341 ))),
1342 }
1343 }
1344 Self::Protobuf {
1345 descriptor_set,
1346 message_type,
1347 } => {
1348 if descriptor_set.len() > MAX_FILTER_SCHEMA_BYTES {
1349 return Err(InvalidError::new(format!(
1350 "Protobuf descriptor set exceeds {MAX_FILTER_SCHEMA_BYTES} bytes"
1351 )));
1352 }
1353 validate_bounded_text("Protobuf message type", message_type, MAX_SCHEMA_NAME_BYTES)
1354 }
1355 Self::Unknown => Err(InvalidError::new(
1356 "a schema source of an unknown kind cannot be registered",
1357 )),
1358 }
1359 }
1360}
1361
1362impl Validate for ControlCommand {
1363 fn validate(&self) -> Result<(), InvalidError> {
1364 match self {
1365 Self::RegisterProjection(projection) | Self::RegisterGraph(projection) => {
1366 projection.validate()
1367 }
1368 Self::DropProjection(id) | Self::DropGraph(id) => validate_projection_id(id),
1369 Self::ApplyBinding(binding) => binding.validate(),
1370 Self::RemoveBinding { projection_ref, .. } => projection_ref
1371 .as_deref()
1372 .map_or(Ok(()), validate_projection_id),
1373 Self::RegisterSchema(schema) => schema.validate(),
1374 Self::AllocateSchema(allocation) => allocation.validate(),
1375 Self::FilterCatalog(command) => command.mutation.validate(),
1376 Self::DropSchema(_)
1377 | Self::RegisterSessionSource { .. }
1378 | Self::RemoveSessionSource { .. }
1379 | Self::AcceptGap(_)
1380 | Self::SkipRecord(_)
1381 | Self::SourceRetained(_) => Ok(()),
1382 }
1383 }
1384}
1385
1386fn validate_projection_id(id: &str) -> Result<(), InvalidError> {
1387 validate_bounded_text("projection id", id, MAX_PROJECTION_ID_BYTES)?;
1388 if id.starts_with(RESERVED_PROJECTION_ID_PREFIX) {
1389 return Err(InvalidError::new(format!(
1390 "projection id must not start with {RESERVED_PROJECTION_ID_PREFIX:?}"
1391 )));
1392 }
1393 Ok(())
1394}
1395
1396fn validate_schema_metadata(source: &SchemaSource, name: Option<&str>) -> Result<(), InvalidError> {
1397 if let Some(name) = name {
1398 validate_bounded_text("schema name", name, MAX_SCHEMA_NAME_BYTES)?;
1399 }
1400 source.validate()
1401}
1402
1403fn validate_schema_text(label: &str, text: &str) -> Result<(), InvalidError> {
1404 if text.len() > MAX_FILTER_SCHEMA_BYTES {
1405 return Err(InvalidError::new(format!(
1406 "{label} exceeds {MAX_FILTER_SCHEMA_BYTES} bytes"
1407 )));
1408 }
1409 if !json_depth_within(text.as_bytes(), MAX_FILTER_SCHEMA_DEPTH) {
1410 return Err(InvalidError::new(format!(
1411 "{label} nests deeper than {MAX_FILTER_SCHEMA_DEPTH}"
1412 )));
1413 }
1414 Ok(())
1415}
1416
1417#[cfg(test)]
1418mod tests {
1419 use super::*;
1420 use crate::limits::{
1421 MAX_GRAPH_LABEL_BYTES, MAX_INDEX_FIELD_BYTES, MAX_PROJECTION_ID_BYTES,
1422 MAX_PROJECTION_NAME_BYTES, MAX_SCHEMA_NAME_BYTES,
1423 };
1424 use std::borrow::Borrow;
1425
1426 #[test]
1427 fn given_keyed_projection_when_validated_then_should_check_kind_and_pointer() {
1428 for pointer in ["", "/id", "/a~1b/~0"] {
1429 Projection::builder("keyed")
1430 .keyed(
1431 ProjectionKey::Pointer {
1432 pointer: pointer.to_owned(),
1433 },
1434 Some("deleted"),
1435 )
1436 .build()
1437 .validate()
1438 .expect("valid pointer");
1439 }
1440 for pointer in ["id", "/a~", "/a~2"] {
1441 assert!(
1442 Projection::builder("keyed")
1443 .keyed(
1444 ProjectionKey::Pointer {
1445 pointer: pointer.to_owned()
1446 },
1447 Some("deleted")
1448 )
1449 .build()
1450 .validate()
1451 .is_err()
1452 );
1453 }
1454 assert!(
1455 Projection::builder("keyed")
1456 .record()
1457 .keyed(
1458 ProjectionKey::Pointer {
1459 pointer: "/id".to_owned()
1460 },
1461 Some("deleted")
1462 )
1463 .build()
1464 .validate()
1465 .is_err()
1466 );
1467 assert!(
1468 Projection::builder("keyed")
1469 .graph(EntitySchema::default())
1470 .keyed(
1471 ProjectionKey::Header {
1472 name: "id".to_owned()
1473 },
1474 Some("deleted")
1475 )
1476 .build()
1477 .validate()
1478 .is_err()
1479 );
1480 Projection::builder("keyed")
1481 .record()
1482 .keyed(
1483 ProjectionKey::Header {
1484 name: "id".to_owned(),
1485 },
1486 Some("deleted"),
1487 )
1488 .build()
1489 .validate()
1490 .expect("record header key");
1491 }
1492
1493 #[test]
1494 fn given_an_invalid_projection_id_when_any_command_names_it_then_should_refuse_it() {
1495 let projection = |id: &str| {
1496 Projection::builder(id)
1497 .name("reading")
1498 .field("host")
1499 .build()
1500 };
1501 let at_cap = "p".repeat(MAX_PROJECTION_ID_BYTES);
1502 projection(&at_cap)
1503 .validate()
1504 .expect("a projection id at the cap is valid");
1505 ControlCommand::DropProjection(at_cap.clone())
1506 .validate()
1507 .expect("a drop of a projection id at the cap is valid");
1508 ControlCommand::RemoveBinding {
1509 source: SourceSelector::new("fleet", "readings"),
1510 projection_ref: None,
1511 generation: None,
1512 }
1513 .validate()
1514 .expect("a whole-binding removal names no projection");
1515 for invalid in [
1516 String::new(),
1517 "p".repeat(MAX_PROJECTION_ID_BYTES + 1),
1518 "reading\n.v1".to_owned(),
1519 format!("{RESERVED_PROJECTION_ID_PREFIX}reading"),
1520 ] {
1521 assert!(projection(&invalid).validate().is_err(), "{invalid:?}");
1522 assert!(
1523 ControlCommand::RegisterProjection(projection(&invalid))
1524 .validate()
1525 .is_err()
1526 );
1527 assert!(
1528 ControlCommand::DropProjection(invalid.clone())
1529 .validate()
1530 .is_err()
1531 );
1532 assert!(
1533 ControlCommand::DropGraph(invalid.clone())
1534 .validate()
1535 .is_err()
1536 );
1537 let allowed = ProjectionBinding::builder()
1538 .source("fleet", "readings")
1539 .allow(invalid.as_str())
1540 .build();
1541 assert!(ControlCommand::ApplyBinding(allowed).validate().is_err());
1542 let defaulted = ProjectionBinding::builder()
1543 .source("fleet", "readings")
1544 .default_projection(invalid.as_str())
1545 .build();
1546 assert!(defaulted.validate().is_err());
1547 assert!(
1548 ControlCommand::RemoveBinding {
1549 source: SourceSelector::new("fleet", "readings"),
1550 projection_ref: Some(invalid.clone()),
1551 generation: None,
1552 }
1553 .validate()
1554 .is_err()
1555 );
1556 assert!(invalid.parse::<ProjectionId>().is_err());
1557 }
1558 }
1559
1560 #[test]
1561 fn given_a_projection_name_over_the_cap_when_validated_then_should_refuse_it_and_accept_none() {
1562 let named = |name: &str| Projection::builder("reading.v1").name(name).build();
1563 named("").validate().expect("a projection name is optional");
1564 named(&"n".repeat(MAX_PROJECTION_NAME_BYTES))
1565 .validate()
1566 .expect("a projection name at the cap is valid");
1567 assert!(
1568 named(&"n".repeat(MAX_PROJECTION_NAME_BYTES + 1))
1569 .validate()
1570 .is_err()
1571 );
1572 assert!(named("read\ting").validate().is_err());
1573 }
1574
1575 #[test]
1576 fn given_an_invalid_index_field_when_a_projection_is_validated_then_should_refuse_it() {
1577 let with_field = |name: &str, pointer: &str| {
1578 Projection::builder("reading.v1")
1579 .field_at(name, pointer)
1580 .build()
1581 };
1582 with_field("host", "/host")
1583 .validate()
1584 .expect("a plain index field is valid");
1585 let long = "f".repeat(MAX_INDEX_FIELD_BYTES + 1);
1586 assert!(with_field(&long, "/host").validate().is_err());
1587 assert!(with_field("host", &long).validate().is_err());
1588 assert!(with_field("", "/host").validate().is_err());
1589 assert!(with_field("host", "").validate().is_err());
1590 assert!(with_field("ho\u{7}st", "/host").validate().is_err());
1591 assert!(
1592 Projection::builder("reading.v1")
1593 .vector_field(long)
1594 .build()
1595 .validate()
1596 .is_err()
1597 );
1598 assert!(
1599 Projection::builder("reading.v1")
1600 .vector_field("")
1601 .build()
1602 .validate()
1603 .is_err()
1604 );
1605 }
1606
1607 #[test]
1608 fn given_an_invalid_schema_name_when_registered_then_should_refuse_it() {
1609 let schema = |name: Option<String>| SchemaDef {
1610 id: 7,
1611 source: SchemaSource::JsonSchema {
1612 schema: "{}".to_owned(),
1613 },
1614 name,
1615 version: None,
1616 };
1617 schema(None)
1618 .validate()
1619 .expect("a schema without a name is valid");
1620 ControlCommand::RegisterSchema(schema(Some("s".repeat(MAX_SCHEMA_NAME_BYTES))))
1621 .validate()
1622 .expect("a schema name at the cap is valid");
1623 for invalid in [
1624 String::new(),
1625 "s".repeat(MAX_SCHEMA_NAME_BYTES + 1),
1626 "read\ning".to_owned(),
1627 ] {
1628 assert!(schema(Some(invalid.clone())).validate().is_err());
1629 assert!(
1630 ControlCommand::RegisterSchema(schema(Some(invalid)))
1631 .validate()
1632 .is_err()
1633 );
1634 }
1635 }
1636
1637 #[test]
1638 fn given_an_invalid_graph_label_when_a_graph_projection_is_validated_then_should_refuse_it() {
1639 let graph = |label: &str, edge_type: &str| {
1640 Projection::builder("people.v1")
1641 .graph(EntitySchema {
1642 nodes: vec![NodeExtract {
1643 label: label.to_owned(),
1644 value_pointer: "/name".to_owned(),
1645 embedding_pointer: None,
1646 }],
1647 edges: vec![EdgeExtract {
1648 edge_type: edge_type.to_owned(),
1649 from_pointer: "/name".to_owned(),
1650 to_pointer: "/company".to_owned(),
1651 valid_from_pointer: None,
1652 valid_to_pointer: None,
1653 }],
1654 })
1655 .build()
1656 };
1657 let at_cap = "l".repeat(MAX_GRAPH_LABEL_BYTES);
1658 ControlCommand::RegisterGraph(graph(&at_cap, &at_cap))
1659 .validate()
1660 .expect("a label and an edge type at the cap are valid");
1661 let long = "l".repeat(MAX_GRAPH_LABEL_BYTES + 1);
1662 assert!(graph(&long, "works_at").validate().is_err());
1663 assert!(graph("Person", &long).validate().is_err());
1664 assert!(graph("", "works_at").validate().is_err());
1665 assert!(graph("Person", "").validate().is_err());
1666 assert!(
1667 ControlCommand::RegisterGraph(graph("Per\nson", "works_at"))
1668 .validate()
1669 .is_err()
1670 );
1671 }
1672
1673 #[test]
1674 fn given_binding_policies_when_encoded_then_should_carry_them_only_when_not_the_default() {
1675 let plain = ProjectionBinding::builder().source("s", "t").build();
1676 assert_eq!(plain.invalid_records, InvalidRecordPolicy::Block);
1677 assert_eq!(plain.oversize, OversizePolicy::Block);
1678 let plain_json = serde_json::to_value(&plain).expect("encode a default binding");
1679 assert!(plain_json.get("invalid_records").is_none());
1680 assert!(plain_json.get("oversize").is_none());
1681 let opted = ProjectionBinding::builder()
1682 .source("s", "t")
1683 .invalid_records(InvalidRecordPolicy::DeadLetter)
1684 .oversize(OversizePolicy::Reference)
1685 .build();
1686 let json = serde_json::to_value(&opted).expect("encode an opted binding");
1687 assert_eq!(json["invalid_records"], "dead_letter");
1688 assert_eq!(json["oversize"], "reference");
1689 let decoded: ProjectionBinding =
1690 serde_json::from_value(json).expect("decode an opted binding");
1691 assert_eq!(decoded, opted);
1692 }
1693
1694 #[test]
1695 fn given_an_unknown_binding_policy_when_decoded_then_should_degrade_and_refuse_to_apply_it() {
1696 let binding: ProjectionBinding = serde_json::from_value(serde_json::json!({
1697 "source": {"stream": "s", "topic": "t"},
1698 "index": "t",
1699 "invalid_records": "quarantine",
1700 "oversize": "truncate",
1701 }))
1702 .expect("an unknown policy decodes to Unknown");
1703 assert_eq!(binding.invalid_records, InvalidRecordPolicy::Unknown);
1704 assert_eq!(binding.oversize, OversizePolicy::Unknown);
1705 assert!(binding.validate().is_err());
1706 }
1707
1708 #[test]
1709 fn given_body_references_when_retention_outlives_the_log_then_should_refuse_the_binding() {
1710 let binding = |retention: Option<RetentionPolicy>| {
1711 let builder = ProjectionBinding::builder()
1712 .source("s", "t")
1713 .oversize(OversizePolicy::Reference);
1714 match retention {
1715 Some(retention) => builder.retention(retention),
1716 None => builder,
1717 }
1718 .build()
1719 };
1720 binding(None)
1721 .validate()
1722 .expect("the fleet default is checked by the backend");
1723 binding(Some(RetentionPolicy::MirrorLog))
1724 .validate()
1725 .expect("references live as long as their records");
1726 assert!(binding(Some(RetentionPolicy::Keep)).validate().is_err());
1727 assert!(
1728 binding(Some(RetentionPolicy::TimeToLive { ttl_micros: 1 }))
1729 .validate()
1730 .is_err()
1731 );
1732 }
1733
1734 #[test]
1735 fn given_a_record_projection_when_validated_then_should_refuse_extraction_rules() {
1736 let record = Projection::builder("orders").record().build();
1737 assert_eq!(record.kind, ProjectionKind::Record);
1738 assert_eq!(ProjectionKind::from_code(2), ProjectionKind::Record);
1739 assert_eq!(ProjectionKind::Record.code(), 2);
1740 record
1741 .validate()
1742 .expect("a bare record projection is valid");
1743 let with_field = Projection::builder("orders").record().field("id").build();
1744 assert!(with_field.validate().is_err());
1745 let with_vector = Projection::builder("orders")
1746 .record()
1747 .vector_field("/embedding")
1748 .build();
1749 assert!(with_vector.validate().is_err());
1750 }
1751
1752 #[test]
1753 fn given_a_record_skip_when_matched_then_should_cover_only_its_exact_record() {
1754 let skip = RecordSkip {
1755 source: SourceSelector::new("s", "t"),
1756 partition_id: 2,
1757 topic_created_at_micros: 7,
1758 offset: 40,
1759 target: "rows".to_owned(),
1760 reason: "rejected".to_owned(),
1761 };
1762 assert!(skip.covers("s", "t", 2, 7, 40, "rows"));
1763 assert!(
1764 !skip.covers("s", "t", 2, 8, 40, "rows"),
1765 "another incarnation"
1766 );
1767 assert!(!skip.covers("s", "t", 2, 7, 41, "rows"));
1768 assert!(!skip.covers("s", "t", 3, 7, 40, "rows"));
1769 assert!(!skip.covers("s", "t", 2, 7, 40, "other"));
1770 assert!(!skip.covers("s", "u", 2, 7, 40, "rows"));
1771 }
1772
1773 #[test]
1774 fn given_an_unknown_schema_source_kind_when_decoded_then_should_degrade_not_fail() {
1775 let source: SchemaSource =
1779 serde_json::from_str(r#"{"kind":"parquet_descriptor","blob":[1,2,3]}"#)
1780 .expect("an unknown schema kind decodes to Unknown, not an error");
1781 assert_eq!(source, SchemaSource::Unknown);
1782 let def = SchemaDef {
1784 id: 1,
1785 source,
1786 name: None,
1787 version: None,
1788 };
1789 assert_eq!(def.content_type(), ContentType::Any);
1790 }
1791
1792 #[test]
1793 fn given_an_unknown_retention_kind_when_decoded_then_should_degrade_not_fail() {
1794 let policy: RetentionPolicy = serde_json::from_str(r#"{"kind":"keep_for_eras","eras":3}"#)
1795 .expect("an unknown retention kind decodes to Unknown, not an error");
1796 assert_eq!(policy, RetentionPolicy::Unknown);
1797 }
1798
1799 #[test]
1800 fn given_an_explicit_operational_index_when_built_then_should_keep_one_index() {
1801 let binding = ProjectionBinding::builder()
1802 .source("fleet", "readings")
1803 .allow("reading.v1")
1804 .index("readings_rows")
1805 .build();
1806 assert_eq!(binding.index, "readings_rows");
1807 assert_eq!(binding.backend, None);
1808 }
1809
1810 #[test]
1811 fn given_an_operational_backend_when_built_then_should_keep_exact_generation() {
1812 let backend = BackendBinding {
1813 resource_id: crate::destination::BackendResourceId::from_u128(7),
1814 generation: 3,
1815 };
1816 let binding = ProjectionBinding::builder()
1817 .source("fleet", "readings")
1818 .backend(backend.clone())
1819 .build();
1820 assert_eq!(binding.backend, Some(backend));
1821 }
1822
1823 #[test]
1824 fn given_no_operational_index_when_built_then_should_use_the_topic_name() {
1825 let binding = ProjectionBinding::builder()
1826 .source("fleet", "readings")
1827 .allow("reading.v1")
1828 .build();
1829 assert_eq!(binding.index, "readings");
1830 }
1831
1832 #[test]
1833 fn given_no_retention_when_built_then_should_default_to_none() {
1834 let binding = ProjectionBinding::builder()
1835 .source("fleet", "readings")
1836 .index("readings_rows")
1837 .build();
1838 assert_eq!(binding.retention, None);
1839 }
1840
1841 #[test]
1842 fn given_no_source_when_try_built_then_should_error() {
1843 assert!(ProjectionBinding::builder().try_build().is_err());
1844 }
1845
1846 #[test]
1847 fn given_a_projection_built_with_the_default_when_inspected_then_should_inline_payload() {
1848 let projection = Projection::builder("api.call.v1")
1849 .name("api.call")
1850 .version(1)
1851 .fields(["endpoint", "status"])
1852 .build();
1853 assert!(
1854 projection.inline_payload_default,
1855 "Projection::builder default should inline payload"
1856 );
1857 assert!(
1858 projection.extraction.inline_payload,
1859 "Projection::builder default should mark extraction.inline_payload too"
1860 );
1861 }
1862
1863 #[test]
1864 fn given_a_projection_with_index_only_when_inspected_then_should_skip_inlining() {
1865 let projection = Projection::builder("api.call.v1")
1866 .name("api.call")
1867 .version(1)
1868 .fields(["endpoint"])
1869 .index_only()
1870 .build();
1871 assert!(
1872 !projection.inline_payload_default,
1873 "index_only must clear inline_payload_default"
1874 );
1875 assert!(
1876 !projection.extraction.inline_payload,
1877 "index_only must clear extraction.inline_payload"
1878 );
1879 }
1880
1881 #[test]
1882 fn given_an_empty_projection_id_when_parsed_then_should_error() {
1883 assert!("".parse::<ProjectionId>().is_err());
1884 assert_eq!(
1885 "reading.v1"
1886 .parse::<ProjectionId>()
1887 .expect("non-empty id parses")
1888 .as_str(),
1889 "reading.v1"
1890 );
1891 }
1892
1893 #[test]
1894 fn given_an_index_schema_builder_when_built_then_should_keep_every_declared_rule() {
1895 let schema = IndexSchema::builder()
1896 .field("host")
1897 .field_at("cpu_pct", "/cpu/value")
1898 .vector_field("/vectors/text")
1899 .inline_payload()
1900 .build();
1901 assert_eq!(
1902 schema,
1903 IndexSchema {
1904 fields: vec![
1905 IndexField::new("host", "/host"),
1906 IndexField::new("cpu_pct", "/cpu/value"),
1907 ],
1908 vector_field: Some("/vectors/text".to_owned()),
1909 inline_payload: true,
1910 }
1911 );
1912 }
1913
1914 #[test]
1915 fn given_a_projection_id_from_a_string_when_read_as_text_then_should_expose_that_string() {
1916 let id = ProjectionId::from("reading.v1".to_owned());
1917 let viewed: &str = id.as_ref();
1918 let borrowed: &str = id.borrow();
1919 assert_eq!(id, ProjectionId::new("reading.v1"));
1920 assert_eq!(
1921 (id.to_string(), viewed, borrowed),
1922 ("reading.v1".to_owned(), "reading.v1", "reading.v1")
1923 );
1924 }
1925
1926 #[test]
1927 fn given_a_keyed_record_projection_when_encoded_then_should_round_trip_and_omit_defaults() {
1928 let keyed = Projection::builder("orders.v1")
1929 .record()
1930 .keyed(
1931 ProjectionKey::Header {
1932 name: "order_id".to_owned(),
1933 },
1934 Some("deleted"),
1935 )
1936 .build();
1937 let json = serde_json::to_value(&keyed).expect("encode a keyed record projection");
1938 let decoded: Projection =
1939 serde_json::from_value(json).expect("decode a keyed record projection");
1940 assert_eq!(decoded, keyed);
1941 let plain = serde_json::to_value(Projection::builder("orders.v1").build())
1942 .expect("encode a default projection");
1943 assert_eq!((plain.get("kind"), plain.get("history")), (None, None));
1944 }
1945
1946 #[test]
1947 fn given_a_keyed_projection_without_a_delete_header_when_decoded_then_should_validate_and_encode_without_it()
1948 {
1949 let keyed = Projection::builder("orders.v1")
1950 .record()
1951 .keyed(
1952 ProjectionKey::Header {
1953 name: "order_id".to_owned(),
1954 },
1955 Some("deleted"),
1956 )
1957 .build();
1958 let mut json = serde_json::to_value(&keyed).expect("encode a keyed projection");
1959 json["history"]
1960 .as_object_mut()
1961 .expect("a keyed history object")
1962 .remove("delete_header");
1963 let decoded: Projection = serde_json::from_value(json.clone())
1964 .expect("decode a keyed projection without a delete header");
1965 decoded
1966 .validate()
1967 .expect("a keyed view may name no delete header");
1968 assert_eq!(
1969 serde_json::to_value(&decoded).expect("encode it again"),
1970 json
1971 );
1972 json["history"]["delete_header"] = serde_json::Value::String(String::new());
1973 let empty: Projection =
1974 serde_json::from_value(json).expect("decode an empty delete header");
1975 assert!(
1976 empty.validate().is_err(),
1977 "a named delete header must not be empty"
1978 );
1979 }
1980
1981 #[test]
1982 fn given_a_selector_and_notify_when_a_binding_is_built_then_should_keep_both() {
1983 let binding = ProjectionBinding::builder()
1984 .notify()
1985 .selector(SourceSelector::new("fleet", "readings"))
1986 .try_build()
1987 .expect("a selector is a source");
1988 assert_eq!(
1989 binding,
1990 ProjectionBinding {
1991 source: SourceSelector::new("fleet", "readings"),
1992 allowed_projections: Vec::new(),
1993 default_projection: None,
1994 backend: None,
1995 index: "readings".to_owned(),
1996 notify: true,
1997 retention: None,
1998 invalid_records: InvalidRecordPolicy::Block,
1999 oversize: OversizePolicy::Block,
2000 }
2001 );
2002 }
2003
2004 #[test]
2005 fn given_a_gap_acceptance_when_matched_then_should_cover_only_its_incarnation_and_range() {
2006 let gap = GapAcceptance {
2007 source: SourceSelector::new("s", "t"),
2008 partition_id: 2,
2009 topic_created_at_micros: 7,
2010 from_offset: 10,
2011 to_offset: 20,
2012 };
2013 assert!(gap.covers(7, 10, 20), "a resume at both accepted edges");
2014 assert!(!gap.covers(8, 10, 20), "another incarnation");
2015 assert!(!gap.covers(7, 9, 20), "a cursor before the accepted range");
2016 assert!(
2017 !gap.covers(7, 10, 21),
2018 "a first record past the accepted range"
2019 );
2020 }
2021
2022 #[test]
2023 fn given_an_index_field_name_that_is_not_an_identifier_when_validated_then_should_refuse_it() {
2024 for name in ["user-id", "order.total", "9lives", "caf\u{e9}"] {
2025 let projection = Projection::builder("orders")
2026 .field_at(name, "/value")
2027 .build();
2028 assert!(projection.validate().is_err(), "`{name}` is refused");
2029 }
2030 for name in ["user_id", "_private", "total2"] {
2031 let projection = Projection::builder("orders")
2032 .field_at(name, "/value")
2033 .build();
2034 assert!(projection.validate().is_ok(), "`{name}` is accepted");
2035 }
2036 }
2037
2038 #[test]
2039 fn given_a_retention_that_never_or_always_prunes_when_validated_then_should_refuse_the_binding()
2040 {
2041 for retention in [
2042 RetentionPolicy::Unknown,
2043 RetentionPolicy::TimeToLive { ttl_micros: 0 },
2044 RetentionPolicy::MaxRows { rows: 0 },
2045 ] {
2046 let binding = ProjectionBinding::builder()
2047 .source("s", "t")
2048 .retention(retention)
2049 .build();
2050 assert!(binding.validate().is_err(), "{retention:?} is refused");
2051 }
2052 let bounded = ProjectionBinding::builder()
2053 .source("s", "t")
2054 .retention(RetentionPolicy::MaxRows { rows: 1 })
2055 .build();
2056 assert!(bounded.validate().is_ok());
2057 }
2058
2059 #[test]
2060 fn given_one_unknown_binding_policy_when_validated_then_should_refuse_the_binding() {
2061 let binding = ProjectionBinding {
2062 invalid_records: InvalidRecordPolicy::Unknown,
2063 ..ProjectionBinding::builder().source("s", "t").build()
2064 };
2065 assert_eq!(binding.oversize, OversizePolicy::Block);
2066 assert!(binding.validate().is_err());
2067 }
2068}
2069
2070#[cfg(all(test, feature = "cbor"))]
2071mod wire_tests {
2072 use super::*;
2073 use crate::codes::CONTROL_OP_VERSION;
2074 use crate::content::ContentType;
2075 use crate::framing::{decode_named, encode_named};
2076
2077 #[test]
2078 fn given_an_apply_binding_when_round_tripped_then_should_preserve_index_and_version() {
2079 let binding = ProjectionBinding::builder()
2080 .source("fleet", "readings")
2081 .allow("reading.v1")
2082 .default_projection("reading.v1")
2083 .index("readings_rows")
2084 .build();
2085 let envelope = ControlEnvelope {
2086 v: CONTROL_OP_VERSION,
2087 timestamp_micros: 42,
2088 command: ControlCommand::ApplyBinding(binding),
2089 stream: None,
2090 stamp: None,
2091 };
2092 let bytes = encode_named(&envelope).expect("envelope serializes");
2093 let back: ControlEnvelope = decode_named(&bytes).expect("envelope deserializes");
2094 assert_eq!(back.v, CONTROL_OP_VERSION);
2095 let ControlCommand::ApplyBinding(decoded) = back.command else {
2096 panic!("expected ApplyBinding");
2097 };
2098 assert_eq!(decoded.index, "readings_rows");
2099 assert_eq!(decoded.backend, None);
2100 assert_eq!(decoded.retention, None);
2103 }
2104
2105 #[test]
2106 fn given_a_register_schema_when_round_tripped_then_should_preserve_source() {
2107 let envelope = ControlEnvelope {
2108 v: CONTROL_OP_VERSION,
2109 timestamp_micros: 7,
2110 command: ControlCommand::RegisterSchema(SchemaDef {
2111 id: 11,
2112 source: SchemaSource::Avro {
2113 schema: r#"{"type":"record","name":"Reading","fields":[]}"#.to_owned(),
2114 },
2115 name: None,
2116 version: None,
2117 }),
2118 stream: None,
2119 stamp: None,
2120 };
2121 let bytes = encode_named(&envelope).expect("envelope serializes");
2122 let back: ControlEnvelope = decode_named(&bytes).expect("envelope deserializes");
2123 assert_eq!(back.stream, None);
2124 let ControlCommand::RegisterSchema(decoded) = back.command else {
2125 panic!("expected RegisterSchema");
2126 };
2127 assert_eq!(decoded.id, 11);
2128 assert_eq!(decoded.content_type(), ContentType::Avro);
2129 }
2130
2131 #[test]
2132 fn given_a_stream_scoped_drop_schema_when_round_tripped_then_should_preserve_the_stream() {
2133 let envelope = ControlEnvelope {
2134 v: CONTROL_OP_VERSION,
2135 timestamp_micros: 7,
2136 command: ControlCommand::DropSchema(11),
2137 stream: Some("acme".to_owned()),
2138 stamp: None,
2139 };
2140 let back: ControlEnvelope =
2141 decode_named(&encode_named(&envelope).expect("envelope serializes"))
2142 .expect("envelope deserializes");
2143 assert_eq!(back.stream.as_deref(), Some("acme"));
2144 assert!(matches!(back.command, ControlCommand::DropSchema(11)));
2145 let unscoped = ControlEnvelope {
2146 stream: None,
2147 ..envelope
2148 };
2149 let json = serde_json::to_string(&unscoped).expect("envelope serializes");
2150 assert!(
2151 !json.contains("stream"),
2152 "an unset stream is omitted: {json}"
2153 );
2154 }
2155
2156 #[test]
2157 fn given_a_stamped_envelope_when_round_tripped_then_should_keep_the_identity_and_omit_an_unset_stamp()
2158 {
2159 let envelope = ControlEnvelope {
2160 v: CONTROL_OP_VERSION,
2161 timestamp_micros: 7,
2162 command: ControlCommand::DropSchema(11),
2163 stream: Some("acme".to_owned()),
2164 stamp: Some(ControlStamp {
2165 deployment_id: 42,
2166 operation_id: u128::MAX - 1,
2167 stream_generation: Some(ControlStreamGeneration {
2168 stream_id: 9,
2169 created_at_micros: 1_700_000_000_000_000,
2170 }),
2171 key_id: u64::MAX,
2172 caller: Some(crate::forward::Caller {
2173 user_id: 0,
2174 agent: None,
2175 on_behalf_of: None,
2176 }),
2177 }),
2178 };
2179 let back: ControlEnvelope =
2180 decode_named(&encode_named(&envelope).expect("envelope serializes"))
2181 .expect("envelope deserializes");
2182 assert_eq!(back.stamp, envelope.stamp);
2183 let unstamped = ControlEnvelope {
2184 stamp: None,
2185 ..envelope
2186 };
2187 let json = serde_json::to_string(&unstamped).expect("envelope serializes");
2188 assert!(
2189 !json.contains("\"stamp\""),
2190 "an unset stamp is omitted: {json}"
2191 );
2192 }
2193
2194 #[test]
2195 fn given_a_protobuf_schema_source_when_round_tripped_then_should_preserve_bytes() {
2196 let source = SchemaSource::Protobuf {
2197 descriptor_set: vec![10, 20, 30],
2198 message_type: "fleet.Reading".to_owned(),
2199 };
2200 let bytes = encode_named(&source).expect("serializes");
2201 let back: SchemaSource = decode_named(&bytes).expect("deserializes");
2202 assert_eq!(back, source);
2203 }
2204
2205 #[test]
2206 fn given_retention_set_when_round_tripped_then_should_preserve_policy() {
2207 for policy in [
2208 RetentionPolicy::MirrorLog,
2212 RetentionPolicy::Keep,
2213 RetentionPolicy::KeepUntilSourceDeleted,
2214 RetentionPolicy::TimeToLive {
2215 ttl_micros: 3_600_000_000,
2216 },
2217 RetentionPolicy::MaxRows { rows: 10_000 },
2218 ] {
2219 let binding = ProjectionBinding::builder()
2220 .source("fleet", "telemetry")
2221 .allow("telemetry.v1")
2222 .index("telemetry_rows")
2223 .retention(policy)
2224 .build();
2225 assert_eq!(binding.retention, Some(policy));
2226 let bytes = encode_named(&binding).expect("binding serializes");
2227 let back: ProjectionBinding = decode_named(&bytes).expect("binding deserializes");
2228 assert_eq!(back.retention, Some(policy));
2229 }
2230 }
2231}
2232
2233#[cfg(all(test, feature = "codecs"))]
2234mod schema_tests {
2235 use super::*;
2236 use crate::framing::{decode_named, encode_named};
2237
2238 #[test]
2239 fn given_a_schema_def_with_version_when_round_tripped_then_should_preserve_it() {
2240 let def = SchemaDef {
2241 id: 7,
2242 source: SchemaSource::Avro {
2243 schema: "{}".to_owned(),
2244 },
2245 name: Some("readings".to_owned()),
2246 version: Some(2),
2247 };
2248 let bytes = encode_named(&def).expect("serializes");
2249 let back: SchemaDef = decode_named(&bytes).expect("deserializes");
2250 assert_eq!(back.version, Some(2));
2251 let unversioned = SchemaDef {
2254 name: None,
2255 version: None,
2256 ..def
2257 };
2258 let json = serde_json::to_string(&unversioned).expect("serializes");
2259 assert!(
2260 !json.contains("version"),
2261 "unset version must be omitted: {json}"
2262 );
2263 }
2264}