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    /// The client that asked for the record. Absent on a record the backend or
985    /// the streaming server issues on its own, so it tells the root user, id
986    /// zero, apart from them.
987    #[serde(default, skip_serializing_if = "Option::is_none")]
988    pub caller: Option<crate::forward::Caller>,
989}
990
991/// One incarnation of a stream: a deleted and recreated stream has a new
992/// creation time even when it reuses its name and id.
993#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
994pub struct ControlStreamGeneration {
995    pub stream_id: u32,
996    pub created_at_micros: u64,
997}
998
999impl Validate for ProjectionId {
1000    fn validate(&self) -> Result<(), InvalidError> {
1001        validate_projection_id(&self.0)
1002    }
1003}
1004
1005impl Validate for Projection {
1006    fn validate(&self) -> Result<(), InvalidError> {
1007        self.id.validate()?;
1008        if !self.name.is_empty() {
1009            validate_bounded_text("projection name", &self.name, MAX_PROJECTION_NAME_BYTES)?;
1010        }
1011        for field in &self.extraction.fields {
1012            validate_bounded_text("index field name", &field.name, MAX_INDEX_FIELD_BYTES)?;
1013            validate_bounded_text("index field pointer", &field.pointer, MAX_INDEX_FIELD_BYTES)?;
1014        }
1015        if let Some(vector_field) = &self.extraction.vector_field {
1016            validate_bounded_text("index vector field", vector_field, MAX_INDEX_FIELD_BYTES)?;
1017        }
1018        if let Some(entity_schema) = &self.entity_schema {
1019            for node in &entity_schema.nodes {
1020                validate_bounded_text("graph node label", &node.label, MAX_GRAPH_LABEL_BYTES)?;
1021            }
1022            for edge in &entity_schema.edges {
1023                validate_bounded_text("graph edge type", &edge.edge_type, MAX_GRAPH_LABEL_BYTES)?;
1024            }
1025        }
1026        Ok(())
1027    }
1028}
1029
1030impl Validate for ProjectionBinding {
1031    fn validate(&self) -> Result<(), InvalidError> {
1032        for projection in &self.allowed_projections {
1033            projection.validate()?;
1034        }
1035        if let Some(projection) = &self.default_projection {
1036            projection.validate()?;
1037        }
1038        Ok(())
1039    }
1040}
1041
1042impl Validate for SchemaDef {
1043    fn validate(&self) -> Result<(), InvalidError> {
1044        if let Some(name) = &self.name {
1045            validate_bounded_text("schema name", name, MAX_SCHEMA_NAME_BYTES)?;
1046        }
1047        Ok(())
1048    }
1049}
1050
1051impl Validate for ControlCommand {
1052    fn validate(&self) -> Result<(), InvalidError> {
1053        match self {
1054            Self::RegisterProjection(projection) | Self::RegisterGraph(projection) => {
1055                projection.validate()
1056            }
1057            Self::DropProjection(id) | Self::DropGraph(id) => validate_projection_id(id),
1058            Self::ApplyBinding(binding) => binding.validate(),
1059            Self::RemoveBinding { projection_ref, .. } => projection_ref
1060                .as_deref()
1061                .map_or(Ok(()), validate_projection_id),
1062            Self::RegisterSchema(schema) => schema.validate(),
1063            Self::FilterCatalog(command) => command.mutation.validate(),
1064            Self::DropSchema(_)
1065            | Self::RegisterSessionSource { .. }
1066            | Self::RemoveSessionSource { .. }
1067            | Self::AcceptGap(_)
1068            | Self::SkipRecord(_) => Ok(()),
1069        }
1070    }
1071}
1072
1073fn validate_projection_id(id: &str) -> Result<(), InvalidError> {
1074    validate_bounded_text("projection id", id, MAX_PROJECTION_ID_BYTES)?;
1075    if id.starts_with(RESERVED_PROJECTION_ID_PREFIX) {
1076        return Err(InvalidError::new(format!(
1077            "projection id must not start with {RESERVED_PROJECTION_ID_PREFIX:?}"
1078        )));
1079    }
1080    Ok(())
1081}
1082
1083#[cfg(test)]
1084mod tests {
1085    use super::*;
1086    use crate::destination::ProjectionRef;
1087    use crate::limits::{
1088        MAX_GRAPH_LABEL_BYTES, MAX_INDEX_FIELD_BYTES, MAX_PROJECTION_ID_BYTES,
1089        MAX_PROJECTION_NAME_BYTES, MAX_SCHEMA_NAME_BYTES,
1090    };
1091
1092    #[test]
1093    fn given_an_invalid_projection_id_when_any_command_names_it_then_should_refuse_it() {
1094        let projection = |id: &str| {
1095            Projection::builder(id)
1096                .name("reading")
1097                .field("host")
1098                .build()
1099        };
1100        let at_cap = "p".repeat(MAX_PROJECTION_ID_BYTES);
1101        projection(&at_cap)
1102            .validate()
1103            .expect("a projection id at the cap is valid");
1104        ControlCommand::DropProjection(at_cap.clone())
1105            .validate()
1106            .expect("a drop of a projection id at the cap is valid");
1107        ControlCommand::RemoveBinding {
1108            source: SourceSelector::new("fleet", "readings"),
1109            projection_ref: None,
1110        }
1111        .validate()
1112        .expect("a whole-binding removal names no projection");
1113        for invalid in [
1114            String::new(),
1115            "p".repeat(MAX_PROJECTION_ID_BYTES + 1),
1116            "reading\n.v1".to_owned(),
1117            format!("{RESERVED_PROJECTION_ID_PREFIX}reading"),
1118        ] {
1119            assert!(projection(&invalid).validate().is_err(), "{invalid:?}");
1120            assert!(
1121                ControlCommand::RegisterProjection(projection(&invalid))
1122                    .validate()
1123                    .is_err()
1124            );
1125            assert!(
1126                ControlCommand::DropProjection(invalid.clone())
1127                    .validate()
1128                    .is_err()
1129            );
1130            assert!(
1131                ControlCommand::DropGraph(invalid.clone())
1132                    .validate()
1133                    .is_err()
1134            );
1135            let allowed = ProjectionBinding::builder()
1136                .source("fleet", "readings")
1137                .allow(invalid.as_str())
1138                .build();
1139            assert!(ControlCommand::ApplyBinding(allowed).validate().is_err());
1140            let defaulted = ProjectionBinding::builder()
1141                .source("fleet", "readings")
1142                .default_projection(invalid.as_str())
1143                .build();
1144            assert!(defaulted.validate().is_err());
1145            assert!(
1146                ControlCommand::RemoveBinding {
1147                    source: SourceSelector::new("fleet", "readings"),
1148                    projection_ref: Some(invalid.clone()),
1149                }
1150                .validate()
1151                .is_err()
1152            );
1153            assert!(
1154                ProjectionRef {
1155                    id: ProjectionId::new(invalid.clone()),
1156                    version: 1,
1157                }
1158                .validate()
1159                .is_err()
1160            );
1161            assert!(invalid.parse::<ProjectionId>().is_err());
1162        }
1163    }
1164
1165    #[test]
1166    fn given_a_projection_name_over_the_cap_when_validated_then_should_refuse_it_and_accept_none() {
1167        let named = |name: &str| Projection::builder("reading.v1").name(name).build();
1168        named("").validate().expect("a projection name is optional");
1169        named(&"n".repeat(MAX_PROJECTION_NAME_BYTES))
1170            .validate()
1171            .expect("a projection name at the cap is valid");
1172        assert!(
1173            named(&"n".repeat(MAX_PROJECTION_NAME_BYTES + 1))
1174                .validate()
1175                .is_err()
1176        );
1177        assert!(named("read\ting").validate().is_err());
1178    }
1179
1180    #[test]
1181    fn given_an_invalid_index_field_when_a_projection_is_validated_then_should_refuse_it() {
1182        let with_field = |name: &str, pointer: &str| {
1183            Projection::builder("reading.v1")
1184                .field_at(name, pointer)
1185                .build()
1186        };
1187        with_field("host", "/host")
1188            .validate()
1189            .expect("a plain index field is valid");
1190        let long = "f".repeat(MAX_INDEX_FIELD_BYTES + 1);
1191        assert!(with_field(&long, "/host").validate().is_err());
1192        assert!(with_field("host", &long).validate().is_err());
1193        assert!(with_field("", "/host").validate().is_err());
1194        assert!(with_field("host", "").validate().is_err());
1195        assert!(with_field("ho\u{7}st", "/host").validate().is_err());
1196        assert!(
1197            Projection::builder("reading.v1")
1198                .vector_field(long)
1199                .build()
1200                .validate()
1201                .is_err()
1202        );
1203        assert!(
1204            Projection::builder("reading.v1")
1205                .vector_field("")
1206                .build()
1207                .validate()
1208                .is_err()
1209        );
1210    }
1211
1212    #[test]
1213    fn given_an_invalid_schema_name_when_registered_then_should_refuse_it() {
1214        let schema = |name: Option<String>| SchemaDef {
1215            id: 7,
1216            source: SchemaSource::JsonSchema {
1217                schema: "{}".to_owned(),
1218            },
1219            name,
1220            version: None,
1221        };
1222        schema(None)
1223            .validate()
1224            .expect("a schema without a name is valid");
1225        ControlCommand::RegisterSchema(schema(Some("s".repeat(MAX_SCHEMA_NAME_BYTES))))
1226            .validate()
1227            .expect("a schema name at the cap is valid");
1228        for invalid in [
1229            String::new(),
1230            "s".repeat(MAX_SCHEMA_NAME_BYTES + 1),
1231            "read\ning".to_owned(),
1232        ] {
1233            assert!(schema(Some(invalid.clone())).validate().is_err());
1234            assert!(
1235                ControlCommand::RegisterSchema(schema(Some(invalid)))
1236                    .validate()
1237                    .is_err()
1238            );
1239        }
1240    }
1241
1242    #[test]
1243    fn given_an_invalid_graph_label_when_a_graph_projection_is_validated_then_should_refuse_it() {
1244        let graph = |label: &str, edge_type: &str| {
1245            Projection::builder("people.v1")
1246                .graph(EntitySchema {
1247                    nodes: vec![NodeExtract {
1248                        label: label.to_owned(),
1249                        value_pointer: "/name".to_owned(),
1250                        embedding_pointer: None,
1251                    }],
1252                    edges: vec![EdgeExtract {
1253                        edge_type: edge_type.to_owned(),
1254                        from_pointer: "/name".to_owned(),
1255                        to_pointer: "/company".to_owned(),
1256                        valid_from_pointer: None,
1257                        valid_to_pointer: None,
1258                    }],
1259                })
1260                .build()
1261        };
1262        let at_cap = "l".repeat(MAX_GRAPH_LABEL_BYTES);
1263        ControlCommand::RegisterGraph(graph(&at_cap, &at_cap))
1264            .validate()
1265            .expect("a label and an edge type at the cap are valid");
1266        let long = "l".repeat(MAX_GRAPH_LABEL_BYTES + 1);
1267        assert!(graph(&long, "works_at").validate().is_err());
1268        assert!(graph("Person", &long).validate().is_err());
1269        assert!(graph("", "works_at").validate().is_err());
1270        assert!(graph("Person", "").validate().is_err());
1271        assert!(
1272            ControlCommand::RegisterGraph(graph("Per\nson", "works_at"))
1273                .validate()
1274                .is_err()
1275        );
1276    }
1277
1278    #[test]
1279    fn given_a_binding_that_skips_rejected_records_when_encoded_then_should_carry_the_opt_in_only_when_set()
1280     {
1281        let plain = ProjectionBinding::builder().source("s", "t").build();
1282        assert!(!plain.skip_rejected);
1283        let plain_json = serde_json::to_value(&plain).unwrap();
1284        assert!(plain_json.get("skip_rejected").is_none());
1285        let opted = ProjectionBinding::builder()
1286            .source("s", "t")
1287            .skip_rejected()
1288            .build();
1289        let json = serde_json::to_value(&opted).unwrap();
1290        assert_eq!(json["skip_rejected"], true);
1291        let decoded: ProjectionBinding = serde_json::from_value(json).unwrap();
1292        assert_eq!(decoded, opted);
1293    }
1294
1295    #[test]
1296    fn given_a_record_skip_when_matched_then_should_cover_only_its_exact_record() {
1297        let skip = RecordSkip {
1298            source: SourceSelector::new("s", "t"),
1299            partition_id: 2,
1300            topic_created_at_micros: 7,
1301            offset: 40,
1302            target: "rows".to_owned(),
1303            reason: "rejected".to_owned(),
1304        };
1305        assert!(skip.covers("s", "t", 2, 7, 40, "rows"));
1306        assert!(
1307            !skip.covers("s", "t", 2, 8, 40, "rows"),
1308            "another incarnation"
1309        );
1310        assert!(!skip.covers("s", "t", 2, 7, 41, "rows"));
1311        assert!(!skip.covers("s", "t", 3, 7, 40, "rows"));
1312        assert!(!skip.covers("s", "t", 2, 7, 40, "other"));
1313        assert!(!skip.covers("s", "u", 2, 7, 40, "rows"));
1314    }
1315
1316    #[test]
1317    fn given_an_unknown_schema_source_kind_when_decoded_then_should_degrade_not_fail() {
1318        // A newer server names a `kind` this build does not know. The decode
1319        // must degrade to `Unknown` (the forward-compat catch-all) rather than
1320        // failing the whole reply, and any extra fields are ignored.
1321        let source: SchemaSource =
1322            serde_json::from_str(r#"{"kind":"parquet_descriptor","blob":[1,2,3]}"#)
1323                .expect("an unknown schema kind decodes to Unknown, not an error");
1324        assert_eq!(source, SchemaSource::Unknown);
1325        // A `SchemaDef` wrapping it reports the best-effort content type.
1326        let def = SchemaDef {
1327            id: 1,
1328            source,
1329            name: None,
1330            version: None,
1331        };
1332        assert_eq!(def.content_type(), ContentType::Any);
1333    }
1334
1335    #[test]
1336    fn given_an_unknown_retention_kind_when_decoded_then_should_degrade_not_fail() {
1337        let policy: RetentionPolicy = serde_json::from_str(r#"{"kind":"keep_for_eras","eras":3}"#)
1338            .expect("an unknown retention kind decodes to Unknown, not an error");
1339        assert_eq!(policy, RetentionPolicy::Unknown);
1340    }
1341
1342    #[test]
1343    fn given_an_explicit_operational_index_when_built_then_should_keep_one_index() {
1344        let binding = ProjectionBinding::builder()
1345            .source("fleet", "readings")
1346            .allow("reading.v1")
1347            .index("readings_rows")
1348            .build();
1349        assert_eq!(binding.index, "readings_rows");
1350        assert_eq!(binding.backend, None);
1351    }
1352
1353    #[test]
1354    fn given_an_operational_backend_when_built_then_should_keep_exact_generation() {
1355        let backend = BackendBinding {
1356            resource_id: crate::destination::BackendResourceId::from_u128(7),
1357            generation: 3,
1358        };
1359        let binding = ProjectionBinding::builder()
1360            .source("fleet", "readings")
1361            .backend(backend.clone())
1362            .build();
1363        assert_eq!(binding.backend, Some(backend));
1364    }
1365
1366    #[test]
1367    fn given_no_operational_index_when_built_then_should_use_the_topic_name() {
1368        let binding = ProjectionBinding::builder()
1369            .source("fleet", "readings")
1370            .allow("reading.v1")
1371            .build();
1372        assert_eq!(binding.index, "readings");
1373    }
1374
1375    #[test]
1376    fn given_no_retention_when_built_then_should_default_to_none() {
1377        let binding = ProjectionBinding::builder()
1378            .source("fleet", "readings")
1379            .index("readings_rows")
1380            .build();
1381        assert_eq!(binding.retention, None);
1382    }
1383
1384    #[test]
1385    fn given_no_source_when_try_built_then_should_error() {
1386        assert!(ProjectionBinding::builder().try_build().is_err());
1387    }
1388
1389    #[test]
1390    fn given_a_projection_built_with_the_default_when_inspected_then_should_inline_payload() {
1391        let projection = Projection::builder("api.call.v1")
1392            .name("api.call")
1393            .version(1)
1394            .fields(["endpoint", "status"])
1395            .build();
1396        assert!(
1397            projection.inline_payload_default,
1398            "Projection::builder default should inline payload"
1399        );
1400        assert!(
1401            projection.extraction.inline_payload,
1402            "Projection::builder default should mark extraction.inline_payload too"
1403        );
1404    }
1405
1406    #[test]
1407    fn given_a_projection_with_index_only_when_inspected_then_should_skip_inlining() {
1408        let projection = Projection::builder("api.call.v1")
1409            .name("api.call")
1410            .version(1)
1411            .fields(["endpoint"])
1412            .index_only()
1413            .build();
1414        assert!(
1415            !projection.inline_payload_default,
1416            "index_only must clear inline_payload_default"
1417        );
1418        assert!(
1419            !projection.extraction.inline_payload,
1420            "index_only must clear extraction.inline_payload"
1421        );
1422    }
1423
1424    #[test]
1425    fn given_an_empty_projection_id_when_parsed_then_should_error() {
1426        assert!("".parse::<ProjectionId>().is_err());
1427        assert_eq!(
1428            "reading.v1"
1429                .parse::<ProjectionId>()
1430                .expect("non-empty id parses")
1431                .as_str(),
1432            "reading.v1"
1433        );
1434    }
1435}
1436
1437#[cfg(all(test, feature = "cbor"))]
1438mod wire_tests {
1439    use super::*;
1440    use crate::codes::CONTROL_OP_VERSION;
1441    use crate::content::ContentType;
1442    use crate::framing::{decode_named, encode_named};
1443
1444    #[test]
1445    fn given_an_apply_binding_when_round_tripped_then_should_preserve_index_and_version() {
1446        let binding = ProjectionBinding::builder()
1447            .source("fleet", "readings")
1448            .allow("reading.v1")
1449            .default_projection("reading.v1")
1450            .index("readings_rows")
1451            .build();
1452        let envelope = ControlEnvelope {
1453            v: CONTROL_OP_VERSION,
1454            timestamp_micros: 42,
1455            command: ControlCommand::ApplyBinding(binding),
1456            stream: None,
1457            stamp: None,
1458        };
1459        let bytes = encode_named(&envelope).expect("envelope serializes");
1460        let back: ControlEnvelope = decode_named(&bytes).expect("envelope deserializes");
1461        assert_eq!(back.v, CONTROL_OP_VERSION);
1462        let ControlCommand::ApplyBinding(decoded) = back.command else {
1463            panic!("expected ApplyBinding");
1464        };
1465        assert_eq!(decoded.index, "readings_rows");
1466        assert_eq!(decoded.backend, None);
1467        // Retention left unset stays unset across the wire (inherits LaserData Cloud
1468        // default), and is omitted from the encoding entirely.
1469        assert_eq!(decoded.retention, None);
1470    }
1471
1472    #[test]
1473    fn given_a_register_schema_when_round_tripped_then_should_preserve_source() {
1474        let envelope = ControlEnvelope {
1475            v: CONTROL_OP_VERSION,
1476            timestamp_micros: 7,
1477            command: ControlCommand::RegisterSchema(SchemaDef {
1478                id: 11,
1479                source: SchemaSource::Avro {
1480                    schema: r#"{"type":"record","name":"Reading","fields":[]}"#.to_owned(),
1481                },
1482                name: None,
1483                version: None,
1484            }),
1485            stream: None,
1486            stamp: None,
1487        };
1488        let bytes = encode_named(&envelope).expect("envelope serializes");
1489        let back: ControlEnvelope = decode_named(&bytes).expect("envelope deserializes");
1490        assert_eq!(back.stream, None);
1491        let ControlCommand::RegisterSchema(decoded) = back.command else {
1492            panic!("expected RegisterSchema");
1493        };
1494        assert_eq!(decoded.id, 11);
1495        assert_eq!(decoded.content_type(), ContentType::Avro);
1496    }
1497
1498    #[test]
1499    fn given_a_stream_scoped_drop_schema_when_round_tripped_then_should_preserve_the_stream() {
1500        let envelope = ControlEnvelope {
1501            v: CONTROL_OP_VERSION,
1502            timestamp_micros: 7,
1503            command: ControlCommand::DropSchema(11),
1504            stream: Some("acme".to_owned()),
1505            stamp: None,
1506        };
1507        let back: ControlEnvelope =
1508            decode_named(&encode_named(&envelope).expect("envelope serializes"))
1509                .expect("envelope deserializes");
1510        assert_eq!(back.stream.as_deref(), Some("acme"));
1511        assert!(matches!(back.command, ControlCommand::DropSchema(11)));
1512        let unscoped = ControlEnvelope {
1513            stream: None,
1514            ..envelope
1515        };
1516        let json = serde_json::to_string(&unscoped).expect("envelope serializes");
1517        assert!(
1518            !json.contains("stream"),
1519            "an unset stream is omitted: {json}"
1520        );
1521    }
1522
1523    #[test]
1524    fn given_a_stamped_envelope_when_round_tripped_then_should_keep_the_identity_and_omit_an_unset_stamp()
1525     {
1526        let envelope = ControlEnvelope {
1527            v: CONTROL_OP_VERSION,
1528            timestamp_micros: 7,
1529            command: ControlCommand::DropSchema(11),
1530            stream: Some("acme".to_owned()),
1531            stamp: Some(ControlStamp {
1532                deployment_id: 42,
1533                actor_user_id: 3,
1534                operation_id: u128::MAX - 1,
1535                stream_generation: Some(ControlStreamGeneration {
1536                    stream_id: 9,
1537                    created_at_micros: 1_700_000_000_000_000,
1538                }),
1539                key_id: u64::MAX,
1540                caller: None,
1541            }),
1542        };
1543        let back: ControlEnvelope =
1544            decode_named(&encode_named(&envelope).expect("envelope serializes"))
1545                .expect("envelope deserializes");
1546        assert_eq!(back.stamp, envelope.stamp);
1547        let unstamped = ControlEnvelope {
1548            stamp: None,
1549            ..envelope
1550        };
1551        let json = serde_json::to_string(&unstamped).expect("envelope serializes");
1552        assert!(
1553            !json.contains("\"stamp\""),
1554            "an unset stamp is omitted: {json}"
1555        );
1556    }
1557
1558    #[test]
1559    fn given_a_protobuf_schema_source_when_round_tripped_then_should_preserve_bytes() {
1560        let source = SchemaSource::Protobuf {
1561            descriptor_set: vec![10, 20, 30],
1562            message_type: "fleet.Reading".to_owned(),
1563        };
1564        let bytes = encode_named(&source).expect("serializes");
1565        let back: SchemaSource = decode_named(&bytes).expect("deserializes");
1566        assert_eq!(back, source);
1567    }
1568
1569    #[test]
1570    fn given_retention_set_when_round_tripped_then_should_preserve_policy() {
1571        for policy in [
1572            // Explicit `MirrorLog` is the default *value* but not the default
1573            // *state* (`None`). Set explicitly it must survive as `Some(MirrorLog)`,
1574            // distinct from the unset binding that omits the field entirely.
1575            RetentionPolicy::MirrorLog,
1576            RetentionPolicy::Keep,
1577            RetentionPolicy::KeepUntilSourceDeleted,
1578            RetentionPolicy::TimeToLive {
1579                ttl_micros: 3_600_000_000,
1580            },
1581            RetentionPolicy::MaxRows { rows: 10_000 },
1582        ] {
1583            let binding = ProjectionBinding::builder()
1584                .source("fleet", "telemetry")
1585                .allow("telemetry.v1")
1586                .index("telemetry_rows")
1587                .retention(policy)
1588                .build();
1589            assert_eq!(binding.retention, Some(policy));
1590            let bytes = encode_named(&binding).expect("binding serializes");
1591            let back: ProjectionBinding = decode_named(&bytes).expect("binding deserializes");
1592            assert_eq!(back.retention, Some(policy));
1593        }
1594    }
1595}
1596
1597#[cfg(all(test, feature = "codecs"))]
1598mod schema_tests {
1599    use super::*;
1600    use crate::framing::{decode_named, encode_named};
1601
1602    #[test]
1603    fn given_a_schema_def_with_version_when_round_tripped_then_should_preserve_it() {
1604        let def = SchemaDef {
1605            id: 7,
1606            source: SchemaSource::Avro {
1607                schema: "{}".to_owned(),
1608            },
1609            name: Some("readings".to_owned()),
1610            version: Some(2),
1611        };
1612        let bytes = encode_named(&def).expect("serializes");
1613        let back: SchemaDef = decode_named(&bytes).expect("deserializes");
1614        assert_eq!(back.version, Some(2));
1615        // Unset version is omitted from the wire entirely (back-compat with
1616        // pre-version registries).
1617        let unversioned = SchemaDef {
1618            name: None,
1619            version: None,
1620            ..def
1621        };
1622        let json = serde_json::to_string(&unversioned).expect("serializes");
1623        assert!(
1624            !json.contains("version"),
1625            "unset version must be omitted: {json}"
1626        );
1627    }
1628}