laser_wire/control.rs
1use crate::content::ContentType;
2use crate::destination::BackendBinding;
3use crate::error::InvalidError;
4use serde::{Deserialize, Serialize};
5use std::str::FromStr;
6
7/// One indexed scalar: the index key `name` and the RFC-6901 JSON `pointer` into
8/// the payload it is extracted from.
9///
10/// **Why declared-once extraction exists.** Stamping `agdx.idx.host_id=node-7`
11/// on every Iggy message duplicates the field name on the wire and couples the
12/// producer to projection details. With a schema declared once on the
13/// projector, producers just push the payload (single or batched) and the
14/// worker derives the index from the body. The `agdx.idx.*` header path remains
15/// as the escape hatch for raw payloads and for schema-first bodies whose
16/// writer schema is not registered. Explicit headers always win over
17/// schema-extracted values for the same field name.
18#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
19pub struct IndexField {
20 pub name: String,
21 pub pointer: String,
22 /// Optional storage-type hint. The embedded engine ignores it and keeps
23 /// native JSON types. A wide-column backend uses it to create a real typed
24 /// column instead of a text fallback. Absent on the wire when unset, so
25 /// pre-hint registries decode unchanged.
26 #[serde(default, skip_serializing_if = "Option::is_none")]
27 pub field_type: Option<FieldType>,
28}
29
30impl IndexField {
31 /// An indexed field `name` extracted from the payload at JSON `pointer`.
32 pub fn new(name: impl Into<String>, pointer: impl Into<String>) -> Self {
33 Self {
34 name: name.into(),
35 pointer: pointer.into(),
36 field_type: None,
37 }
38 }
39
40 /// An indexed field with an explicit storage-type hint for columnar backends.
41 pub fn typed(
42 name: impl Into<String>,
43 pointer: impl Into<String>,
44 field_type: FieldType,
45 ) -> Self {
46 Self {
47 name: name.into(),
48 pointer: pointer.into(),
49 field_type: Some(field_type),
50 }
51 }
52}
53
54/// Storage-type hint for an indexed field. A hint, not a constraint: the
55/// projector stores whatever scalar the payload carries either way. Columnar
56/// backends use the hint for real column DDL.
57#[derive(
58 Clone,
59 Copy,
60 Debug,
61 PartialEq,
62 Eq,
63 Serialize,
64 Deserialize,
65 strum::Display,
66 strum::EnumString,
67 strum::VariantArray,
68)]
69#[serde(rename_all = "snake_case")]
70#[strum(serialize_all = "snake_case")]
71#[non_exhaustive]
72pub enum FieldType {
73 Text,
74 Int,
75 Float,
76 Bool,
77}
78
79/// The indexed fields (and optional vector field) a projection extracts.
80#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
81pub struct IndexSchema {
82 pub fields: Vec<IndexField>,
83 // Optional JSON pointer to a vector field (`[f32]`) inside the payload.
84 // Default is `/embedding` at the root, which matches what producers
85 // writing JSON bodies produce today.
86 #[serde(default, skip_serializing_if = "Option::is_none")]
87 pub vector_field: Option<String>,
88 // Whether the projector inlines the payload bytes alongside the row by
89 // default. The producer can still override per record, but a sensible
90 // default means batch-publishers do not need to stamp the directive on
91 // every message.
92 #[serde(default)]
93 pub inline_payload: bool,
94}
95
96impl IndexSchema {
97 /// Start building an index schema.
98 pub fn builder() -> IndexSchemaBuilder {
99 IndexSchemaBuilder::default()
100 }
101}
102
103/// Fluent builder for an `IndexSchema`.
104#[derive(Default)]
105pub struct IndexSchemaBuilder {
106 schema: IndexSchema,
107}
108
109impl IndexSchemaBuilder {
110 /// Index the JSON value at the root-level field `name`. The index key and
111 /// the JSON path are the same in the common case, so `field("host")`
112 /// expands to "extract `/host` from the payload and store it under
113 /// the index key `host`". For nested or renamed extraction use
114 /// [`field_at`](Self::field_at).
115 pub fn field(mut self, name: impl Into<String>) -> Self {
116 let name = name.into();
117 let pointer = format!("/{name}");
118 self.schema.fields.push(IndexField::new(name, pointer));
119 self
120 }
121
122 /// Index the JSON value at `pointer` (RFC-6901) under the index key
123 /// `name`. Use when the index column name differs from the payload field,
124 /// or when the value lives in a nested structure -
125 /// `field_at("cpu_pct", "/cpu/value")` indexes `cpu.value` as
126 /// `cpu_pct`.
127 pub fn field_at(mut self, name: impl Into<String>, pointer: impl Into<String>) -> Self {
128 self.schema.fields.push(IndexField::new(name, pointer));
129 self
130 }
131
132 /// Point the vector extractor at `pointer` instead of the default
133 /// (`/embedding` at the payload root). Pointer is RFC-6901.
134 pub fn vector_field(mut self, pointer: impl Into<String>) -> Self {
135 self.schema.vector_field = Some(pointer.into());
136 self
137 }
138
139 /// Default `inline_payload = true` for every record projected through this
140 /// schema. Per-record overrides stay valid.
141 pub fn inline_payload(mut self) -> Self {
142 self.schema.inline_payload = true;
143 self
144 }
145
146 /// Finish the index schema.
147 pub fn build(self) -> IndexSchema {
148 self.schema
149 }
150}
151
152/// Opaque projection identifier. Stable string the producer stamps on the
153/// wire via the `agdx.ref` header and the worker keys its catalog by.
154/// Recommended shape: `"<name>.v<version>"`, e.g. `"reading.v1"`. Distinct from
155/// `schema_id`, which selects a codec's writer schema rather than a
156/// materialization rule.
157#[derive(Clone, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
158#[serde(transparent)]
159pub struct ProjectionId(String);
160
161impl ProjectionId {
162 /// A projection id from a string.
163 pub fn new(value: impl Into<String>) -> Self {
164 Self(value.into())
165 }
166
167 /// The id as a string slice.
168 pub fn as_str(&self) -> &str {
169 &self.0
170 }
171}
172
173impl std::fmt::Display for ProjectionId {
174 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
175 f.write_str(&self.0)
176 }
177}
178
179impl AsRef<str> for ProjectionId {
180 fn as_ref(&self) -> &str {
181 &self.0
182 }
183}
184
185impl std::borrow::Borrow<str> for ProjectionId {
186 fn borrow(&self) -> &str {
187 &self.0
188 }
189}
190
191impl FromStr for ProjectionId {
192 type Err = InvalidError;
193
194 fn from_str(s: &str) -> Result<Self, Self::Err> {
195 if s.is_empty() {
196 return Err(InvalidError::new("projection id must not be empty"));
197 }
198 Ok(Self(s.to_owned()))
199 }
200}
201
202impl From<&str> for ProjectionId {
203 fn from(value: &str) -> Self {
204 Self(value.to_owned())
205 }
206}
207
208impl From<String> for ProjectionId {
209 fn from(value: String) -> Self {
210 Self(value)
211 }
212}
213
214/// What a projection materializes: queryable rows, or a knowledge graph of
215/// nodes and edges. Rides the wire as a u8 code (the growable-dictionary pattern)
216/// so a future kind flows through an old reader as
217/// [`Unrecognized`](Self::Unrecognized) rather than failing the listing.
218#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
219#[serde(from = "u8", into = "u8")]
220pub enum ProjectionKind {
221 /// Materialize queryable rows (the default).
222 #[default]
223 Row,
224 /// Materialize a knowledge graph (nodes, edges, triplets).
225 Graph,
226 /// A code this build does not know, passed through.
227 Unrecognized(u8),
228}
229
230impl ProjectionKind {
231 /// The pinned wire code.
232 pub const fn code(self) -> u8 {
233 match self {
234 ProjectionKind::Row => 0,
235 ProjectionKind::Graph => 1,
236 ProjectionKind::Unrecognized(code) => code,
237 }
238 }
239
240 /// The kind for a wire code (unknown codes become
241 /// [`Unrecognized`](Self::Unrecognized)).
242 pub const fn from_code(code: u8) -> Self {
243 match code {
244 0 => ProjectionKind::Row,
245 1 => ProjectionKind::Graph,
246 other => ProjectionKind::Unrecognized(other),
247 }
248 }
249
250 /// Whether this is the default `Row` kind (omitted on the wire).
251 pub const fn is_row(&self) -> bool {
252 matches!(self, ProjectionKind::Row)
253 }
254}
255
256impl From<u8> for ProjectionKind {
257 fn from(code: u8) -> Self {
258 Self::from_code(code)
259 }
260}
261
262impl From<ProjectionKind> for u8 {
263 fn from(kind: ProjectionKind) -> u8 {
264 kind.code()
265 }
266}
267
268/// The declared plan for extracting nodes and edges from a payload. Pointer-based
269/// (RFC 6901) and deterministic, so no model call is needed. Recorded on a graph
270/// projection for discovery and as the extraction contract. Graph data is written
271/// via the graph upsert op. Applying this plan to a bound source is managed-side.
272#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
273pub struct EntitySchema {
274 pub nodes: Vec<NodeExtract>,
275 #[serde(default, skip_serializing_if = "Vec::is_empty")]
276 pub edges: Vec<EdgeExtract>,
277}
278
279/// One node-extraction rule: the node's label and the pointer to its canonical
280/// value (which content-addresses its id, so the same entity converges).
281#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
282pub struct NodeExtract {
283 pub label: String,
284 pub value_pointer: String,
285 #[serde(default, skip_serializing_if = "Option::is_none")]
286 pub embedding_pointer: Option<String>,
287}
288
289/// One edge-extraction rule: the edge type, the pointers to its endpoint values,
290/// and optional pointers to the bitemporal valid-time window.
291#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
292pub struct EdgeExtract {
293 pub edge_type: String,
294 pub from_pointer: String,
295 pub to_pointer: String,
296 /// RFC-6901 pointer to the valid-time start (epoch micros) for the extracted
297 /// edge, making it a bitemporal fact. `None` leaves the window open.
298 #[serde(default, skip_serializing_if = "Option::is_none")]
299 pub valid_from_pointer: Option<String>,
300 /// RFC-6901 pointer to the valid-time end (epoch micros). `None` leaves the
301 /// edge open-ended (still valid).
302 #[serde(default, skip_serializing_if = "Option::is_none")]
303 pub valid_to_pointer: Option<String>,
304}
305
306/// Global reusable projection definition. Names the extraction rules that turn
307/// a payload into a queryable row. Not attached to a topic on its own:
308/// bindings ([`ProjectionBinding`]) declare where projections may apply.
309///
310/// # Storage model
311///
312/// A published record lives in up to three places, controlled by the projection:
313///
314/// 1. **Iggy log** (always): the original wire bytes, partitioned, replayable
315/// from offset 0. The source of truth.
316/// 2. **Indexed columns** (always): the scalar fields declared via
317/// `field` / `field_at`, extracted from the payload at materialize time
318/// and stored in the row. These drive filters, ordering, and aggregates.
319/// 3. **Inline body** (opt-in via `inline_payload`, on by default through
320/// `Projection::builder`): a copy of the full original payload alongside
321/// the row, so typed fetches can decode it without going back to the log.
322///
323/// The body may carry fields that are not indexed. Only the declared fields
324/// are queryable. Everything else rides through as part of the inlined body
325/// (when on) or is reachable only by Iggy replay (when off).
326#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
327pub struct Projection {
328 /// Stable id used on the wire as `agdx.ref`.
329 pub id: ProjectionId,
330 /// Human-readable name (e.g. `"reading"`). Distinct from `id` so the same
331 /// logical projection can rename without breaking the wire ref.
332 pub name: String,
333 /// Schema-evolution version. Bump on incompatible changes. The wire id
334 /// usually encodes this (`reading.v2`).
335 pub version: u32,
336 /// What this projection materializes: rows (default) or a graph.
337 #[serde(default, skip_serializing_if = "ProjectionKind::is_row")]
338 pub kind: ProjectionKind,
339 /// Expected payload codec. `Any` means best-effort decode: the extraction
340 /// plan tolerates an opaque payload.
341 pub content_type: ContentType,
342 /// Field extraction plan.
343 pub extraction: IndexSchema,
344 /// Node/edge extraction plan for a `Graph` projection. `None` for a row
345 /// projection.
346 #[serde(default, skip_serializing_if = "Option::is_none")]
347 pub entity_schema: Option<EntitySchema>,
348 /// Default `inline_payload` for records routed through this projection,
349 /// overridable per record.
350 #[serde(default)]
351 pub inline_payload_default: bool,
352}
353
354impl Projection {
355 /// Start building a projection with this id.
356 pub fn builder(id: impl Into<ProjectionId>) -> ProjectionBuilder {
357 ProjectionBuilder {
358 projection: Self {
359 id: id.into(),
360 name: String::new(),
361 version: 1,
362 kind: ProjectionKind::Row,
363 content_type: ContentType::Any,
364 // Default: inline the body alongside the indexed row so typed
365 // fetches can decode it without going back to the Iggy log.
366 // Opt out with `.index_only()` for high-volume projections
367 // where duplication is not worth it.
368 extraction: IndexSchema {
369 fields: Vec::new(),
370 vector_field: None,
371 inline_payload: true,
372 },
373 entity_schema: None,
374 inline_payload_default: true,
375 },
376 }
377 }
378}
379
380/// Fluent builder for a `Projection`.
381pub struct ProjectionBuilder {
382 projection: Projection,
383}
384
385impl ProjectionBuilder {
386 /// Set the human-readable projection name.
387 pub fn name(mut self, value: impl Into<String>) -> Self {
388 self.projection.name = value.into();
389 self
390 }
391
392 /// Set the projection version.
393 pub fn version(mut self, value: u32) -> Self {
394 self.projection.version = value;
395 self
396 }
397
398 /// Set the wire codec the projector decodes records with.
399 pub fn content_type(mut self, value: ContentType) -> Self {
400 self.projection.content_type = value;
401 self
402 }
403
404 /// Set the full index schema (instead of `field`/`vector_field`).
405 pub fn extraction(mut self, value: IndexSchema) -> Self {
406 self.projection.extraction = value;
407 self
408 }
409
410 /// Make this a graph projection with the given node/edge extraction plan.
411 pub fn graph(mut self, schema: EntitySchema) -> Self {
412 self.projection.kind = ProjectionKind::Graph;
413 self.projection.entity_schema = Some(schema);
414 self
415 }
416
417 /// Index a field by name (extracted at its matching JSON pointer).
418 pub fn field(mut self, name: impl Into<String>) -> Self {
419 let name = name.into();
420 let pointer = format!("/{name}");
421 self.projection
422 .extraction
423 .fields
424 .push(IndexField::new(name, pointer));
425 self
426 }
427
428 /// Bulk version of [`field`](Self::field): declare many top-level indexed
429 /// fields in one call. Each name `n` lands at JSON pointer `/n`. For nested
430 /// pointers use [`field_at`](Self::field_at) instead.
431 pub fn fields<I, S>(mut self, names: I) -> Self
432 where
433 I: IntoIterator<Item = S>,
434 S: Into<String>,
435 {
436 for name in names {
437 let name = name.into();
438 let pointer = format!("/{name}");
439 self.projection
440 .extraction
441 .fields
442 .push(IndexField::new(name, pointer));
443 }
444 self
445 }
446
447 /// Index a field `name` from an explicit JSON `pointer`.
448 pub fn field_at(mut self, name: impl Into<String>, pointer: impl Into<String>) -> Self {
449 self.projection
450 .extraction
451 .fields
452 .push(IndexField::new(name, pointer));
453 self
454 }
455
456 /// Like [`field`](Self::field) but with a storage-type hint for columnar
457 /// backends (`FieldType::Int`, `Float`, `Bool`, `Text`). The embedded
458 /// engine ignores the hint.
459 pub fn field_typed(mut self, name: impl Into<String>, field_type: FieldType) -> Self {
460 let name = name.into();
461 let pointer = format!("/{name}");
462 self.projection
463 .extraction
464 .fields
465 .push(IndexField::typed(name, pointer, field_type));
466 self
467 }
468
469 /// Like [`field_at`](Self::field_at) but with a storage-type hint.
470 pub fn field_at_typed(
471 mut self,
472 name: impl Into<String>,
473 pointer: impl Into<String>,
474 field_type: FieldType,
475 ) -> Self {
476 self.projection
477 .extraction
478 .fields
479 .push(IndexField::typed(name, pointer, field_type));
480 self
481 }
482
483 /// Extract the embedding vector from this JSON pointer.
484 pub fn vector_field(mut self, pointer: impl Into<String>) -> Self {
485 self.projection.extraction.vector_field = Some(pointer.into());
486 self
487 }
488
489 /// Inline the original payload alongside the materialized row. This is
490 /// the default for projections built through `Projection::builder`, so
491 /// calling it is redundant. It stays in the API for callers who want to be
492 /// explicit. To opt out, use [`index_only`](Self::index_only).
493 pub fn inline_payload(mut self) -> Self {
494 self.projection.inline_payload_default = true;
495 self.projection.extraction.inline_payload = true;
496 self
497 }
498
499 /// Opt out of inlining the body. The materialized row keeps only the
500 /// indexed scalars declared via `field` / `field_at` (and any vector
501 /// extracted via `vector_field`). The full payload is not duplicated into
502 /// the index. Use this when:
503 ///
504 /// - the body is large and you already store it elsewhere (object store,
505 /// OLAP table) and only need a fast queryable secondary index, or
506 /// - you are pushing extreme throughput and the index DB cost of a body
507 /// copy is not worth it.
508 ///
509 /// The Iggy log retains the original bytes either way, so a future
510 /// projector rebuild can re-inline. With `index_only`, typed fetches
511 /// return rows whose `payload` is `None`. Callers either decode from the
512 /// indexed columns or replay from the log.
513 pub fn index_only(mut self) -> Self {
514 self.projection.inline_payload_default = false;
515 self.projection.extraction.inline_payload = false;
516 self
517 }
518
519 /// Finish the projection.
520 pub fn build(self) -> Projection {
521 self.projection
522 }
523}
524
525/// How long a binding's materialized rows live, **decoupled from the source
526/// topic's Iggy `message_expiry`**. Lets a projection outlive (or undershoot)
527/// the log it was built from.
528///
529/// The canonical case: a topic with aggressive expiry (short-lived partitions
530/// for cheap storage and fast replay) whose derived index must survive forever.
531/// Set the binding to [`RetentionPolicy::Keep`].
532#[derive(Clone, Copy, Debug, PartialEq, Eq, Default, Serialize, Deserialize)]
533#[serde(tag = "kind", rename_all = "snake_case")]
534#[non_exhaustive]
535pub enum RetentionPolicy {
536 /// Follow the log: rows are pruned once Iggy drops the messages that
537 /// produced them. The default. Like `KeepUntilSourceDeleted`, it also
538 /// deletes the projection when the source topic is deleted.
539 #[default]
540 MirrorLog,
541 /// Keep rows forever, regardless of the source log *or its deletion*.
542 Keep,
543 /// Keep rows forever while the source topic exists, but drop the whole
544 /// projection when the source topic is **deleted**. Ignores message expiry
545 /// like `Keep`, but is tied to the topic's existence like `MirrorLog`.
546 KeepUntilSourceDeleted,
547 /// Keep rows for `ttl_micros` after they were materialized, independent of
548 /// the log. Older rows are swept, and survivors outlive an expired source.
549 TimeToLive { ttl_micros: u64 },
550 /// Keep the newest `rows` rows for the target table, independent of the log.
551 MaxRows { rows: u64 },
552 /// A policy `kind` a newer server named that this build does not know. The
553 /// decode degrades to this rather than failing the whole reply, so an older
554 /// client keeps reading bindings it cannot fully interpret (it must not
555 /// re-apply one: re-serializing loses the original `kind` and its fields).
556 /// The same forward-compat shape the `ResultCode` and u8 dictionaries use.
557 #[serde(other)]
558 Unknown,
559}
560
561/// Declares where a `Projection` is allowed to materialize. Bindings, not
562/// projections, tell the worker what to consume. A binding pairs a source
563/// selector (stream and topic) with a set of allowed projection refs, an
564/// optional default, and one operational index. The index uses the replica-local
565/// backend unless an exact backend resource generation is bound. Shared
566/// lakehouse materializations are independent `MaterializationDestination`
567/// declarations, never mirrors on this operational cursor.
568#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
569pub struct ProjectionBinding {
570 pub source: SourceSelector,
571 /// Projections allowed to fire on messages from this source. A record
572 /// stamped with an `agdx.ref` outside this set is either DLQ'd or skipped,
573 /// per worker policy.
574 #[serde(default)]
575 pub allowed_projections: Vec<ProjectionId>,
576 /// Default projection applied to records that do not carry an `agdx.ref`.
577 /// `None` means such records are skipped.
578 #[serde(default)]
579 pub default_projection: Option<ProjectionId>,
580 /// Exact operational backend resource generation. `None` selects the
581 /// replica-local backend.
582 #[serde(default, skip_serializing_if = "Option::is_none")]
583 pub backend: Option<BackendBinding>,
584 /// Operational index name served by `QueryTarget::Operational`.
585 pub index: String,
586 /// Opt this binding into the change feed: after each committed projector
587 /// batch the managed backend publishes one `ChangeRecord` on the changes topic.
588 /// Default off and skipped on the wire, so a deployment that never opts in
589 /// emits nothing and a pre-feed binding encodes byte-identically.
590 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
591 pub notify: bool,
592 /// Row lifetime for this binding, independent of the source topic's Iggy
593 /// retention. `None` inherits LaserData Cloud's fleet-wide default
594 /// (`MirrorLog` unless an operator changed it). Set it via
595 /// [`retention`](ProjectionBindingBuilder::retention).
596 #[serde(default, skip_serializing_if = "Option::is_none")]
597 pub retention: Option<RetentionPolicy>,
598 /// Let the managed backend skip a record the destination or the extraction
599 /// deterministically rejects, instead of holding the partition until the
600 /// binding enables skipping. Operator skips also require this opt-in. The
601 /// backend skips only after it proved the rejection: the destination
602 /// accepted other records of the same batch and refused this
603 /// one twice. Every skip is an ordered control record, so every replica
604 /// skips the same record, and the record goes to the dead-letter topic.
605 /// Default off and skipped on the wire.
606 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
607 pub skip_rejected: bool,
608}
609
610impl ProjectionBinding {
611 /// Start building a binding (which topic feeds which projection).
612 pub fn builder() -> ProjectionBindingBuilder {
613 ProjectionBindingBuilder::default()
614 }
615}
616
617/// Fluent builder for a `ProjectionBinding`.
618#[derive(Default)]
619pub struct ProjectionBindingBuilder {
620 source: Option<SourceSelector>,
621 allowed: Vec<ProjectionId>,
622 default_projection: Option<ProjectionId>,
623 backend: Option<BackendBinding>,
624 index: Option<String>,
625 notify: bool,
626 retention: Option<RetentionPolicy>,
627 skip_rejected: bool,
628}
629
630impl ProjectionBindingBuilder {
631 /// Set the binding source. A source is always a `(stream, topic)` pair,
632 /// and a topic only exists within a stream, so both are required.
633 pub fn source(mut self, stream: impl Into<String>, topic: impl Into<String>) -> Self {
634 self.source = Some(SourceSelector::new(stream, topic));
635 self
636 }
637
638 /// Set the binding source from a pre-built [`SourceSelector`].
639 pub fn selector(mut self, source: SourceSelector) -> Self {
640 self.source = Some(source);
641 self
642 }
643
644 /// Allow records to route to this projection.
645 pub fn allow(mut self, projection: impl Into<ProjectionId>) -> Self {
646 self.allowed.push(projection.into());
647 self
648 }
649
650 /// Projection used when a record carries no `agdx.ref`.
651 pub fn default_projection(mut self, projection: impl Into<ProjectionId>) -> Self {
652 self.default_projection = Some(projection.into());
653 self
654 }
655
656 /// Bind the operational index to an exact backend resource generation.
657 /// Omit this for the replica-local backend.
658 pub fn backend(mut self, backend: BackendBinding) -> Self {
659 self.backend = Some(backend);
660 self
661 }
662
663 /// Set how long the materialized rows live, independent of the source
664 /// topic's Iggy retention. Omit to inherit the managed backend's default.
665 ///
666 /// ```no_run
667 /// # use laser_wire::control::{ProjectionBinding, RetentionPolicy};
668 /// // Short-lived topic, permanent index:
669 /// let binding = ProjectionBinding::builder()
670 /// .source("_agdx", "telemetry")
671 /// .allow("telemetry.v1")
672 /// .index("telemetry_rows")
673 /// .retention(RetentionPolicy::Keep)
674 /// .build();
675 /// ```
676 pub fn retention(mut self, retention: RetentionPolicy) -> Self {
677 self.retention = Some(retention);
678 self
679 }
680
681 /// Set the replica-local operational index name. Omit it to use the source
682 /// topic name. External lakehouse destinations use the destination API.
683 pub fn index(mut self, index: impl Into<String>) -> Self {
684 self.index = Some(index.into());
685 self
686 }
687
688 /// Opt into the change feed: one `ChangeRecord` per committed projector
689 /// batch for this binding, on the changes topic.
690 #[must_use]
691 pub fn notify(mut self) -> Self {
692 self.notify = true;
693 self
694 }
695
696 /// Let the managed backend skip a record it proved the destination or the
697 /// extraction rejects, instead of holding the partition. The skipped record
698 /// goes to the dead-letter topic. See [`ProjectionBinding::skip_rejected`].
699 #[must_use]
700 pub fn skip_rejected(mut self) -> Self {
701 self.skip_rejected = true;
702 self
703 }
704
705 /// Build the binding. Panics if `.source(..)` / `.selector(..)` was not set
706 /// (programmer error, not user input). Prefer [`try_build`](Self::try_build)
707 /// for code paths that handle config-load failures gracefully.
708 pub fn build(self) -> ProjectionBinding {
709 self.try_build()
710 .expect("ProjectionBinding requires a source - call .source(stream, topic)")
711 }
712
713 /// Build the binding, returning an error if required fields are missing
714 /// instead of panicking.
715 pub fn try_build(self) -> Result<ProjectionBinding, InvalidError> {
716 let source = self
717 .source
718 .ok_or_else(|| InvalidError::new("ProjectionBinding requires a source"))?;
719 let index = self.index.unwrap_or_else(|| source.topic.clone());
720 Ok(ProjectionBinding {
721 source,
722 allowed_projections: self.allowed,
723 default_projection: self.default_projection,
724 backend: self.backend,
725 index,
726 notify: self.notify,
727 retention: self.retention,
728 skip_rejected: self.skip_rejected,
729 })
730 }
731}
732
733/// Selects a single source `(stream, topic)` for v1. Both are required, since
734/// a topic only exists within a stream. Future versions can extend this to
735/// prefix, glob, or multi-stream selectors. Today the model is
736/// one-topic-per-binding to keep routing simple.
737#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
738pub struct SourceSelector {
739 pub stream: String,
740 pub topic: String,
741}
742
743impl SourceSelector {
744 /// A `(stream, topic)` source for a binding.
745 pub fn new(stream: impl Into<String>, topic: impl Into<String>) -> Self {
746 Self {
747 stream: stream.into(),
748 topic: topic.into(),
749 }
750 }
751}
752
753/// A registered writer schema, keyed by the `id` a producer stamps on
754/// `agdx.sid`. Avro and Protobuf schemas decode their schema-first bodies. A
755/// JSON Schema validates the decoded payload of a self-describing codec
756/// (JSON, MessagePack, CBOR, BSON), which otherwise needs no entry here. Ids
757/// are permanent: registering an occupied id with a different definition is
758/// rejected managed-side, and dropping tombstones the definition.
759#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
760pub struct SchemaDef {
761 pub id: u32,
762 pub source: SchemaSource,
763 /// Optional human label, pure metadata. LaserData Cloud stores and returns
764 /// it but never dispatches on it (`agdx.sid` carries the id). Uniqueness is
765 /// not enforced. Absent on the wire when unset.
766 #[serde(default, skip_serializing_if = "Option::is_none")]
767 pub name: Option<String>,
768 /// Optional caller-tracked schema version, pure metadata. LaserData Cloud
769 /// stores and returns it but never dispatches on it (the `id` alone selects
770 /// the decoder). Absent on the wire when unset, so pre-version registries
771 /// decode unchanged.
772 #[serde(default, skip_serializing_if = "Option::is_none")]
773 pub version: Option<u32>,
774}
775
776impl SchemaDef {
777 /// The codec this schema applies to. A JSON Schema validates any
778 /// self-describing codec and reports as `Json`.
779 pub fn content_type(&self) -> ContentType {
780 match self.source {
781 SchemaSource::Avro { .. } => ContentType::Avro,
782 SchemaSource::Protobuf { .. } => ContentType::Protobuf,
783 SchemaSource::JsonSchema { .. } => ContentType::Json,
784 // An unknown source kind from a newer server: best-effort.
785 SchemaSource::Unknown => ContentType::Any,
786 }
787 }
788}
789
790/// The schema payload for a schema-first codec. Internally tagged on `kind`
791/// (`{"kind":"avro","schema":...}`) so an older client tolerates a newer
792/// server's unknown source kind: an unrecognized `kind` decodes to
793/// [`SchemaSource::Unknown`] instead of failing the whole reply.
794#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
795#[serde(tag = "kind", rename_all = "snake_case")]
796#[non_exhaustive]
797pub enum SchemaSource {
798 /// An Avro writer schema as its canonical JSON text.
799 Avro { schema: String },
800 /// A Protobuf `FileDescriptorSet` (one or more compiled `.proto` files)
801 /// plus the fully-qualified message type to decode, e.g. `"fleet.Reading"`.
802 Protobuf {
803 #[serde(with = "crate::encoding::bin_bytes")]
804 descriptor_set: Vec<u8>,
805 message_type: String,
806 },
807 /// A JSON Schema (draft 2020-12) as its JSON text. Validation-only: a
808 /// self-describing record stamping this schema's id on `agdx.sid` has its
809 /// decoded payload validated by LaserData Cloud. Mismatches are counted,
810 /// optionally dead-lettered, and never materialize body fields.
811 JsonSchema { schema: String },
812 /// A schema `kind` a newer server named that this build does not know. The
813 /// decode degrades to this rather than failing, so an older client can still
814 /// read a schema registry that holds a source kind it cannot decode against
815 /// (it must not re-register one: the original `kind` and its fields are
816 /// lost). Same forward-compat shape as [`RetentionPolicy::Unknown`].
817 #[serde(other)]
818 Unknown,
819}
820
821/// One control command on the control topic. The client SDK (or any tool
822/// driving LaserData Cloud) publishes these to register projections,
823/// bindings, and schemas.
824#[derive(Clone, Debug, Serialize, Deserialize)]
825pub enum ControlCommand {
826 RegisterProjection(Projection),
827 DropProjection(String),
828 ApplyBinding(ProjectionBinding),
829 RemoveBinding {
830 source: SourceSelector,
831 projection_ref: Option<String>,
832 },
833 /// Register (or replace by `id`) a writer schema for a schema-first codec.
834 RegisterSchema(SchemaDef),
835 /// Drop the schema registered under this id.
836 DropSchema(u32),
837 /// Register (or replace by id) a graph projection: a [`Projection`] with
838 /// `kind = Graph` and an `entity_schema`. A distinct command from
839 /// [`RegisterProjection`](Self::RegisterProjection) so a deployment can gate
840 /// graph registration separately.
841 RegisterGraph(Projection),
842 /// Drop the graph projection registered under this id.
843 DropGraph(String),
844 /// Register the named session topics in one stream, or all session topics.
845 RegisterSessionSource {
846 stream: String,
847 topics: SessionTopics,
848 },
849 /// Stop folding session records from one stream.
850 RemoveSessionSource {
851 stream: String,
852 },
853 /// One consumer-filter catalog mutation, stamped by the streaming server.
854 /// Its outcome (applied or rejected) is recorded under the operation id.
855 FilterCatalog(crate::filter::FilterCatalogCommand),
856 /// An operator accepts that one source partition lost history to
857 /// retention. A projection that resumes past the gap continues, and keeps
858 /// the gap as a record that its history is incomplete. Only the managed
859 /// backend's operator path appends it. The streaming server never forwards
860 /// it from a client.
861 AcceptGap(GapAcceptance),
862 /// The managed backend skips one record that a binding's destination or extraction
863 /// deterministically rejected. Ordered on the control topic, so every
864 /// replica skips the same record. Appended by the backend itself, for a
865 /// binding that opted in or after an operator asked. The streaming server
866 /// never forwards it from a client.
867 SkipRecord(RecordSkip),
868}
869
870/// One record a binding skips, on one topic incarnation.
871#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
872pub struct RecordSkip {
873 pub source: SourceSelector,
874 pub partition_id: u32,
875 /// Creation time of the source topic incarnation. A recreated topic never
876 /// inherits a skip.
877 pub topic_created_at_micros: u64,
878 pub offset: u64,
879 /// The operational index or graph the record failed to reach. The
880 /// record's other effects still apply.
881 pub target: String,
882 /// What the destination or the extraction said about the record.
883 pub reason: String,
884}
885
886impl RecordSkip {
887 /// Whether this skip covers `offset` of `partition_id` for `target` on
888 /// the topic incarnation `generation`.
889 pub fn covers(
890 &self,
891 stream: &str,
892 topic: &str,
893 partition_id: u32,
894 generation: u64,
895 offset: u64,
896 target: &str,
897 ) -> bool {
898 self.source.stream == stream
899 && self.source.topic == topic
900 && self.partition_id == partition_id
901 && self.topic_created_at_micros == generation
902 && self.offset == offset
903 && self.target == target
904 }
905}
906
907/// The exact missing range an operator accepted for one source partition.
908#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
909pub struct GapAcceptance {
910 pub source: SourceSelector,
911 pub partition_id: u32,
912 /// Creation time of the source topic incarnation the gap belongs to. A
913 /// recreated topic needs its own acceptance.
914 pub topic_created_at_micros: u64,
915 /// First missing offset, the resume point the projection expected.
916 pub from_offset: u64,
917 /// First offset the log could still serve when the gap was accepted.
918 pub to_offset: u64,
919}
920
921impl GapAcceptance {
922 /// Whether this acceptance covers a resume at `cursor` whose first readable
923 /// record is `first_available`, on the topic incarnation `generation`.
924 pub fn covers(&self, generation: u64, cursor: u64, first_available: u64) -> bool {
925 self.topic_created_at_micros == generation
926 && self.from_offset <= cursor
927 && self.to_offset >= first_available
928 }
929}
930
931/// The topics a session-source registration covers.
932#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
933pub enum SessionTopics {
934 All,
935 Named(Vec<String>),
936}
937
938/// Versioned wrapper around a [`ControlCommand`], CBOR-named on the wire.
939#[derive(Clone, Debug, Serialize, Deserialize)]
940pub struct ControlEnvelope {
941 pub v: u32,
942 pub timestamp_micros: u64,
943 pub command: ControlCommand,
944 /// The stream the command's resource names belong to, set by a client that
945 /// scopes its resources to a stream on the projection, binding, graph, and
946 /// schema commands. The writer-schema registry keys `RegisterSchema` and
947 /// `DropSchema` by `(stream, id)` when it is set. Absent on the wire when
948 /// unset, which addresses the deployment-wide registry.
949 #[serde(default, skip_serializing_if = "Option::is_none")]
950 pub stream: Option<String>,
951 /// Identity the managed backend binds to the record before it appends it
952 /// to the control log. A client never sets it. The backend seals the whole
953 /// encoded envelope, stamp included, so a record cannot be moved to
954 /// another deployment, actor, operation or stream generation.
955 #[serde(default, skip_serializing_if = "Option::is_none")]
956 pub stamp: Option<ControlStamp>,
957}
958
959/// Who appended a control record and for which resource generation.
960#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
961pub struct ControlStamp {
962 /// The deployment whose backend appended the record. Zero for a
963 /// standalone backend without a deployment identity.
964 pub deployment_id: u64,
965 /// The authenticated user the request came from. Zero for a command the
966 /// streaming server issues itself.
967 pub actor_user_id: u32,
968 /// Stable identity of the logical control operation. A retry keeps it, so
969 /// a second append of the same operation applies once.
970 pub operation_id: u128,
971 /// The authenticated stream generation, present when the request was
972 /// scoped to a stream.
973 #[serde(default, skip_serializing_if = "Option::is_none")]
974 pub stream_generation: Option<ControlStreamGeneration>,
975 /// Identifies the sealing key without revealing it.
976 pub key_id: u64,
977}
978
979/// One incarnation of a stream: a deleted and recreated stream has a new
980/// creation time even when it reuses its name and id.
981#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
982pub struct ControlStreamGeneration {
983 pub stream_id: u32,
984 pub created_at_micros: u64,
985}
986
987#[cfg(test)]
988mod tests {
989 use super::*;
990
991 #[test]
992 fn given_a_binding_that_skips_rejected_records_when_encoded_then_should_carry_the_opt_in_only_when_set()
993 {
994 let plain = ProjectionBinding::builder().source("s", "t").build();
995 assert!(!plain.skip_rejected);
996 let plain_json = serde_json::to_value(&plain).unwrap();
997 assert!(plain_json.get("skip_rejected").is_none());
998 let opted = ProjectionBinding::builder()
999 .source("s", "t")
1000 .skip_rejected()
1001 .build();
1002 let json = serde_json::to_value(&opted).unwrap();
1003 assert_eq!(json["skip_rejected"], true);
1004 let decoded: ProjectionBinding = serde_json::from_value(json).unwrap();
1005 assert_eq!(decoded, opted);
1006 }
1007
1008 #[test]
1009 fn given_a_record_skip_when_matched_then_should_cover_only_its_exact_record() {
1010 let skip = RecordSkip {
1011 source: SourceSelector::new("s", "t"),
1012 partition_id: 2,
1013 topic_created_at_micros: 7,
1014 offset: 40,
1015 target: "rows".to_owned(),
1016 reason: "rejected".to_owned(),
1017 };
1018 assert!(skip.covers("s", "t", 2, 7, 40, "rows"));
1019 assert!(
1020 !skip.covers("s", "t", 2, 8, 40, "rows"),
1021 "another incarnation"
1022 );
1023 assert!(!skip.covers("s", "t", 2, 7, 41, "rows"));
1024 assert!(!skip.covers("s", "t", 3, 7, 40, "rows"));
1025 assert!(!skip.covers("s", "t", 2, 7, 40, "other"));
1026 assert!(!skip.covers("s", "u", 2, 7, 40, "rows"));
1027 }
1028
1029 #[test]
1030 fn given_an_unknown_schema_source_kind_when_decoded_then_should_degrade_not_fail() {
1031 // A newer server names a `kind` this build does not know. The decode
1032 // must degrade to `Unknown` (the forward-compat catch-all) rather than
1033 // failing the whole reply, and any extra fields are ignored.
1034 let source: SchemaSource =
1035 serde_json::from_str(r#"{"kind":"parquet_descriptor","blob":[1,2,3]}"#)
1036 .expect("an unknown schema kind decodes to Unknown, not an error");
1037 assert_eq!(source, SchemaSource::Unknown);
1038 // A `SchemaDef` wrapping it reports the best-effort content type.
1039 let def = SchemaDef {
1040 id: 1,
1041 source,
1042 name: None,
1043 version: None,
1044 };
1045 assert_eq!(def.content_type(), ContentType::Any);
1046 }
1047
1048 #[test]
1049 fn given_an_unknown_retention_kind_when_decoded_then_should_degrade_not_fail() {
1050 let policy: RetentionPolicy = serde_json::from_str(r#"{"kind":"keep_for_eras","eras":3}"#)
1051 .expect("an unknown retention kind decodes to Unknown, not an error");
1052 assert_eq!(policy, RetentionPolicy::Unknown);
1053 }
1054
1055 #[test]
1056 fn given_an_explicit_operational_index_when_built_then_should_keep_one_index() {
1057 let binding = ProjectionBinding::builder()
1058 .source("fleet", "readings")
1059 .allow("reading.v1")
1060 .index("readings_rows")
1061 .build();
1062 assert_eq!(binding.index, "readings_rows");
1063 assert_eq!(binding.backend, None);
1064 }
1065
1066 #[test]
1067 fn given_an_operational_backend_when_built_then_should_keep_exact_generation() {
1068 let backend = BackendBinding {
1069 resource_id: crate::destination::BackendResourceId::from_u128(7),
1070 generation: 3,
1071 };
1072 let binding = ProjectionBinding::builder()
1073 .source("fleet", "readings")
1074 .backend(backend.clone())
1075 .build();
1076 assert_eq!(binding.backend, Some(backend));
1077 }
1078
1079 #[test]
1080 fn given_no_operational_index_when_built_then_should_use_the_topic_name() {
1081 let binding = ProjectionBinding::builder()
1082 .source("fleet", "readings")
1083 .allow("reading.v1")
1084 .build();
1085 assert_eq!(binding.index, "readings");
1086 }
1087
1088 #[test]
1089 fn given_no_retention_when_built_then_should_default_to_none() {
1090 let binding = ProjectionBinding::builder()
1091 .source("fleet", "readings")
1092 .index("readings_rows")
1093 .build();
1094 assert_eq!(binding.retention, None);
1095 }
1096
1097 #[test]
1098 fn given_no_source_when_try_built_then_should_error() {
1099 assert!(ProjectionBinding::builder().try_build().is_err());
1100 }
1101
1102 #[test]
1103 fn given_a_projection_built_with_the_default_when_inspected_then_should_inline_payload() {
1104 let projection = Projection::builder("api.call.v1")
1105 .name("api.call")
1106 .version(1)
1107 .fields(["endpoint", "status"])
1108 .build();
1109 assert!(
1110 projection.inline_payload_default,
1111 "Projection::builder default should inline payload"
1112 );
1113 assert!(
1114 projection.extraction.inline_payload,
1115 "Projection::builder default should mark extraction.inline_payload too"
1116 );
1117 }
1118
1119 #[test]
1120 fn given_a_projection_with_index_only_when_inspected_then_should_skip_inlining() {
1121 let projection = Projection::builder("api.call.v1")
1122 .name("api.call")
1123 .version(1)
1124 .fields(["endpoint"])
1125 .index_only()
1126 .build();
1127 assert!(
1128 !projection.inline_payload_default,
1129 "index_only must clear inline_payload_default"
1130 );
1131 assert!(
1132 !projection.extraction.inline_payload,
1133 "index_only must clear extraction.inline_payload"
1134 );
1135 }
1136
1137 #[test]
1138 fn given_an_empty_projection_id_when_parsed_then_should_error() {
1139 assert!("".parse::<ProjectionId>().is_err());
1140 assert_eq!(
1141 "reading.v1"
1142 .parse::<ProjectionId>()
1143 .expect("non-empty id parses")
1144 .as_str(),
1145 "reading.v1"
1146 );
1147 }
1148}
1149
1150#[cfg(all(test, feature = "cbor"))]
1151mod wire_tests {
1152 use super::*;
1153 use crate::codes::CONTROL_OP_VERSION;
1154 use crate::content::ContentType;
1155 use crate::framing::{decode_named, encode_named};
1156
1157 #[test]
1158 fn given_an_apply_binding_when_round_tripped_then_should_preserve_index_and_version() {
1159 let binding = ProjectionBinding::builder()
1160 .source("fleet", "readings")
1161 .allow("reading.v1")
1162 .default_projection("reading.v1")
1163 .index("readings_rows")
1164 .build();
1165 let envelope = ControlEnvelope {
1166 v: CONTROL_OP_VERSION,
1167 timestamp_micros: 42,
1168 command: ControlCommand::ApplyBinding(binding),
1169 stream: None,
1170 stamp: None,
1171 };
1172 let bytes = encode_named(&envelope).expect("envelope serializes");
1173 let back: ControlEnvelope = decode_named(&bytes).expect("envelope deserializes");
1174 assert_eq!(back.v, CONTROL_OP_VERSION);
1175 let ControlCommand::ApplyBinding(decoded) = back.command else {
1176 panic!("expected ApplyBinding");
1177 };
1178 assert_eq!(decoded.index, "readings_rows");
1179 assert_eq!(decoded.backend, None);
1180 // Retention left unset stays unset across the wire (inherits LaserData Cloud
1181 // default), and is omitted from the encoding entirely.
1182 assert_eq!(decoded.retention, None);
1183 }
1184
1185 #[test]
1186 fn given_a_register_schema_when_round_tripped_then_should_preserve_source() {
1187 let envelope = ControlEnvelope {
1188 v: CONTROL_OP_VERSION,
1189 timestamp_micros: 7,
1190 command: ControlCommand::RegisterSchema(SchemaDef {
1191 id: 11,
1192 source: SchemaSource::Avro {
1193 schema: r#"{"type":"record","name":"Reading","fields":[]}"#.to_owned(),
1194 },
1195 name: None,
1196 version: None,
1197 }),
1198 stream: None,
1199 stamp: None,
1200 };
1201 let bytes = encode_named(&envelope).expect("envelope serializes");
1202 let back: ControlEnvelope = decode_named(&bytes).expect("envelope deserializes");
1203 assert_eq!(back.stream, None);
1204 let ControlCommand::RegisterSchema(decoded) = back.command else {
1205 panic!("expected RegisterSchema");
1206 };
1207 assert_eq!(decoded.id, 11);
1208 assert_eq!(decoded.content_type(), ContentType::Avro);
1209 }
1210
1211 #[test]
1212 fn given_a_stream_scoped_drop_schema_when_round_tripped_then_should_preserve_the_stream() {
1213 let envelope = ControlEnvelope {
1214 v: CONTROL_OP_VERSION,
1215 timestamp_micros: 7,
1216 command: ControlCommand::DropSchema(11),
1217 stream: Some("acme".to_owned()),
1218 stamp: None,
1219 };
1220 let back: ControlEnvelope =
1221 decode_named(&encode_named(&envelope).expect("envelope serializes"))
1222 .expect("envelope deserializes");
1223 assert_eq!(back.stream.as_deref(), Some("acme"));
1224 assert!(matches!(back.command, ControlCommand::DropSchema(11)));
1225 let unscoped = ControlEnvelope {
1226 stream: None,
1227 ..envelope
1228 };
1229 let json = serde_json::to_string(&unscoped).expect("envelope serializes");
1230 assert!(
1231 !json.contains("stream"),
1232 "an unset stream is omitted: {json}"
1233 );
1234 }
1235
1236 #[test]
1237 fn given_a_stamped_envelope_when_round_tripped_then_should_keep_the_identity_and_omit_an_unset_stamp()
1238 {
1239 let envelope = ControlEnvelope {
1240 v: CONTROL_OP_VERSION,
1241 timestamp_micros: 7,
1242 command: ControlCommand::DropSchema(11),
1243 stream: Some("acme".to_owned()),
1244 stamp: Some(ControlStamp {
1245 deployment_id: 42,
1246 actor_user_id: 3,
1247 operation_id: u128::MAX - 1,
1248 stream_generation: Some(ControlStreamGeneration {
1249 stream_id: 9,
1250 created_at_micros: 1_700_000_000_000_000,
1251 }),
1252 key_id: u64::MAX,
1253 }),
1254 };
1255 let back: ControlEnvelope =
1256 decode_named(&encode_named(&envelope).expect("envelope serializes"))
1257 .expect("envelope deserializes");
1258 assert_eq!(back.stamp, envelope.stamp);
1259 let unstamped = ControlEnvelope {
1260 stamp: None,
1261 ..envelope
1262 };
1263 let json = serde_json::to_string(&unstamped).expect("envelope serializes");
1264 assert!(
1265 !json.contains("\"stamp\""),
1266 "an unset stamp is omitted: {json}"
1267 );
1268 }
1269
1270 #[test]
1271 fn given_a_protobuf_schema_source_when_round_tripped_then_should_preserve_bytes() {
1272 let source = SchemaSource::Protobuf {
1273 descriptor_set: vec![10, 20, 30],
1274 message_type: "fleet.Reading".to_owned(),
1275 };
1276 let bytes = encode_named(&source).expect("serializes");
1277 let back: SchemaSource = decode_named(&bytes).expect("deserializes");
1278 assert_eq!(back, source);
1279 }
1280
1281 #[test]
1282 fn given_retention_set_when_round_tripped_then_should_preserve_policy() {
1283 for policy in [
1284 // Explicit `MirrorLog` is the default *value* but not the default
1285 // *state* (`None`). Set explicitly it must survive as `Some(MirrorLog)`,
1286 // distinct from the unset binding that omits the field entirely.
1287 RetentionPolicy::MirrorLog,
1288 RetentionPolicy::Keep,
1289 RetentionPolicy::KeepUntilSourceDeleted,
1290 RetentionPolicy::TimeToLive {
1291 ttl_micros: 3_600_000_000,
1292 },
1293 RetentionPolicy::MaxRows { rows: 10_000 },
1294 ] {
1295 let binding = ProjectionBinding::builder()
1296 .source("fleet", "telemetry")
1297 .allow("telemetry.v1")
1298 .index("telemetry_rows")
1299 .retention(policy)
1300 .build();
1301 assert_eq!(binding.retention, Some(policy));
1302 let bytes = encode_named(&binding).expect("binding serializes");
1303 let back: ProjectionBinding = decode_named(&bytes).expect("binding deserializes");
1304 assert_eq!(back.retention, Some(policy));
1305 }
1306 }
1307}
1308
1309#[cfg(all(test, feature = "codecs"))]
1310mod schema_tests {
1311 use super::*;
1312 use crate::framing::{decode_named, encode_named};
1313
1314 #[test]
1315 fn given_a_schema_def_with_version_when_round_tripped_then_should_preserve_it() {
1316 let def = SchemaDef {
1317 id: 7,
1318 source: SchemaSource::Avro {
1319 schema: "{}".to_owned(),
1320 },
1321 name: Some("readings".to_owned()),
1322 version: Some(2),
1323 };
1324 let bytes = encode_named(&def).expect("serializes");
1325 let back: SchemaDef = decode_named(&bytes).expect("deserializes");
1326 assert_eq!(back.version, Some(2));
1327 // Unset version is omitted from the wire entirely (back-compat with
1328 // pre-version registries).
1329 let unversioned = SchemaDef {
1330 name: None,
1331 version: None,
1332 ..def
1333 };
1334 let json = serde_json::to_string(&unversioned).expect("serializes");
1335 assert!(
1336 !json.contains("version"),
1337 "unset version must be omitted: {json}"
1338 );
1339 }
1340}