Skip to main content

laser_wire/
control.rs

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