Skip to main content

laser_wire/
control.rs

1use crate::content::ContentType;
2use crate::destination::BackendBinding;
3use crate::error::InvalidError;
4use crate::limits::{
5    MAX_FILTER_SCHEMA_BYTES, MAX_FILTER_SCHEMA_DEPTH, MAX_GRAPH_LABEL_BYTES, MAX_INDEX_FIELD_BYTES,
6    MAX_PROJECTION_ID_BYTES, MAX_PROJECTION_NAME_BYTES, MAX_SCHEMA_NAME_BYTES,
7};
8use crate::validate::{Validate, json_depth_within, validate_bounded_text};
9use serde::{Deserialize, Serialize};
10use std::str::FromStr;
11
12/// Prefix no projection id may start with: activity keys an index-only
13/// binding under it, so it must never name a projection.
14pub const RESERVED_PROJECTION_ID_PREFIX: &str = "index:";
15
16/// The one JSON Schema dialect the registry accepts, named or implied.
17pub const JSON_SCHEMA_DIALECT: &str = "https://json-schema.org/draft/2020-12/schema";
18
19/// One indexed scalar: the index key `name` and the RFC-6901 JSON `pointer` into
20/// the payload it is extracted from.
21///
22/// **Why declared-once extraction exists.** Stamping `agdx.idx.host_id=node-7`
23/// on every Iggy message duplicates the field name on the wire and couples the
24/// producer to projection details. With a schema declared once on the
25/// projector, producers just push the payload (single or batched) and the
26/// worker derives the index from the body. The `agdx.idx.*` header path remains
27/// as the escape hatch for raw payloads and for schema-first bodies whose
28/// writer schema is not registered. Explicit headers always win over
29/// schema-extracted values for the same field name.
30#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
31pub struct IndexField {
32    pub name: String,
33    pub pointer: String,
34    /// Optional field type: the query result type of the field, a stored value
35    /// that does not fit reading as null, and the column type of a wide-column
36    /// backend. Unset, the field is a `variant` and keeps each stored type.
37    #[serde(default, skip_serializing_if = "Option::is_none")]
38    pub field_type: Option<FieldType>,
39}
40
41impl IndexField {
42    /// An indexed field `name` extracted from the payload at JSON `pointer`.
43    pub fn new(name: impl Into<String>, pointer: impl Into<String>) -> Self {
44        Self {
45            name: name.into(),
46            pointer: pointer.into(),
47            field_type: None,
48        }
49    }
50
51    /// An indexed field with a type its query results carry.
52    pub fn typed(
53        name: impl Into<String>,
54        pointer: impl Into<String>,
55        field_type: FieldType,
56    ) -> Self {
57        Self {
58            name: name.into(),
59            pointer: pointer.into(),
60            field_type: Some(field_type),
61        }
62    }
63}
64
65/// The type of an indexed field. The projector stores whatever scalar the
66/// payload carries, and a query reads the field as this type.
67#[derive(
68    Clone,
69    Copy,
70    Debug,
71    PartialEq,
72    Eq,
73    Serialize,
74    Deserialize,
75    strum::Display,
76    strum::EnumString,
77    strum::VariantArray,
78)]
79#[serde(rename_all = "snake_case")]
80#[strum(serialize_all = "snake_case")]
81#[non_exhaustive]
82pub enum FieldType {
83    Text,
84    Int,
85    Float,
86    Bool,
87}
88
89/// The indexed fields (and optional vector field) a projection extracts.
90#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
91pub struct IndexSchema {
92    pub fields: Vec<IndexField>,
93    // Optional JSON pointer to a vector field (`[f32]`) inside the payload.
94    // Default is `/embedding` at the root, which matches what producers
95    // writing JSON bodies produce today.
96    #[serde(default, skip_serializing_if = "Option::is_none")]
97    pub vector_field: Option<String>,
98    // Whether the projector inlines the payload bytes alongside the row by
99    // default. The producer can still override per record, but a sensible
100    // default means batch-publishers do not need to stamp the directive on
101    // every message.
102    #[serde(default)]
103    pub inline_payload: bool,
104}
105
106impl IndexSchema {
107    /// Start building an index schema.
108    pub fn builder() -> IndexSchemaBuilder {
109        IndexSchemaBuilder::default()
110    }
111}
112
113/// Fluent builder for an `IndexSchema`.
114#[derive(Default)]
115pub struct IndexSchemaBuilder {
116    schema: IndexSchema,
117}
118
119impl IndexSchemaBuilder {
120    /// Index the JSON value at the root-level field `name`. The index key and
121    /// the JSON path are the same in the common case, so `field("host")`
122    /// expands to "extract `/host` from the payload and store it under
123    /// the index key `host`". For nested or renamed extraction use
124    /// [`field_at`](Self::field_at).
125    pub fn field(mut self, name: impl Into<String>) -> Self {
126        let name = name.into();
127        let pointer = format!("/{name}");
128        self.schema.fields.push(IndexField::new(name, pointer));
129        self
130    }
131
132    /// Index the JSON value at `pointer` (RFC-6901) under the index key
133    /// `name`. Use when the index column name differs from the payload field,
134    /// or when the value lives in a nested structure -
135    /// `field_at("cpu_pct", "/cpu/value")` indexes `cpu.value` as
136    /// `cpu_pct`.
137    pub fn field_at(mut self, name: impl Into<String>, pointer: impl Into<String>) -> Self {
138        self.schema.fields.push(IndexField::new(name, pointer));
139        self
140    }
141
142    /// Point the vector extractor at `pointer` instead of the default
143    /// (`/embedding` at the payload root). Pointer is RFC-6901.
144    pub fn vector_field(mut self, pointer: impl Into<String>) -> Self {
145        self.schema.vector_field = Some(pointer.into());
146        self
147    }
148
149    /// Default `inline_payload = true` for every record projected through this
150    /// schema. Per-record overrides stay valid.
151    pub fn inline_payload(mut self) -> Self {
152        self.schema.inline_payload = true;
153        self
154    }
155
156    /// Finish the index schema.
157    pub fn build(self) -> IndexSchema {
158        self.schema
159    }
160}
161
162/// Opaque projection identifier. Stable string the producer stamps on the
163/// wire via the `agdx.ref` header and the worker keys its catalog by.
164/// Recommended shape: `"<name>.v<version>"`, e.g. `"reading.v1"`. Distinct from
165/// `schema_id`, which selects a codec's writer schema rather than a
166/// materialization rule.
167#[derive(Clone, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
168#[serde(transparent)]
169pub struct ProjectionId(String);
170
171impl ProjectionId {
172    /// A projection id from a string.
173    pub fn new(value: impl Into<String>) -> Self {
174        Self(value.into())
175    }
176
177    /// The id as a string slice.
178    pub fn as_str(&self) -> &str {
179        &self.0
180    }
181}
182
183impl std::fmt::Display for ProjectionId {
184    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
185        f.write_str(&self.0)
186    }
187}
188
189impl AsRef<str> for ProjectionId {
190    fn as_ref(&self) -> &str {
191        &self.0
192    }
193}
194
195impl std::borrow::Borrow<str> for ProjectionId {
196    fn borrow(&self) -> &str {
197        &self.0
198    }
199}
200
201impl FromStr for ProjectionId {
202    type Err = InvalidError;
203
204    fn from_str(s: &str) -> Result<Self, Self::Err> {
205        validate_projection_id(s)?;
206        Ok(Self(s.to_owned()))
207    }
208}
209
210impl From<&str> for ProjectionId {
211    fn from(value: &str) -> Self {
212        Self(value.to_owned())
213    }
214}
215
216impl From<String> for ProjectionId {
217    fn from(value: String) -> Self {
218        Self(value)
219    }
220}
221
222#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
223#[serde(tag = "kind", rename_all = "snake_case")]
224pub enum ProjectionHistory {
225    #[default]
226    Append,
227    Keyed {
228        key: ProjectionKey,
229        /// The header whose presence deletes the record's key. Without one the view never deletes.
230        #[serde(default, skip_serializing_if = "Option::is_none")]
231        delete_header: Option<String>,
232    },
233}
234
235impl ProjectionHistory {
236    pub fn is_append(&self) -> bool {
237        matches!(self, Self::Append)
238    }
239}
240
241#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
242#[serde(tag = "kind", rename_all = "snake_case")]
243pub enum ProjectionKey {
244    Header { name: String },
245    Pointer { pointer: String },
246}
247
248/// What a projection materializes: queryable rows, or a knowledge graph of
249/// nodes and edges. Rides the wire as a u8 code (the growable-dictionary pattern)
250/// so a future kind flows through an old reader as
251/// [`Unrecognized`](Self::Unrecognized) rather than failing the listing.
252#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
253#[serde(from = "u8", into = "u8")]
254pub enum ProjectionKind {
255    /// Materialize queryable rows (the default).
256    #[default]
257    Row,
258    /// Materialize a knowledge graph (nodes, edges, triplets).
259    Graph,
260    /// Materialize every routed record whole: its body, headers and source
261    /// position, with no extraction.
262    Record,
263    /// A code this build does not know, passed through.
264    Unrecognized(u8),
265}
266
267impl ProjectionKind {
268    /// The pinned wire code.
269    pub const fn code(self) -> u8 {
270        match self {
271            ProjectionKind::Row => 0,
272            ProjectionKind::Graph => 1,
273            ProjectionKind::Record => 2,
274            ProjectionKind::Unrecognized(code) => code,
275        }
276    }
277
278    /// The kind for a wire code (unknown codes become
279    /// [`Unrecognized`](Self::Unrecognized)).
280    pub const fn from_code(code: u8) -> Self {
281        match code {
282            0 => ProjectionKind::Row,
283            1 => ProjectionKind::Graph,
284            2 => ProjectionKind::Record,
285            other => ProjectionKind::Unrecognized(other),
286        }
287    }
288
289    /// Whether this is the default `Row` kind (omitted on the wire).
290    pub const fn is_row(&self) -> bool {
291        matches!(self, ProjectionKind::Row)
292    }
293}
294
295impl From<u8> for ProjectionKind {
296    fn from(code: u8) -> Self {
297        Self::from_code(code)
298    }
299}
300
301impl From<ProjectionKind> for u8 {
302    fn from(kind: ProjectionKind) -> u8 {
303        kind.code()
304    }
305}
306
307/// The declared plan for extracting nodes and edges from a payload. Pointer-based
308/// (RFC 6901) and deterministic, so no model call is needed. Recorded on a graph
309/// projection for discovery and as the extraction contract. Graph data is written
310/// via the graph upsert op. Applying this plan to a bound source is managed-side.
311#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
312pub struct EntitySchema {
313    pub nodes: Vec<NodeExtract>,
314    #[serde(default, skip_serializing_if = "Vec::is_empty")]
315    pub edges: Vec<EdgeExtract>,
316}
317
318/// One node-extraction rule: the node's label and the pointer to its canonical
319/// value (which content-addresses its id, so the same entity converges).
320#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
321pub struct NodeExtract {
322    pub label: String,
323    pub value_pointer: String,
324    #[serde(default, skip_serializing_if = "Option::is_none")]
325    pub embedding_pointer: Option<String>,
326}
327
328/// One edge-extraction rule: the edge type, the pointers to its endpoint values,
329/// and optional pointers to the bitemporal valid-time window.
330#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
331pub struct EdgeExtract {
332    pub edge_type: String,
333    pub from_pointer: String,
334    pub to_pointer: String,
335    /// RFC-6901 pointer to the valid-time start (epoch micros) for the extracted
336    /// edge, making it a bitemporal fact. `None` leaves the window open.
337    #[serde(default, skip_serializing_if = "Option::is_none")]
338    pub valid_from_pointer: Option<String>,
339    /// RFC-6901 pointer to the valid-time end (epoch micros). `None` leaves the
340    /// edge open-ended (still valid).
341    #[serde(default, skip_serializing_if = "Option::is_none")]
342    pub valid_to_pointer: Option<String>,
343}
344
345/// Global reusable projection definition. Names the extraction rules that turn
346/// a payload into a queryable row. Not attached to a topic on its own:
347/// bindings ([`ProjectionBinding`]) declare where projections may apply.
348///
349/// # Storage model
350///
351/// A published record lives in up to three places, controlled by the projection:
352///
353/// 1. **Iggy log** (always): the original wire bytes, partitioned, replayable
354///    from offset 0. The source of truth.
355/// 2. **Indexed columns** (always): the scalar fields declared via
356///    `field` / `field_at`, extracted from the payload at materialize time
357///    and stored in the row. These drive filters, ordering, and aggregates.
358/// 3. **Inline body** (opt-in via `inline_payload`, on by default through
359///    `Projection::builder`): a copy of the full original payload alongside
360///    the row, so typed fetches can decode it without going back to the log.
361///
362/// The body may carry fields that are not indexed. Only the declared fields
363/// are queryable. Everything else rides through as part of the inlined body
364/// (when on) or is reachable only by Iggy replay (when off).
365#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
366pub struct Projection {
367    /// Stable id used on the wire as `agdx.ref`.
368    pub id: ProjectionId,
369    /// Human-readable name (e.g. `"reading"`). Distinct from `id` so the same
370    /// logical projection can rename without breaking the wire ref.
371    pub name: String,
372    /// Schema-evolution version. Bump on incompatible changes. The wire id
373    /// usually encodes this (`reading.v2`).
374    pub version: u32,
375    /// What this projection materializes: rows (default) or a graph.
376    #[serde(default, skip_serializing_if = "ProjectionKind::is_row")]
377    pub kind: ProjectionKind,
378    #[serde(default, skip_serializing_if = "ProjectionHistory::is_append")]
379    pub history: ProjectionHistory,
380    /// Expected payload codec. `Any` means best-effort decode: the extraction
381    /// plan tolerates an opaque payload.
382    pub content_type: ContentType,
383    /// Field extraction plan.
384    pub extraction: IndexSchema,
385    /// Node/edge extraction plan for a `Graph` projection. `None` for a row
386    /// projection.
387    #[serde(default, skip_serializing_if = "Option::is_none")]
388    pub entity_schema: Option<EntitySchema>,
389    /// Default `inline_payload` for records routed through this projection,
390    /// overridable per record.
391    #[serde(default)]
392    pub inline_payload_default: bool,
393}
394
395impl Projection {
396    /// Start building a projection with this id.
397    pub fn builder(id: impl Into<ProjectionId>) -> ProjectionBuilder {
398        ProjectionBuilder {
399            projection: Self {
400                id: id.into(),
401                name: String::new(),
402                version: 1,
403                kind: ProjectionKind::Row,
404                history: ProjectionHistory::Append,
405                content_type: ContentType::Any,
406                // Default: inline the body alongside the indexed row so typed
407                // fetches can decode it without going back to the Iggy log.
408                // Opt out with `.index_only()` for high-volume projections
409                // where duplication is not worth it.
410                extraction: IndexSchema {
411                    fields: Vec::new(),
412                    vector_field: None,
413                    inline_payload: true,
414                },
415                entity_schema: None,
416                inline_payload_default: true,
417            },
418        }
419    }
420}
421
422/// Fluent builder for a `Projection`.
423pub struct ProjectionBuilder {
424    projection: Projection,
425}
426
427impl ProjectionBuilder {
428    /// Keep the current row of each key, deleted by a record carrying `delete_header` when one is named.
429    pub fn keyed(mut self, key: ProjectionKey, delete_header: Option<&str>) -> Self {
430        self.projection.history = ProjectionHistory::Keyed {
431            key,
432            delete_header: delete_header.map(str::to_owned),
433        };
434        self
435    }
436
437    /// Set the human-readable projection name.
438    pub fn name(mut self, value: impl Into<String>) -> Self {
439        self.projection.name = value.into();
440        self
441    }
442
443    /// Set the projection version.
444    pub fn version(mut self, value: u32) -> Self {
445        self.projection.version = value;
446        self
447    }
448
449    /// Set the wire codec the projector decodes records with.
450    pub fn content_type(mut self, value: ContentType) -> Self {
451        self.projection.content_type = value;
452        self
453    }
454
455    /// Set the full index schema (instead of `field`/`vector_field`).
456    pub fn extraction(mut self, value: IndexSchema) -> Self {
457        self.projection.extraction = value;
458        self
459    }
460
461    /// Make this a whole-record projection: every routed record materializes
462    /// with its body, headers and source position, and nothing is extracted.
463    pub fn record(mut self) -> Self {
464        self.projection.kind = ProjectionKind::Record;
465        self.projection.extraction = IndexSchema::default();
466        self.projection.entity_schema = None;
467        self.projection.inline_payload_default = false;
468        self
469    }
470
471    /// Make this a graph projection with the given node/edge extraction plan.
472    pub fn graph(mut self, schema: EntitySchema) -> Self {
473        self.projection.kind = ProjectionKind::Graph;
474        self.projection.entity_schema = Some(schema);
475        self
476    }
477
478    /// Index a field by name (extracted at its matching JSON pointer).
479    pub fn field(mut self, name: impl Into<String>) -> Self {
480        let name = name.into();
481        let pointer = format!("/{name}");
482        self.projection
483            .extraction
484            .fields
485            .push(IndexField::new(name, pointer));
486        self
487    }
488
489    /// Bulk version of [`field`](Self::field): declare many top-level indexed
490    /// fields in one call. Each name `n` lands at JSON pointer `/n`. For nested
491    /// pointers use [`field_at`](Self::field_at) instead.
492    pub fn fields<I, S>(mut self, names: I) -> Self
493    where
494        I: IntoIterator<Item = S>,
495        S: Into<String>,
496    {
497        for name in names {
498            let name = name.into();
499            let pointer = format!("/{name}");
500            self.projection
501                .extraction
502                .fields
503                .push(IndexField::new(name, pointer));
504        }
505        self
506    }
507
508    /// Index a field `name` from an explicit JSON `pointer`.
509    pub fn field_at(mut self, name: impl Into<String>, pointer: impl Into<String>) -> Self {
510        self.projection
511            .extraction
512            .fields
513            .push(IndexField::new(name, pointer));
514        self
515    }
516
517    /// Like [`field`](Self::field) but with a storage-type hint for columnar
518    /// backends (`FieldType::Int`, `Float`, `Bool`, `Text`). The embedded
519    /// engine ignores the hint.
520    pub fn field_typed(mut self, name: impl Into<String>, field_type: FieldType) -> Self {
521        let name = name.into();
522        let pointer = format!("/{name}");
523        self.projection
524            .extraction
525            .fields
526            .push(IndexField::typed(name, pointer, field_type));
527        self
528    }
529
530    /// Like [`field_at`](Self::field_at) but with a storage-type hint.
531    pub fn field_at_typed(
532        mut self,
533        name: impl Into<String>,
534        pointer: impl Into<String>,
535        field_type: FieldType,
536    ) -> Self {
537        self.projection
538            .extraction
539            .fields
540            .push(IndexField::typed(name, pointer, field_type));
541        self
542    }
543
544    /// Extract the embedding vector from this JSON pointer.
545    pub fn vector_field(mut self, pointer: impl Into<String>) -> Self {
546        self.projection.extraction.vector_field = Some(pointer.into());
547        self
548    }
549
550    /// Inline the original payload alongside the materialized row. This is
551    /// the default for projections built through `Projection::builder`, so
552    /// calling it is redundant. It stays in the API for callers who want to be
553    /// explicit. To opt out, use [`index_only`](Self::index_only).
554    pub fn inline_payload(mut self) -> Self {
555        self.projection.inline_payload_default = true;
556        self.projection.extraction.inline_payload = true;
557        self
558    }
559
560    /// Opt out of inlining the body. The materialized row keeps only the
561    /// indexed scalars declared via `field` / `field_at` (and any vector
562    /// extracted via `vector_field`). The full payload is not duplicated into
563    /// the index. Use this when:
564    ///
565    /// - the body is large and you already store it elsewhere (object store,
566    ///   OLAP table) and only need a fast queryable secondary index, or
567    /// - you are pushing extreme throughput and the index DB cost of a body
568    ///   copy is not worth it.
569    ///
570    /// The Iggy log retains the original bytes either way, so a future
571    /// projector rebuild can re-inline. With `index_only`, typed fetches
572    /// return rows whose `payload` is `None`. Callers either decode from the
573    /// indexed columns or replay from the log.
574    pub fn index_only(mut self) -> Self {
575        self.projection.inline_payload_default = false;
576        self.projection.extraction.inline_payload = false;
577        self
578    }
579
580    /// Finish the projection.
581    pub fn build(self) -> Projection {
582        self.projection
583    }
584}
585
586/// How long a binding's materialized rows live, **decoupled from the source
587/// topic's Iggy `message_expiry`**. Lets a projection outlive (or undershoot)
588/// the log it was built from.
589///
590/// The canonical case: a topic with aggressive expiry (short-lived partitions
591/// for cheap storage and fast replay) whose derived index must survive forever.
592/// Set the binding to [`RetentionPolicy::Keep`].
593#[derive(Clone, Copy, Debug, PartialEq, Eq, Default, Serialize, Deserialize)]
594#[serde(tag = "kind", rename_all = "snake_case")]
595#[non_exhaustive]
596pub enum RetentionPolicy {
597    /// Follow the log: rows are pruned once Iggy drops the messages that
598    /// produced them. The default. Like `KeepUntilSourceDeleted`, it also
599    /// deletes the projection when the source topic is deleted.
600    #[default]
601    MirrorLog,
602    /// Keep rows forever, regardless of the source log *or its deletion*.
603    Keep,
604    /// Keep rows forever while the source topic exists, but drop the whole
605    /// projection when the source topic is **deleted**. Ignores message expiry
606    /// like `Keep`, but is tied to the topic's existence like `MirrorLog`.
607    KeepUntilSourceDeleted,
608    /// Keep rows for `ttl_micros` after they were materialized, independent of
609    /// the log. Older rows are swept, and survivors outlive an expired source.
610    TimeToLive { ttl_micros: u64 },
611    /// Keep the newest `rows` rows for the target table, independent of the log.
612    MaxRows { rows: u64 },
613    /// A policy `kind` a newer server named that this build does not know. The
614    /// decode degrades to this rather than failing the whole reply, so an older
615    /// client keeps reading bindings it cannot fully interpret (it must not
616    /// re-apply one: re-serializing loses the original `kind` and its fields).
617    /// The same forward-compat shape the `ResultCode` and u8 dictionaries use.
618    #[serde(other)]
619    Unknown,
620}
621
622/// Declares where a `Projection` is allowed to materialize. Bindings, not
623/// projections, tell the worker what to consume. A binding pairs a source
624/// selector (stream and topic) with a set of allowed projection refs, an
625/// optional default, and one operational index. The index uses the replica-local
626/// backend unless an exact backend resource generation is bound.
627#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
628pub struct ProjectionBinding {
629    pub source: SourceSelector,
630    /// Projections allowed to fire on messages from this source. A record
631    /// stamped with an `agdx.ref` outside this set falls back to the default
632    /// projection, and is skipped when there is none.
633    #[serde(default)]
634    pub allowed_projections: Vec<ProjectionId>,
635    /// Default projection applied to records that do not carry an `agdx.ref`.
636    /// `None` means such records are skipped.
637    #[serde(default)]
638    pub default_projection: Option<ProjectionId>,
639    /// Exact operational backend resource generation. `None` selects the
640    /// replica-local backend.
641    #[serde(default, skip_serializing_if = "Option::is_none")]
642    pub backend: Option<BackendBinding>,
643    /// Operational index name served by `QueryTarget::Operational`.
644    pub index: String,
645    /// Opt this binding into the change feed: after each committed projector
646    /// batch the managed backend publishes one `ChangeRecord` on the changes topic.
647    /// Default off and skipped on the wire, so a deployment that never opts in
648    /// emits nothing and a pre-feed binding encodes byte-identically.
649    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
650    pub notify: bool,
651    /// Row lifetime for this binding, independent of the source topic's Iggy
652    /// retention. `None` inherits LaserData Cloud's fleet-wide default
653    /// (`MirrorLog` unless an operator changed it). Set it via
654    /// [`retention`](ProjectionBindingBuilder::retention).
655    #[serde(default, skip_serializing_if = "Option::is_none")]
656    pub retention: Option<RetentionPolicy>,
657    /// What happens to a record the destination or the extraction
658    /// deterministically rejects. `Block`, the default, holds the partition at
659    /// the record until an operator skips it. `DeadLetter` skips it through an
660    /// ordered control record, so every replica skips the same record, and
661    /// sends it to the dead-letter topic. A destination rejection counts only
662    /// after the destination accepted other records of the batch and refused
663    /// this one twice. Skipped on the wire at its default.
664    #[serde(default, skip_serializing_if = "InvalidRecordPolicy::is_block")]
665    pub invalid_records: InvalidRecordPolicy,
666    /// What a whole-record binding does with a body larger than its backend
667    /// stores. `Block`, the default, holds the partition at the record.
668    /// `Reference` keeps the row without the body, which the source log still
669    /// holds, and requires `MirrorLog` retention. Skipped on the wire at its
670    /// default.
671    #[serde(default, skip_serializing_if = "OversizePolicy::is_block")]
672    pub oversize: OversizePolicy,
673}
674
675/// What a binding does with a record it proved invalid.
676#[derive(
677    Clone,
678    Copy,
679    Debug,
680    Default,
681    PartialEq,
682    Eq,
683    Serialize,
684    Deserialize,
685    strum::Display,
686    strum::EnumString,
687)]
688#[serde(rename_all = "snake_case")]
689#[strum(serialize_all = "snake_case")]
690#[non_exhaustive]
691pub enum InvalidRecordPolicy {
692    /// Hold the partition at the record until an operator skips it.
693    #[default]
694    Block,
695    /// Skip the record and send it to the dead-letter topic.
696    DeadLetter,
697    /// A policy a newer server named that this build does not know.
698    #[serde(other)]
699    #[strum(disabled)]
700    Unknown,
701}
702
703impl InvalidRecordPolicy {
704    /// Whether this is the default `Block` policy (omitted on the wire).
705    pub fn is_block(&self) -> bool {
706        matches!(self, Self::Block)
707    }
708}
709
710/// What a whole-record binding does with a body its backend cannot store.
711#[derive(
712    Clone,
713    Copy,
714    Debug,
715    Default,
716    PartialEq,
717    Eq,
718    Serialize,
719    Deserialize,
720    strum::Display,
721    strum::EnumString,
722)]
723#[serde(rename_all = "snake_case")]
724#[strum(serialize_all = "snake_case")]
725#[non_exhaustive]
726pub enum OversizePolicy {
727    /// Hold the partition at the record.
728    #[default]
729    Block,
730    /// Keep the row without its body, which the source log still holds.
731    Reference,
732    /// A policy a newer server named that this build does not know.
733    #[serde(other)]
734    #[strum(disabled)]
735    Unknown,
736}
737
738impl OversizePolicy {
739    /// Whether this is the default `Block` policy (omitted on the wire).
740    pub fn is_block(&self) -> bool {
741        matches!(self, Self::Block)
742    }
743}
744
745impl ProjectionBinding {
746    /// Start building a binding (which topic feeds which projection).
747    pub fn builder() -> ProjectionBindingBuilder {
748        ProjectionBindingBuilder::default()
749    }
750}
751
752/// Fluent builder for a `ProjectionBinding`.
753#[derive(Default)]
754pub struct ProjectionBindingBuilder {
755    source: Option<SourceSelector>,
756    allowed: Vec<ProjectionId>,
757    default_projection: Option<ProjectionId>,
758    backend: Option<BackendBinding>,
759    index: Option<String>,
760    notify: bool,
761    retention: Option<RetentionPolicy>,
762    invalid_records: InvalidRecordPolicy,
763    oversize: OversizePolicy,
764}
765
766impl ProjectionBindingBuilder {
767    /// Set the binding source. A source is always a `(stream, topic)` pair,
768    /// and a topic only exists within a stream, so both are required.
769    pub fn source(mut self, stream: impl Into<String>, topic: impl Into<String>) -> Self {
770        self.source = Some(SourceSelector::new(stream, topic));
771        self
772    }
773
774    /// Set the binding source from a pre-built [`SourceSelector`].
775    pub fn selector(mut self, source: SourceSelector) -> Self {
776        self.source = Some(source);
777        self
778    }
779
780    /// Allow records to route to this projection.
781    pub fn allow(mut self, projection: impl Into<ProjectionId>) -> Self {
782        self.allowed.push(projection.into());
783        self
784    }
785
786    /// Projection used when a record carries no `agdx.ref`.
787    pub fn default_projection(mut self, projection: impl Into<ProjectionId>) -> Self {
788        self.default_projection = Some(projection.into());
789        self
790    }
791
792    /// Bind the operational index to an exact backend resource generation.
793    /// Omit this for the replica-local backend.
794    pub fn backend(mut self, backend: BackendBinding) -> Self {
795        self.backend = Some(backend);
796        self
797    }
798
799    /// Set how long the materialized rows live, independent of the source
800    /// topic's Iggy retention. Omit to inherit the managed backend's default.
801    ///
802    /// ```no_run
803    /// # use laser_wire::control::{ProjectionBinding, RetentionPolicy};
804    /// // Short-lived topic, permanent index:
805    /// let binding = ProjectionBinding::builder()
806    ///     .source("_agdx", "telemetry")
807    ///     .allow("telemetry.v1")
808    ///     .index("telemetry_rows")
809    ///     .retention(RetentionPolicy::Keep)
810    ///     .build();
811    /// ```
812    pub fn retention(mut self, retention: RetentionPolicy) -> Self {
813        self.retention = Some(retention);
814        self
815    }
816
817    /// Set the operational index name. Omit it to use the source topic name.
818    pub fn index(mut self, index: impl Into<String>) -> Self {
819        self.index = Some(index.into());
820        self
821    }
822
823    /// Opt into the change feed: one `ChangeRecord` per committed projector
824    /// batch for this binding, on the changes topic.
825    #[must_use]
826    pub fn notify(mut self) -> Self {
827        self.notify = true;
828        self
829    }
830
831    /// Set what happens to a record the binding proved invalid. See
832    /// [`ProjectionBinding::invalid_records`].
833    #[must_use]
834    pub fn invalid_records(mut self, policy: InvalidRecordPolicy) -> Self {
835        self.invalid_records = policy;
836        self
837    }
838
839    /// Set what a whole-record binding does with an oversized body. See
840    /// [`ProjectionBinding::oversize`].
841    #[must_use]
842    pub fn oversize(mut self, policy: OversizePolicy) -> Self {
843        self.oversize = policy;
844        self
845    }
846
847    /// Build the binding. Panics if `.source(..)` / `.selector(..)` was not set
848    /// (programmer error, not user input). Prefer [`try_build`](Self::try_build)
849    /// for code paths that handle config-load failures gracefully.
850    pub fn build(self) -> ProjectionBinding {
851        self.try_build()
852            .expect("ProjectionBinding requires a source - call .source(stream, topic)")
853    }
854
855    /// Build the binding, returning an error if required fields are missing
856    /// instead of panicking.
857    pub fn try_build(self) -> Result<ProjectionBinding, InvalidError> {
858        let source = self
859            .source
860            .ok_or_else(|| InvalidError::new("ProjectionBinding requires a source"))?;
861        let index = self.index.unwrap_or_else(|| source.topic.clone());
862        Ok(ProjectionBinding {
863            source,
864            allowed_projections: self.allowed,
865            default_projection: self.default_projection,
866            backend: self.backend,
867            index,
868            notify: self.notify,
869            retention: self.retention,
870            invalid_records: self.invalid_records,
871            oversize: self.oversize,
872        })
873    }
874}
875
876/// Selects a single source `(stream, topic)` for v1. Both are required, since
877/// a topic only exists within a stream. Future versions can extend this to
878/// prefix, glob, or multi-stream selectors. Today the model is
879/// one-topic-per-binding to keep routing simple.
880#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
881pub struct SourceSelector {
882    pub stream: String,
883    pub topic: String,
884}
885
886impl SourceSelector {
887    /// A `(stream, topic)` source for a binding.
888    pub fn new(stream: impl Into<String>, topic: impl Into<String>) -> Self {
889        Self {
890            stream: stream.into(),
891            topic: topic.into(),
892        }
893    }
894}
895
896/// A registered writer schema, keyed by the `id` a producer stamps on
897/// `agdx.sid`. Avro and Protobuf schemas decode their schema-first bodies. A
898/// JSON Schema validates the decoded payload of a self-describing codec
899/// (JSON, MessagePack, CBOR, BSON), which otherwise needs no entry here. Ids
900/// are permanent: registering an occupied id with a different definition is
901/// rejected managed-side, and dropping tombstones the definition.
902#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
903pub struct SchemaDef {
904    pub id: u32,
905    pub source: SchemaSource,
906    /// Optional human label, pure metadata. LaserData Cloud stores and returns
907    /// it but never dispatches on it (`agdx.sid` carries the id). Uniqueness is
908    /// not enforced. Absent on the wire when unset.
909    #[serde(default, skip_serializing_if = "Option::is_none")]
910    pub name: Option<String>,
911    /// Optional caller-tracked schema version, pure metadata. LaserData Cloud
912    /// stores and returns it but never dispatches on it (the `id` alone selects
913    /// the decoder). Absent on the wire when unset, so pre-version registries
914    /// decode unchanged.
915    #[serde(default, skip_serializing_if = "Option::is_none")]
916    pub version: Option<u32>,
917}
918
919/// A writer schema whose id the control fold chooses at the record's ordered log
920/// position: an identical definition of the same registry is reused, otherwise
921/// the lowest free id is taken. The id an explicit [`SchemaDef`] names is never
922/// renumbered, so the two stay distinct commands.
923#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
924pub struct SchemaAllocation {
925    pub source: SchemaSource,
926    #[serde(default, skip_serializing_if = "Option::is_none")]
927    pub name: Option<String>,
928    #[serde(default, skip_serializing_if = "Option::is_none")]
929    pub version: Option<u32>,
930}
931
932impl SchemaAllocation {
933    /// The definition this allocation registers under `id`.
934    pub fn definition(&self, id: u32) -> SchemaDef {
935        SchemaDef {
936            id,
937            source: self.source.clone(),
938            name: self.name.clone(),
939            version: self.version,
940        }
941    }
942}
943
944impl SchemaDef {
945    /// The codec this schema applies to. A JSON Schema validates any
946    /// self-describing codec and reports as `Json`.
947    pub fn content_type(&self) -> ContentType {
948        match self.source {
949            SchemaSource::Avro { .. } => ContentType::Avro,
950            SchemaSource::Protobuf { .. } => ContentType::Protobuf,
951            SchemaSource::JsonSchema { .. } => ContentType::Json,
952            // An unknown source kind from a newer server: best-effort.
953            SchemaSource::Unknown => ContentType::Any,
954        }
955    }
956}
957
958/// The schema payload for a schema-first codec. Internally tagged on `kind`
959/// (`{"kind":"avro","schema":...}`) so an older client tolerates a newer
960/// server's unknown source kind: an unrecognized `kind` decodes to
961/// [`SchemaSource::Unknown`] instead of failing the whole reply.
962#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
963#[serde(tag = "kind", rename_all = "snake_case")]
964#[non_exhaustive]
965pub enum SchemaSource {
966    /// An Avro writer schema as its canonical JSON text.
967    Avro { schema: String },
968    /// A Protobuf `FileDescriptorSet` (one or more compiled `.proto` files)
969    /// plus the fully-qualified message type to decode, e.g. `"fleet.Reading"`.
970    Protobuf {
971        #[serde(with = "crate::encoding::bin_bytes")]
972        descriptor_set: Vec<u8>,
973        message_type: String,
974    },
975    /// A JSON Schema (draft 2020-12) as its JSON text. Validation-only: a
976    /// self-describing record stamping this schema's id on `agdx.sid` has its
977    /// decoded payload validated by LaserData Cloud. Mismatches are counted,
978    /// optionally dead-lettered, and never materialize body fields.
979    JsonSchema { schema: String },
980    /// A schema `kind` a newer server named that this build does not know. The
981    /// decode degrades to this rather than failing, so an older client can still
982    /// read a schema registry that holds a source kind it cannot decode against
983    /// (it must not re-register one: the original `kind` and its fields are
984    /// lost). Same forward-compat shape as [`RetentionPolicy::Unknown`].
985    #[serde(other)]
986    Unknown,
987}
988
989/// One control command on the control topic. The client SDK (or any tool
990/// driving LaserData Cloud) publishes these to register projections,
991/// bindings, and schemas.
992#[derive(Clone, Debug, Serialize, Deserialize)]
993pub enum ControlCommand {
994    RegisterProjection(Projection),
995    DropProjection(String),
996    ApplyBinding(ProjectionBinding),
997    RemoveBinding {
998        source: SourceSelector,
999        projection_ref: Option<String>,
1000        /// Remove the binding only when it belongs to this incarnation of its
1001        /// source stream or an earlier one. `None` removes it whatever its
1002        /// incarnation.
1003        #[serde(default, skip_serializing_if = "Option::is_none")]
1004        generation: Option<ControlStreamGeneration>,
1005    },
1006    /// Register (or replace by `id`) a writer schema for a schema-first codec.
1007    RegisterSchema(SchemaDef),
1008    /// Drop the schema registered under this id.
1009    DropSchema(u32),
1010    /// Register a writer schema under the id the control fold chooses. The
1011    /// record's operation identity keeps the chosen id, so a retry returns it.
1012    AllocateSchema(SchemaAllocation),
1013    /// Register (or replace by id) a graph projection: a [`Projection`] with
1014    /// `kind = Graph` and an `entity_schema`. A distinct command from
1015    /// [`RegisterProjection`](Self::RegisterProjection) so a deployment can gate
1016    /// graph registration separately.
1017    RegisterGraph(Projection),
1018    /// Drop the graph projection registered under this id.
1019    DropGraph(String),
1020    /// Register the named session topics in one stream, or all session topics.
1021    RegisterSessionSource {
1022        stream: String,
1023        topics: SessionTopics,
1024    },
1025    /// Stop folding session records from one stream.
1026    RemoveSessionSource {
1027        stream: String,
1028    },
1029    /// One consumer-filter catalog mutation, stamped by the streaming server.
1030    /// Its outcome (applied or rejected) is recorded under the operation id.
1031    FilterCatalog(Box<crate::filter::FilterCatalogCommand>),
1032    /// An operator accepts that one source partition lost history to
1033    /// retention. A projection that resumes past the gap continues, and keeps
1034    /// the gap as a record that its history is incomplete. Only the managed
1035    /// backend's operator path appends it. The streaming server never forwards
1036    /// it from a client.
1037    AcceptGap(GapAcceptance),
1038    /// The managed backend skips one record that a binding's destination or extraction
1039    /// deterministically rejected. Ordered on the control topic, so every
1040    /// replica skips the same record. Appended by the backend itself, for a
1041    /// binding that opted in or after an operator asked. The streaming server
1042    /// never forwards it from a client.
1043    SkipRecord(RecordSkip),
1044    /// The managed backend observed that one source partition incarnation
1045    /// retains no record below `first_offset`. Record skips and accepted gaps
1046    /// below it can never be reached again and leave the registry. Appended by
1047    /// the backend itself. The streaming server never forwards it from a
1048    /// client.
1049    SourceRetained(SourceRetention),
1050}
1051
1052/// The first offset one source partition incarnation still retains.
1053#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
1054pub struct SourceRetention {
1055    pub source: SourceSelector,
1056    pub partition_id: u32,
1057    /// Creation time of the source topic incarnation the floor belongs to.
1058    pub topic_created_at_micros: u64,
1059    /// No record below this offset can be read again, `u64::MAX` when the
1060    /// incarnation is gone.
1061    pub first_offset: u64,
1062}
1063
1064/// One record a binding skips, on one topic incarnation.
1065#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
1066pub struct RecordSkip {
1067    pub source: SourceSelector,
1068    pub partition_id: u32,
1069    /// Creation time of the source topic incarnation. A recreated topic never
1070    /// inherits a skip.
1071    pub topic_created_at_micros: u64,
1072    pub offset: u64,
1073    /// The operational index or graph the record failed to reach. The
1074    /// record's other effects still apply.
1075    pub target: String,
1076    /// What the destination or the extraction said about the record.
1077    pub reason: String,
1078}
1079
1080impl RecordSkip {
1081    /// Whether this skip covers `offset` of `partition_id` for `target` on
1082    /// the topic incarnation `generation`.
1083    pub fn covers(
1084        &self,
1085        stream: &str,
1086        topic: &str,
1087        partition_id: u32,
1088        generation: u64,
1089        offset: u64,
1090        target: &str,
1091    ) -> bool {
1092        self.source.stream == stream
1093            && self.source.topic == topic
1094            && self.partition_id == partition_id
1095            && self.topic_created_at_micros == generation
1096            && self.offset == offset
1097            && self.target == target
1098    }
1099}
1100
1101/// The exact missing range an operator accepted for one source partition.
1102#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
1103pub struct GapAcceptance {
1104    pub source: SourceSelector,
1105    pub partition_id: u32,
1106    /// Creation time of the source topic incarnation the gap belongs to. A
1107    /// recreated topic needs its own acceptance.
1108    pub topic_created_at_micros: u64,
1109    /// First missing offset, the resume point the projection expected.
1110    pub from_offset: u64,
1111    /// First offset the log could still serve when the gap was accepted.
1112    pub to_offset: u64,
1113}
1114
1115impl GapAcceptance {
1116    /// Whether this acceptance covers a resume at `cursor` whose first readable
1117    /// record is `first_available`, on the topic incarnation `generation`.
1118    pub fn covers(&self, generation: u64, cursor: u64, first_available: u64) -> bool {
1119        self.topic_created_at_micros == generation
1120            && self.from_offset <= cursor
1121            && self.to_offset >= first_available
1122    }
1123}
1124
1125/// The topics a session-source registration covers.
1126#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
1127pub enum SessionTopics {
1128    All,
1129    Named(Vec<String>),
1130}
1131
1132/// Versioned wrapper around a [`ControlCommand`], CBOR-named on the wire.
1133#[derive(Clone, Debug, Serialize, Deserialize)]
1134pub struct ControlEnvelope {
1135    pub v: u32,
1136    pub timestamp_micros: u64,
1137    pub command: ControlCommand,
1138    /// The stream the command's resource names belong to, set by a client that
1139    /// scopes its resources to a stream on the projection, binding, graph, and
1140    /// schema commands. The writer-schema registry keys `RegisterSchema` and
1141    /// `DropSchema` by `(stream, id)` when it is set. Absent on the wire when
1142    /// unset, which addresses the deployment-wide registry.
1143    #[serde(default, skip_serializing_if = "Option::is_none")]
1144    pub stream: Option<String>,
1145    /// Identity the managed backend binds to the record before it appends it
1146    /// to the control log. A client never sets it. The backend seals the whole
1147    /// encoded envelope, stamp included, so a record cannot be moved to
1148    /// another deployment, actor, operation or stream generation.
1149    #[serde(default, skip_serializing_if = "Option::is_none")]
1150    pub stamp: Option<ControlStamp>,
1151}
1152
1153/// Who appended a control record and for which resource generation.
1154#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
1155pub struct ControlStamp {
1156    /// The deployment whose backend appended the record. Zero for a
1157    /// standalone backend without a deployment identity.
1158    pub deployment_id: u64,
1159    /// Stable identity of the logical control operation. A retry keeps it, so
1160    /// a second append of the same operation applies once.
1161    pub operation_id: u128,
1162    /// The authenticated stream generation, present when the request was
1163    /// scoped to a stream.
1164    #[serde(default, skip_serializing_if = "Option::is_none")]
1165    pub stream_generation: Option<ControlStreamGeneration>,
1166    /// Identifies the sealing key without revealing it.
1167    pub key_id: u64,
1168    /// The client that asked for the record, the only actor the record names.
1169    /// Absent on a record the backend or the streaming server issues on its
1170    /// own, so the root user, id zero, is never mistaken for them.
1171    #[serde(default, skip_serializing_if = "Option::is_none")]
1172    pub caller: Option<crate::forward::Caller>,
1173}
1174
1175/// One incarnation of a stream: a deleted and recreated stream has a new
1176/// creation time even when it reuses its name and id.
1177#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
1178pub struct ControlStreamGeneration {
1179    pub stream_id: u32,
1180    pub created_at_micros: u64,
1181}
1182
1183impl Validate for ProjectionId {
1184    fn validate(&self) -> Result<(), InvalidError> {
1185        validate_projection_id(&self.0)
1186    }
1187}
1188
1189impl Validate for Projection {
1190    fn validate(&self) -> Result<(), InvalidError> {
1191        self.id.validate()?;
1192        if let ProjectionHistory::Keyed { key, delete_header } = &self.history {
1193            if self.kind == ProjectionKind::Graph {
1194                return Err(InvalidError::new(
1195                    "graph projections do not support keyed history",
1196                ));
1197            }
1198            if let Some(delete_header) = delete_header {
1199                validate_bounded_text("deletion header", delete_header, MAX_INDEX_FIELD_BYTES)?;
1200            }
1201            match key {
1202                ProjectionKey::Header { name } => {
1203                    validate_bounded_text("key header", name, MAX_INDEX_FIELD_BYTES)?
1204                }
1205                ProjectionKey::Pointer { pointer } => {
1206                    if self.kind != ProjectionKind::Row {
1207                        return Err(InvalidError::new(
1208                            "payload key pointers require a row projection",
1209                        ));
1210                    }
1211                    if !pointer.is_empty() {
1212                        validate_bounded_text("key pointer", pointer, MAX_INDEX_FIELD_BYTES)?;
1213                        if !pointer.starts_with('/') {
1214                            return Err(InvalidError::new("key pointer must be a JSON pointer"));
1215                        }
1216                    }
1217                    let mut bytes = pointer.bytes();
1218                    while let Some(byte) = bytes.next() {
1219                        if byte == b'~' && !matches!(bytes.next(), Some(b'0' | b'1')) {
1220                            return Err(InvalidError::new("key pointer has an invalid escape"));
1221                        }
1222                    }
1223                }
1224            }
1225        }
1226
1227        if self.kind == ProjectionKind::Record
1228            && (!self.extraction.fields.is_empty()
1229                || self.extraction.vector_field.is_some()
1230                || self.entity_schema.is_some())
1231        {
1232            return Err(InvalidError::new(
1233                "a whole-record projection extracts nothing, so it declares no fields, vector field or entity schema",
1234            ));
1235        }
1236        if !self.name.is_empty() {
1237            validate_bounded_text("projection name", &self.name, MAX_PROJECTION_NAME_BYTES)?;
1238        }
1239        for field in &self.extraction.fields {
1240            validate_bounded_text("index field name", &field.name, MAX_INDEX_FIELD_BYTES)?;
1241            let mut characters = field.name.chars();
1242            let leads = characters
1243                .next()
1244                .is_some_and(|first| first.is_ascii_alphabetic() || first == '_');
1245            if !leads || !characters.all(|rest| rest.is_ascii_alphanumeric() || rest == '_') {
1246                return Err(InvalidError::new(format!(
1247                    "index field name `{}` must start with an ASCII letter or `_` and hold only ASCII letters, digits and `_`",
1248                    field.name
1249                )));
1250            }
1251            validate_bounded_text("index field pointer", &field.pointer, MAX_INDEX_FIELD_BYTES)?;
1252        }
1253        if let Some(vector_field) = &self.extraction.vector_field {
1254            validate_bounded_text("index vector field", vector_field, MAX_INDEX_FIELD_BYTES)?;
1255        }
1256        if let Some(entity_schema) = &self.entity_schema {
1257            for node in &entity_schema.nodes {
1258                validate_bounded_text("graph node label", &node.label, MAX_GRAPH_LABEL_BYTES)?;
1259            }
1260            for edge in &entity_schema.edges {
1261                validate_bounded_text("graph edge type", &edge.edge_type, MAX_GRAPH_LABEL_BYTES)?;
1262            }
1263        }
1264        Ok(())
1265    }
1266}
1267
1268impl Validate for ProjectionBinding {
1269    fn validate(&self) -> Result<(), InvalidError> {
1270        for projection in &self.allowed_projections {
1271            projection.validate()?;
1272        }
1273        if let Some(projection) = &self.default_projection {
1274            projection.validate()?;
1275        }
1276        if self.invalid_records == InvalidRecordPolicy::Unknown
1277            || self.oversize == OversizePolicy::Unknown
1278            || self.retention == Some(RetentionPolicy::Unknown)
1279        {
1280            return Err(InvalidError::new(
1281                "a binding cannot apply a policy this build does not know",
1282            ));
1283        }
1284        if matches!(
1285            self.retention,
1286            Some(
1287                RetentionPolicy::TimeToLive { ttl_micros: 0 }
1288                    | RetentionPolicy::MaxRows { rows: 0 }
1289            )
1290        ) {
1291            return Err(InvalidError::new(
1292                "a time to live retention needs a lifetime above zero, and a max rows retention at least one row",
1293            ));
1294        }
1295        if self.oversize == OversizePolicy::Reference
1296            && self
1297                .retention
1298                .is_some_and(|retention| retention != RetentionPolicy::MirrorLog)
1299        {
1300            return Err(InvalidError::new(
1301                "a binding that keeps body references needs mirror_log retention, so no row outlives its record",
1302            ));
1303        }
1304        Ok(())
1305    }
1306}
1307
1308impl Validate for SchemaDef {
1309    fn validate(&self) -> Result<(), InvalidError> {
1310        if self.id == 0 {
1311            return Err(InvalidError::new("writer schema ids start at 1"));
1312        }
1313        validate_schema_metadata(&self.source, self.name.as_deref())
1314    }
1315}
1316
1317impl Validate for SchemaAllocation {
1318    fn validate(&self) -> Result<(), InvalidError> {
1319        validate_schema_metadata(&self.source, self.name.as_deref())
1320    }
1321}
1322
1323impl Validate for SchemaSource {
1324    fn validate(&self) -> Result<(), InvalidError> {
1325        match self {
1326            Self::Avro { schema } => validate_schema_text("Avro schema", schema),
1327            Self::JsonSchema { schema } => {
1328                validate_schema_text("JSON Schema", schema)?;
1329                let value: serde_json::Value = serde_json::from_str(schema).map_err(|error| {
1330                    InvalidError::new(format!("JSON Schema is not valid JSON: {error}"))
1331                })?;
1332                match value.get("$schema") {
1333                    None => Ok(()),
1334                    Some(serde_json::Value::String(dialect))
1335                        if dialect.trim_end_matches('#') == JSON_SCHEMA_DIALECT =>
1336                    {
1337                        Ok(())
1338                    }
1339                    Some(other) => Err(InvalidError::new(format!(
1340                        "a JSON Schema uses draft 2020-12, not {other}"
1341                    ))),
1342                }
1343            }
1344            Self::Protobuf {
1345                descriptor_set,
1346                message_type,
1347            } => {
1348                if descriptor_set.len() > MAX_FILTER_SCHEMA_BYTES {
1349                    return Err(InvalidError::new(format!(
1350                        "Protobuf descriptor set exceeds {MAX_FILTER_SCHEMA_BYTES} bytes"
1351                    )));
1352                }
1353                validate_bounded_text("Protobuf message type", message_type, MAX_SCHEMA_NAME_BYTES)
1354            }
1355            Self::Unknown => Err(InvalidError::new(
1356                "a schema source of an unknown kind cannot be registered",
1357            )),
1358        }
1359    }
1360}
1361
1362impl Validate for ControlCommand {
1363    fn validate(&self) -> Result<(), InvalidError> {
1364        match self {
1365            Self::RegisterProjection(projection) | Self::RegisterGraph(projection) => {
1366                projection.validate()
1367            }
1368            Self::DropProjection(id) | Self::DropGraph(id) => validate_projection_id(id),
1369            Self::ApplyBinding(binding) => binding.validate(),
1370            Self::RemoveBinding { projection_ref, .. } => projection_ref
1371                .as_deref()
1372                .map_or(Ok(()), validate_projection_id),
1373            Self::RegisterSchema(schema) => schema.validate(),
1374            Self::AllocateSchema(allocation) => allocation.validate(),
1375            Self::FilterCatalog(command) => command.mutation.validate(),
1376            Self::DropSchema(_)
1377            | Self::RegisterSessionSource { .. }
1378            | Self::RemoveSessionSource { .. }
1379            | Self::AcceptGap(_)
1380            | Self::SkipRecord(_)
1381            | Self::SourceRetained(_) => Ok(()),
1382        }
1383    }
1384}
1385
1386fn validate_projection_id(id: &str) -> Result<(), InvalidError> {
1387    validate_bounded_text("projection id", id, MAX_PROJECTION_ID_BYTES)?;
1388    if id.starts_with(RESERVED_PROJECTION_ID_PREFIX) {
1389        return Err(InvalidError::new(format!(
1390            "projection id must not start with {RESERVED_PROJECTION_ID_PREFIX:?}"
1391        )));
1392    }
1393    Ok(())
1394}
1395
1396fn validate_schema_metadata(source: &SchemaSource, name: Option<&str>) -> Result<(), InvalidError> {
1397    if let Some(name) = name {
1398        validate_bounded_text("schema name", name, MAX_SCHEMA_NAME_BYTES)?;
1399    }
1400    source.validate()
1401}
1402
1403fn validate_schema_text(label: &str, text: &str) -> Result<(), InvalidError> {
1404    if text.len() > MAX_FILTER_SCHEMA_BYTES {
1405        return Err(InvalidError::new(format!(
1406            "{label} exceeds {MAX_FILTER_SCHEMA_BYTES} bytes"
1407        )));
1408    }
1409    if !json_depth_within(text.as_bytes(), MAX_FILTER_SCHEMA_DEPTH) {
1410        return Err(InvalidError::new(format!(
1411            "{label} nests deeper than {MAX_FILTER_SCHEMA_DEPTH}"
1412        )));
1413    }
1414    Ok(())
1415}
1416
1417#[cfg(test)]
1418mod tests {
1419    use super::*;
1420    use crate::limits::{
1421        MAX_GRAPH_LABEL_BYTES, MAX_INDEX_FIELD_BYTES, MAX_PROJECTION_ID_BYTES,
1422        MAX_PROJECTION_NAME_BYTES, MAX_SCHEMA_NAME_BYTES,
1423    };
1424    use std::borrow::Borrow;
1425
1426    #[test]
1427    fn given_keyed_projection_when_validated_then_should_check_kind_and_pointer() {
1428        for pointer in ["", "/id", "/a~1b/~0"] {
1429            Projection::builder("keyed")
1430                .keyed(
1431                    ProjectionKey::Pointer {
1432                        pointer: pointer.to_owned(),
1433                    },
1434                    Some("deleted"),
1435                )
1436                .build()
1437                .validate()
1438                .expect("valid pointer");
1439        }
1440        for pointer in ["id", "/a~", "/a~2"] {
1441            assert!(
1442                Projection::builder("keyed")
1443                    .keyed(
1444                        ProjectionKey::Pointer {
1445                            pointer: pointer.to_owned()
1446                        },
1447                        Some("deleted")
1448                    )
1449                    .build()
1450                    .validate()
1451                    .is_err()
1452            );
1453        }
1454        assert!(
1455            Projection::builder("keyed")
1456                .record()
1457                .keyed(
1458                    ProjectionKey::Pointer {
1459                        pointer: "/id".to_owned()
1460                    },
1461                    Some("deleted")
1462                )
1463                .build()
1464                .validate()
1465                .is_err()
1466        );
1467        assert!(
1468            Projection::builder("keyed")
1469                .graph(EntitySchema::default())
1470                .keyed(
1471                    ProjectionKey::Header {
1472                        name: "id".to_owned()
1473                    },
1474                    Some("deleted")
1475                )
1476                .build()
1477                .validate()
1478                .is_err()
1479        );
1480        Projection::builder("keyed")
1481            .record()
1482            .keyed(
1483                ProjectionKey::Header {
1484                    name: "id".to_owned(),
1485                },
1486                Some("deleted"),
1487            )
1488            .build()
1489            .validate()
1490            .expect("record header key");
1491    }
1492
1493    #[test]
1494    fn given_an_invalid_projection_id_when_any_command_names_it_then_should_refuse_it() {
1495        let projection = |id: &str| {
1496            Projection::builder(id)
1497                .name("reading")
1498                .field("host")
1499                .build()
1500        };
1501        let at_cap = "p".repeat(MAX_PROJECTION_ID_BYTES);
1502        projection(&at_cap)
1503            .validate()
1504            .expect("a projection id at the cap is valid");
1505        ControlCommand::DropProjection(at_cap.clone())
1506            .validate()
1507            .expect("a drop of a projection id at the cap is valid");
1508        ControlCommand::RemoveBinding {
1509            source: SourceSelector::new("fleet", "readings"),
1510            projection_ref: None,
1511            generation: None,
1512        }
1513        .validate()
1514        .expect("a whole-binding removal names no projection");
1515        for invalid in [
1516            String::new(),
1517            "p".repeat(MAX_PROJECTION_ID_BYTES + 1),
1518            "reading\n.v1".to_owned(),
1519            format!("{RESERVED_PROJECTION_ID_PREFIX}reading"),
1520        ] {
1521            assert!(projection(&invalid).validate().is_err(), "{invalid:?}");
1522            assert!(
1523                ControlCommand::RegisterProjection(projection(&invalid))
1524                    .validate()
1525                    .is_err()
1526            );
1527            assert!(
1528                ControlCommand::DropProjection(invalid.clone())
1529                    .validate()
1530                    .is_err()
1531            );
1532            assert!(
1533                ControlCommand::DropGraph(invalid.clone())
1534                    .validate()
1535                    .is_err()
1536            );
1537            let allowed = ProjectionBinding::builder()
1538                .source("fleet", "readings")
1539                .allow(invalid.as_str())
1540                .build();
1541            assert!(ControlCommand::ApplyBinding(allowed).validate().is_err());
1542            let defaulted = ProjectionBinding::builder()
1543                .source("fleet", "readings")
1544                .default_projection(invalid.as_str())
1545                .build();
1546            assert!(defaulted.validate().is_err());
1547            assert!(
1548                ControlCommand::RemoveBinding {
1549                    source: SourceSelector::new("fleet", "readings"),
1550                    projection_ref: Some(invalid.clone()),
1551                    generation: None,
1552                }
1553                .validate()
1554                .is_err()
1555            );
1556            assert!(invalid.parse::<ProjectionId>().is_err());
1557        }
1558    }
1559
1560    #[test]
1561    fn given_a_projection_name_over_the_cap_when_validated_then_should_refuse_it_and_accept_none() {
1562        let named = |name: &str| Projection::builder("reading.v1").name(name).build();
1563        named("").validate().expect("a projection name is optional");
1564        named(&"n".repeat(MAX_PROJECTION_NAME_BYTES))
1565            .validate()
1566            .expect("a projection name at the cap is valid");
1567        assert!(
1568            named(&"n".repeat(MAX_PROJECTION_NAME_BYTES + 1))
1569                .validate()
1570                .is_err()
1571        );
1572        assert!(named("read\ting").validate().is_err());
1573    }
1574
1575    #[test]
1576    fn given_an_invalid_index_field_when_a_projection_is_validated_then_should_refuse_it() {
1577        let with_field = |name: &str, pointer: &str| {
1578            Projection::builder("reading.v1")
1579                .field_at(name, pointer)
1580                .build()
1581        };
1582        with_field("host", "/host")
1583            .validate()
1584            .expect("a plain index field is valid");
1585        let long = "f".repeat(MAX_INDEX_FIELD_BYTES + 1);
1586        assert!(with_field(&long, "/host").validate().is_err());
1587        assert!(with_field("host", &long).validate().is_err());
1588        assert!(with_field("", "/host").validate().is_err());
1589        assert!(with_field("host", "").validate().is_err());
1590        assert!(with_field("ho\u{7}st", "/host").validate().is_err());
1591        assert!(
1592            Projection::builder("reading.v1")
1593                .vector_field(long)
1594                .build()
1595                .validate()
1596                .is_err()
1597        );
1598        assert!(
1599            Projection::builder("reading.v1")
1600                .vector_field("")
1601                .build()
1602                .validate()
1603                .is_err()
1604        );
1605    }
1606
1607    #[test]
1608    fn given_an_invalid_schema_name_when_registered_then_should_refuse_it() {
1609        let schema = |name: Option<String>| SchemaDef {
1610            id: 7,
1611            source: SchemaSource::JsonSchema {
1612                schema: "{}".to_owned(),
1613            },
1614            name,
1615            version: None,
1616        };
1617        schema(None)
1618            .validate()
1619            .expect("a schema without a name is valid");
1620        ControlCommand::RegisterSchema(schema(Some("s".repeat(MAX_SCHEMA_NAME_BYTES))))
1621            .validate()
1622            .expect("a schema name at the cap is valid");
1623        for invalid in [
1624            String::new(),
1625            "s".repeat(MAX_SCHEMA_NAME_BYTES + 1),
1626            "read\ning".to_owned(),
1627        ] {
1628            assert!(schema(Some(invalid.clone())).validate().is_err());
1629            assert!(
1630                ControlCommand::RegisterSchema(schema(Some(invalid)))
1631                    .validate()
1632                    .is_err()
1633            );
1634        }
1635    }
1636
1637    #[test]
1638    fn given_an_invalid_graph_label_when_a_graph_projection_is_validated_then_should_refuse_it() {
1639        let graph = |label: &str, edge_type: &str| {
1640            Projection::builder("people.v1")
1641                .graph(EntitySchema {
1642                    nodes: vec![NodeExtract {
1643                        label: label.to_owned(),
1644                        value_pointer: "/name".to_owned(),
1645                        embedding_pointer: None,
1646                    }],
1647                    edges: vec![EdgeExtract {
1648                        edge_type: edge_type.to_owned(),
1649                        from_pointer: "/name".to_owned(),
1650                        to_pointer: "/company".to_owned(),
1651                        valid_from_pointer: None,
1652                        valid_to_pointer: None,
1653                    }],
1654                })
1655                .build()
1656        };
1657        let at_cap = "l".repeat(MAX_GRAPH_LABEL_BYTES);
1658        ControlCommand::RegisterGraph(graph(&at_cap, &at_cap))
1659            .validate()
1660            .expect("a label and an edge type at the cap are valid");
1661        let long = "l".repeat(MAX_GRAPH_LABEL_BYTES + 1);
1662        assert!(graph(&long, "works_at").validate().is_err());
1663        assert!(graph("Person", &long).validate().is_err());
1664        assert!(graph("", "works_at").validate().is_err());
1665        assert!(graph("Person", "").validate().is_err());
1666        assert!(
1667            ControlCommand::RegisterGraph(graph("Per\nson", "works_at"))
1668                .validate()
1669                .is_err()
1670        );
1671    }
1672
1673    #[test]
1674    fn given_binding_policies_when_encoded_then_should_carry_them_only_when_not_the_default() {
1675        let plain = ProjectionBinding::builder().source("s", "t").build();
1676        assert_eq!(plain.invalid_records, InvalidRecordPolicy::Block);
1677        assert_eq!(plain.oversize, OversizePolicy::Block);
1678        let plain_json = serde_json::to_value(&plain).expect("encode a default binding");
1679        assert!(plain_json.get("invalid_records").is_none());
1680        assert!(plain_json.get("oversize").is_none());
1681        let opted = ProjectionBinding::builder()
1682            .source("s", "t")
1683            .invalid_records(InvalidRecordPolicy::DeadLetter)
1684            .oversize(OversizePolicy::Reference)
1685            .build();
1686        let json = serde_json::to_value(&opted).expect("encode an opted binding");
1687        assert_eq!(json["invalid_records"], "dead_letter");
1688        assert_eq!(json["oversize"], "reference");
1689        let decoded: ProjectionBinding =
1690            serde_json::from_value(json).expect("decode an opted binding");
1691        assert_eq!(decoded, opted);
1692    }
1693
1694    #[test]
1695    fn given_an_unknown_binding_policy_when_decoded_then_should_degrade_and_refuse_to_apply_it() {
1696        let binding: ProjectionBinding = serde_json::from_value(serde_json::json!({
1697            "source": {"stream": "s", "topic": "t"},
1698            "index": "t",
1699            "invalid_records": "quarantine",
1700            "oversize": "truncate",
1701        }))
1702        .expect("an unknown policy decodes to Unknown");
1703        assert_eq!(binding.invalid_records, InvalidRecordPolicy::Unknown);
1704        assert_eq!(binding.oversize, OversizePolicy::Unknown);
1705        assert!(binding.validate().is_err());
1706    }
1707
1708    #[test]
1709    fn given_body_references_when_retention_outlives_the_log_then_should_refuse_the_binding() {
1710        let binding = |retention: Option<RetentionPolicy>| {
1711            let builder = ProjectionBinding::builder()
1712                .source("s", "t")
1713                .oversize(OversizePolicy::Reference);
1714            match retention {
1715                Some(retention) => builder.retention(retention),
1716                None => builder,
1717            }
1718            .build()
1719        };
1720        binding(None)
1721            .validate()
1722            .expect("the fleet default is checked by the backend");
1723        binding(Some(RetentionPolicy::MirrorLog))
1724            .validate()
1725            .expect("references live as long as their records");
1726        assert!(binding(Some(RetentionPolicy::Keep)).validate().is_err());
1727        assert!(
1728            binding(Some(RetentionPolicy::TimeToLive { ttl_micros: 1 }))
1729                .validate()
1730                .is_err()
1731        );
1732    }
1733
1734    #[test]
1735    fn given_a_record_projection_when_validated_then_should_refuse_extraction_rules() {
1736        let record = Projection::builder("orders").record().build();
1737        assert_eq!(record.kind, ProjectionKind::Record);
1738        assert_eq!(ProjectionKind::from_code(2), ProjectionKind::Record);
1739        assert_eq!(ProjectionKind::Record.code(), 2);
1740        record
1741            .validate()
1742            .expect("a bare record projection is valid");
1743        let with_field = Projection::builder("orders").record().field("id").build();
1744        assert!(with_field.validate().is_err());
1745        let with_vector = Projection::builder("orders")
1746            .record()
1747            .vector_field("/embedding")
1748            .build();
1749        assert!(with_vector.validate().is_err());
1750    }
1751
1752    #[test]
1753    fn given_a_record_skip_when_matched_then_should_cover_only_its_exact_record() {
1754        let skip = RecordSkip {
1755            source: SourceSelector::new("s", "t"),
1756            partition_id: 2,
1757            topic_created_at_micros: 7,
1758            offset: 40,
1759            target: "rows".to_owned(),
1760            reason: "rejected".to_owned(),
1761        };
1762        assert!(skip.covers("s", "t", 2, 7, 40, "rows"));
1763        assert!(
1764            !skip.covers("s", "t", 2, 8, 40, "rows"),
1765            "another incarnation"
1766        );
1767        assert!(!skip.covers("s", "t", 2, 7, 41, "rows"));
1768        assert!(!skip.covers("s", "t", 3, 7, 40, "rows"));
1769        assert!(!skip.covers("s", "t", 2, 7, 40, "other"));
1770        assert!(!skip.covers("s", "u", 2, 7, 40, "rows"));
1771    }
1772
1773    #[test]
1774    fn given_an_unknown_schema_source_kind_when_decoded_then_should_degrade_not_fail() {
1775        // A newer server names a `kind` this build does not know. The decode
1776        // must degrade to `Unknown` (the forward-compat catch-all) rather than
1777        // failing the whole reply, and any extra fields are ignored.
1778        let source: SchemaSource =
1779            serde_json::from_str(r#"{"kind":"parquet_descriptor","blob":[1,2,3]}"#)
1780                .expect("an unknown schema kind decodes to Unknown, not an error");
1781        assert_eq!(source, SchemaSource::Unknown);
1782        // A `SchemaDef` wrapping it reports the best-effort content type.
1783        let def = SchemaDef {
1784            id: 1,
1785            source,
1786            name: None,
1787            version: None,
1788        };
1789        assert_eq!(def.content_type(), ContentType::Any);
1790    }
1791
1792    #[test]
1793    fn given_an_unknown_retention_kind_when_decoded_then_should_degrade_not_fail() {
1794        let policy: RetentionPolicy = serde_json::from_str(r#"{"kind":"keep_for_eras","eras":3}"#)
1795            .expect("an unknown retention kind decodes to Unknown, not an error");
1796        assert_eq!(policy, RetentionPolicy::Unknown);
1797    }
1798
1799    #[test]
1800    fn given_an_explicit_operational_index_when_built_then_should_keep_one_index() {
1801        let binding = ProjectionBinding::builder()
1802            .source("fleet", "readings")
1803            .allow("reading.v1")
1804            .index("readings_rows")
1805            .build();
1806        assert_eq!(binding.index, "readings_rows");
1807        assert_eq!(binding.backend, None);
1808    }
1809
1810    #[test]
1811    fn given_an_operational_backend_when_built_then_should_keep_exact_generation() {
1812        let backend = BackendBinding {
1813            resource_id: crate::destination::BackendResourceId::from_u128(7),
1814            generation: 3,
1815        };
1816        let binding = ProjectionBinding::builder()
1817            .source("fleet", "readings")
1818            .backend(backend.clone())
1819            .build();
1820        assert_eq!(binding.backend, Some(backend));
1821    }
1822
1823    #[test]
1824    fn given_no_operational_index_when_built_then_should_use_the_topic_name() {
1825        let binding = ProjectionBinding::builder()
1826            .source("fleet", "readings")
1827            .allow("reading.v1")
1828            .build();
1829        assert_eq!(binding.index, "readings");
1830    }
1831
1832    #[test]
1833    fn given_no_retention_when_built_then_should_default_to_none() {
1834        let binding = ProjectionBinding::builder()
1835            .source("fleet", "readings")
1836            .index("readings_rows")
1837            .build();
1838        assert_eq!(binding.retention, None);
1839    }
1840
1841    #[test]
1842    fn given_no_source_when_try_built_then_should_error() {
1843        assert!(ProjectionBinding::builder().try_build().is_err());
1844    }
1845
1846    #[test]
1847    fn given_a_projection_built_with_the_default_when_inspected_then_should_inline_payload() {
1848        let projection = Projection::builder("api.call.v1")
1849            .name("api.call")
1850            .version(1)
1851            .fields(["endpoint", "status"])
1852            .build();
1853        assert!(
1854            projection.inline_payload_default,
1855            "Projection::builder default should inline payload"
1856        );
1857        assert!(
1858            projection.extraction.inline_payload,
1859            "Projection::builder default should mark extraction.inline_payload too"
1860        );
1861    }
1862
1863    #[test]
1864    fn given_a_projection_with_index_only_when_inspected_then_should_skip_inlining() {
1865        let projection = Projection::builder("api.call.v1")
1866            .name("api.call")
1867            .version(1)
1868            .fields(["endpoint"])
1869            .index_only()
1870            .build();
1871        assert!(
1872            !projection.inline_payload_default,
1873            "index_only must clear inline_payload_default"
1874        );
1875        assert!(
1876            !projection.extraction.inline_payload,
1877            "index_only must clear extraction.inline_payload"
1878        );
1879    }
1880
1881    #[test]
1882    fn given_an_empty_projection_id_when_parsed_then_should_error() {
1883        assert!("".parse::<ProjectionId>().is_err());
1884        assert_eq!(
1885            "reading.v1"
1886                .parse::<ProjectionId>()
1887                .expect("non-empty id parses")
1888                .as_str(),
1889            "reading.v1"
1890        );
1891    }
1892
1893    #[test]
1894    fn given_an_index_schema_builder_when_built_then_should_keep_every_declared_rule() {
1895        let schema = IndexSchema::builder()
1896            .field("host")
1897            .field_at("cpu_pct", "/cpu/value")
1898            .vector_field("/vectors/text")
1899            .inline_payload()
1900            .build();
1901        assert_eq!(
1902            schema,
1903            IndexSchema {
1904                fields: vec![
1905                    IndexField::new("host", "/host"),
1906                    IndexField::new("cpu_pct", "/cpu/value"),
1907                ],
1908                vector_field: Some("/vectors/text".to_owned()),
1909                inline_payload: true,
1910            }
1911        );
1912    }
1913
1914    #[test]
1915    fn given_a_projection_id_from_a_string_when_read_as_text_then_should_expose_that_string() {
1916        let id = ProjectionId::from("reading.v1".to_owned());
1917        let viewed: &str = id.as_ref();
1918        let borrowed: &str = id.borrow();
1919        assert_eq!(id, ProjectionId::new("reading.v1"));
1920        assert_eq!(
1921            (id.to_string(), viewed, borrowed),
1922            ("reading.v1".to_owned(), "reading.v1", "reading.v1")
1923        );
1924    }
1925
1926    #[test]
1927    fn given_a_keyed_record_projection_when_encoded_then_should_round_trip_and_omit_defaults() {
1928        let keyed = Projection::builder("orders.v1")
1929            .record()
1930            .keyed(
1931                ProjectionKey::Header {
1932                    name: "order_id".to_owned(),
1933                },
1934                Some("deleted"),
1935            )
1936            .build();
1937        let json = serde_json::to_value(&keyed).expect("encode a keyed record projection");
1938        let decoded: Projection =
1939            serde_json::from_value(json).expect("decode a keyed record projection");
1940        assert_eq!(decoded, keyed);
1941        let plain = serde_json::to_value(Projection::builder("orders.v1").build())
1942            .expect("encode a default projection");
1943        assert_eq!((plain.get("kind"), plain.get("history")), (None, None));
1944    }
1945
1946    #[test]
1947    fn given_a_keyed_projection_without_a_delete_header_when_decoded_then_should_validate_and_encode_without_it()
1948     {
1949        let keyed = Projection::builder("orders.v1")
1950            .record()
1951            .keyed(
1952                ProjectionKey::Header {
1953                    name: "order_id".to_owned(),
1954                },
1955                Some("deleted"),
1956            )
1957            .build();
1958        let mut json = serde_json::to_value(&keyed).expect("encode a keyed projection");
1959        json["history"]
1960            .as_object_mut()
1961            .expect("a keyed history object")
1962            .remove("delete_header");
1963        let decoded: Projection = serde_json::from_value(json.clone())
1964            .expect("decode a keyed projection without a delete header");
1965        decoded
1966            .validate()
1967            .expect("a keyed view may name no delete header");
1968        assert_eq!(
1969            serde_json::to_value(&decoded).expect("encode it again"),
1970            json
1971        );
1972        json["history"]["delete_header"] = serde_json::Value::String(String::new());
1973        let empty: Projection =
1974            serde_json::from_value(json).expect("decode an empty delete header");
1975        assert!(
1976            empty.validate().is_err(),
1977            "a named delete header must not be empty"
1978        );
1979    }
1980
1981    #[test]
1982    fn given_a_selector_and_notify_when_a_binding_is_built_then_should_keep_both() {
1983        let binding = ProjectionBinding::builder()
1984            .notify()
1985            .selector(SourceSelector::new("fleet", "readings"))
1986            .try_build()
1987            .expect("a selector is a source");
1988        assert_eq!(
1989            binding,
1990            ProjectionBinding {
1991                source: SourceSelector::new("fleet", "readings"),
1992                allowed_projections: Vec::new(),
1993                default_projection: None,
1994                backend: None,
1995                index: "readings".to_owned(),
1996                notify: true,
1997                retention: None,
1998                invalid_records: InvalidRecordPolicy::Block,
1999                oversize: OversizePolicy::Block,
2000            }
2001        );
2002    }
2003
2004    #[test]
2005    fn given_a_gap_acceptance_when_matched_then_should_cover_only_its_incarnation_and_range() {
2006        let gap = GapAcceptance {
2007            source: SourceSelector::new("s", "t"),
2008            partition_id: 2,
2009            topic_created_at_micros: 7,
2010            from_offset: 10,
2011            to_offset: 20,
2012        };
2013        assert!(gap.covers(7, 10, 20), "a resume at both accepted edges");
2014        assert!(!gap.covers(8, 10, 20), "another incarnation");
2015        assert!(!gap.covers(7, 9, 20), "a cursor before the accepted range");
2016        assert!(
2017            !gap.covers(7, 10, 21),
2018            "a first record past the accepted range"
2019        );
2020    }
2021
2022    #[test]
2023    fn given_an_index_field_name_that_is_not_an_identifier_when_validated_then_should_refuse_it() {
2024        for name in ["user-id", "order.total", "9lives", "caf\u{e9}"] {
2025            let projection = Projection::builder("orders")
2026                .field_at(name, "/value")
2027                .build();
2028            assert!(projection.validate().is_err(), "`{name}` is refused");
2029        }
2030        for name in ["user_id", "_private", "total2"] {
2031            let projection = Projection::builder("orders")
2032                .field_at(name, "/value")
2033                .build();
2034            assert!(projection.validate().is_ok(), "`{name}` is accepted");
2035        }
2036    }
2037
2038    #[test]
2039    fn given_a_retention_that_never_or_always_prunes_when_validated_then_should_refuse_the_binding()
2040    {
2041        for retention in [
2042            RetentionPolicy::Unknown,
2043            RetentionPolicy::TimeToLive { ttl_micros: 0 },
2044            RetentionPolicy::MaxRows { rows: 0 },
2045        ] {
2046            let binding = ProjectionBinding::builder()
2047                .source("s", "t")
2048                .retention(retention)
2049                .build();
2050            assert!(binding.validate().is_err(), "{retention:?} is refused");
2051        }
2052        let bounded = ProjectionBinding::builder()
2053            .source("s", "t")
2054            .retention(RetentionPolicy::MaxRows { rows: 1 })
2055            .build();
2056        assert!(bounded.validate().is_ok());
2057    }
2058
2059    #[test]
2060    fn given_one_unknown_binding_policy_when_validated_then_should_refuse_the_binding() {
2061        let binding = ProjectionBinding {
2062            invalid_records: InvalidRecordPolicy::Unknown,
2063            ..ProjectionBinding::builder().source("s", "t").build()
2064        };
2065        assert_eq!(binding.oversize, OversizePolicy::Block);
2066        assert!(binding.validate().is_err());
2067    }
2068}
2069
2070#[cfg(all(test, feature = "cbor"))]
2071mod wire_tests {
2072    use super::*;
2073    use crate::codes::CONTROL_OP_VERSION;
2074    use crate::content::ContentType;
2075    use crate::framing::{decode_named, encode_named};
2076
2077    #[test]
2078    fn given_an_apply_binding_when_round_tripped_then_should_preserve_index_and_version() {
2079        let binding = ProjectionBinding::builder()
2080            .source("fleet", "readings")
2081            .allow("reading.v1")
2082            .default_projection("reading.v1")
2083            .index("readings_rows")
2084            .build();
2085        let envelope = ControlEnvelope {
2086            v: CONTROL_OP_VERSION,
2087            timestamp_micros: 42,
2088            command: ControlCommand::ApplyBinding(binding),
2089            stream: None,
2090            stamp: None,
2091        };
2092        let bytes = encode_named(&envelope).expect("envelope serializes");
2093        let back: ControlEnvelope = decode_named(&bytes).expect("envelope deserializes");
2094        assert_eq!(back.v, CONTROL_OP_VERSION);
2095        let ControlCommand::ApplyBinding(decoded) = back.command else {
2096            panic!("expected ApplyBinding");
2097        };
2098        assert_eq!(decoded.index, "readings_rows");
2099        assert_eq!(decoded.backend, None);
2100        // Retention left unset stays unset across the wire (inherits LaserData Cloud
2101        // default), and is omitted from the encoding entirely.
2102        assert_eq!(decoded.retention, None);
2103    }
2104
2105    #[test]
2106    fn given_a_register_schema_when_round_tripped_then_should_preserve_source() {
2107        let envelope = ControlEnvelope {
2108            v: CONTROL_OP_VERSION,
2109            timestamp_micros: 7,
2110            command: ControlCommand::RegisterSchema(SchemaDef {
2111                id: 11,
2112                source: SchemaSource::Avro {
2113                    schema: r#"{"type":"record","name":"Reading","fields":[]}"#.to_owned(),
2114                },
2115                name: None,
2116                version: None,
2117            }),
2118            stream: None,
2119            stamp: None,
2120        };
2121        let bytes = encode_named(&envelope).expect("envelope serializes");
2122        let back: ControlEnvelope = decode_named(&bytes).expect("envelope deserializes");
2123        assert_eq!(back.stream, None);
2124        let ControlCommand::RegisterSchema(decoded) = back.command else {
2125            panic!("expected RegisterSchema");
2126        };
2127        assert_eq!(decoded.id, 11);
2128        assert_eq!(decoded.content_type(), ContentType::Avro);
2129    }
2130
2131    #[test]
2132    fn given_a_stream_scoped_drop_schema_when_round_tripped_then_should_preserve_the_stream() {
2133        let envelope = ControlEnvelope {
2134            v: CONTROL_OP_VERSION,
2135            timestamp_micros: 7,
2136            command: ControlCommand::DropSchema(11),
2137            stream: Some("acme".to_owned()),
2138            stamp: None,
2139        };
2140        let back: ControlEnvelope =
2141            decode_named(&encode_named(&envelope).expect("envelope serializes"))
2142                .expect("envelope deserializes");
2143        assert_eq!(back.stream.as_deref(), Some("acme"));
2144        assert!(matches!(back.command, ControlCommand::DropSchema(11)));
2145        let unscoped = ControlEnvelope {
2146            stream: None,
2147            ..envelope
2148        };
2149        let json = serde_json::to_string(&unscoped).expect("envelope serializes");
2150        assert!(
2151            !json.contains("stream"),
2152            "an unset stream is omitted: {json}"
2153        );
2154    }
2155
2156    #[test]
2157    fn given_a_stamped_envelope_when_round_tripped_then_should_keep_the_identity_and_omit_an_unset_stamp()
2158     {
2159        let envelope = ControlEnvelope {
2160            v: CONTROL_OP_VERSION,
2161            timestamp_micros: 7,
2162            command: ControlCommand::DropSchema(11),
2163            stream: Some("acme".to_owned()),
2164            stamp: Some(ControlStamp {
2165                deployment_id: 42,
2166                operation_id: u128::MAX - 1,
2167                stream_generation: Some(ControlStreamGeneration {
2168                    stream_id: 9,
2169                    created_at_micros: 1_700_000_000_000_000,
2170                }),
2171                key_id: u64::MAX,
2172                caller: Some(crate::forward::Caller {
2173                    user_id: 0,
2174                    agent: None,
2175                    on_behalf_of: None,
2176                }),
2177            }),
2178        };
2179        let back: ControlEnvelope =
2180            decode_named(&encode_named(&envelope).expect("envelope serializes"))
2181                .expect("envelope deserializes");
2182        assert_eq!(back.stamp, envelope.stamp);
2183        let unstamped = ControlEnvelope {
2184            stamp: None,
2185            ..envelope
2186        };
2187        let json = serde_json::to_string(&unstamped).expect("envelope serializes");
2188        assert!(
2189            !json.contains("\"stamp\""),
2190            "an unset stamp is omitted: {json}"
2191        );
2192    }
2193
2194    #[test]
2195    fn given_a_protobuf_schema_source_when_round_tripped_then_should_preserve_bytes() {
2196        let source = SchemaSource::Protobuf {
2197            descriptor_set: vec![10, 20, 30],
2198            message_type: "fleet.Reading".to_owned(),
2199        };
2200        let bytes = encode_named(&source).expect("serializes");
2201        let back: SchemaSource = decode_named(&bytes).expect("deserializes");
2202        assert_eq!(back, source);
2203    }
2204
2205    #[test]
2206    fn given_retention_set_when_round_tripped_then_should_preserve_policy() {
2207        for policy in [
2208            // Explicit `MirrorLog` is the default *value* but not the default
2209            // *state* (`None`). Set explicitly it must survive as `Some(MirrorLog)`,
2210            // distinct from the unset binding that omits the field entirely.
2211            RetentionPolicy::MirrorLog,
2212            RetentionPolicy::Keep,
2213            RetentionPolicy::KeepUntilSourceDeleted,
2214            RetentionPolicy::TimeToLive {
2215                ttl_micros: 3_600_000_000,
2216            },
2217            RetentionPolicy::MaxRows { rows: 10_000 },
2218        ] {
2219            let binding = ProjectionBinding::builder()
2220                .source("fleet", "telemetry")
2221                .allow("telemetry.v1")
2222                .index("telemetry_rows")
2223                .retention(policy)
2224                .build();
2225            assert_eq!(binding.retention, Some(policy));
2226            let bytes = encode_named(&binding).expect("binding serializes");
2227            let back: ProjectionBinding = decode_named(&bytes).expect("binding deserializes");
2228            assert_eq!(back.retention, Some(policy));
2229        }
2230    }
2231}
2232
2233#[cfg(all(test, feature = "codecs"))]
2234mod schema_tests {
2235    use super::*;
2236    use crate::framing::{decode_named, encode_named};
2237
2238    #[test]
2239    fn given_a_schema_def_with_version_when_round_tripped_then_should_preserve_it() {
2240        let def = SchemaDef {
2241            id: 7,
2242            source: SchemaSource::Avro {
2243                schema: "{}".to_owned(),
2244            },
2245            name: Some("readings".to_owned()),
2246            version: Some(2),
2247        };
2248        let bytes = encode_named(&def).expect("serializes");
2249        let back: SchemaDef = decode_named(&bytes).expect("deserializes");
2250        assert_eq!(back.version, Some(2));
2251        // Unset version is omitted from the wire entirely (back-compat with
2252        // pre-version registries).
2253        let unversioned = SchemaDef {
2254            name: None,
2255            version: None,
2256            ..def
2257        };
2258        let json = serde_json::to_string(&unversioned).expect("serializes");
2259        assert!(
2260            !json.contains("version"),
2261            "unset version must be omitted: {json}"
2262        );
2263    }
2264}