Skip to main content

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 plane 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 plane 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    /// plane 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 plane 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 plane 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 plane 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}