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