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.customer_id=alice`
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("customer")`
112    /// expands to "extract `/customer` from the payload and store it under
113    /// the index key `customer`". 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("amount_cents", "/amount/value")` indexes `amount.value` as
126    /// `amount_cents`.
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. `"order.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. `"order"`). 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 (`order.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, and the only policy that also deletes the
538    /// 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. `"shop.Order"`.
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 customer 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 a run-status source: LaserData Cloud folds run-tagged agent
824    /// records from this topic into the run registry. Idempotent by source.
825    RegisterRunSource(SourceSelector),
826    /// Stop folding run-status records from this topic. Idempotent.
827    RemoveRunSource(SourceSelector),
828    /// One consumer-filter catalog mutation, stamped by the streaming server.
829    /// Its outcome (applied or rejected) is recorded under the operation id.
830    FilterCatalog(crate::filter::FilterCatalogCommand),
831}
832
833/// Versioned wrapper around a [`ControlCommand`], CBOR-named on the wire.
834#[derive(Clone, Debug, Serialize, Deserialize)]
835pub struct ControlEnvelope {
836    pub v: u32,
837    pub timestamp_micros: u64,
838    pub command: ControlCommand,
839}
840
841#[cfg(test)]
842mod tests {
843    use super::*;
844
845    #[test]
846    fn given_an_unknown_schema_source_kind_when_decoded_then_should_degrade_not_fail() {
847        // A newer server names a `kind` this build does not know. The decode
848        // must degrade to `Unknown` (the forward-compat catch-all) rather than
849        // failing the whole reply, and any extra fields are ignored.
850        let source: SchemaSource =
851            serde_json::from_str(r#"{"kind":"parquet_descriptor","blob":[1,2,3]}"#)
852                .expect("an unknown schema kind decodes to Unknown, not an error");
853        assert_eq!(source, SchemaSource::Unknown);
854        // A `SchemaDef` wrapping it reports the best-effort content type.
855        let def = SchemaDef {
856            id: 1,
857            source,
858            name: None,
859            version: None,
860        };
861        assert_eq!(def.content_type(), ContentType::Any);
862    }
863
864    #[test]
865    fn given_an_unknown_retention_kind_when_decoded_then_should_degrade_not_fail() {
866        let policy: RetentionPolicy = serde_json::from_str(r#"{"kind":"keep_for_eras","eras":3}"#)
867            .expect("an unknown retention kind decodes to Unknown, not an error");
868        assert_eq!(policy, RetentionPolicy::Unknown);
869    }
870
871    #[test]
872    fn given_an_explicit_operational_index_when_built_then_should_keep_one_index() {
873        let binding = ProjectionBinding::builder()
874            .source("shop", "orders")
875            .allow("order.v1")
876            .index("orders_rows")
877            .build();
878        assert_eq!(binding.index, "orders_rows");
879        assert_eq!(binding.backend, None);
880    }
881
882    #[test]
883    fn given_an_operational_backend_when_built_then_should_keep_exact_generation() {
884        let backend = BackendBinding {
885            resource_id: crate::destination::BackendResourceId::from_u128(7),
886            generation: 3,
887        };
888        let binding = ProjectionBinding::builder()
889            .source("shop", "orders")
890            .backend(backend.clone())
891            .build();
892        assert_eq!(binding.backend, Some(backend));
893    }
894
895    #[test]
896    fn given_no_operational_index_when_built_then_should_use_the_topic_name() {
897        let binding = ProjectionBinding::builder()
898            .source("shop", "orders")
899            .allow("order.v1")
900            .build();
901        assert_eq!(binding.index, "orders");
902    }
903
904    #[test]
905    fn given_no_retention_when_built_then_should_default_to_none() {
906        let binding = ProjectionBinding::builder()
907            .source("shop", "orders")
908            .index("orders_rows")
909            .build();
910        assert_eq!(binding.retention, None);
911    }
912
913    #[test]
914    fn given_no_source_when_try_built_then_should_error() {
915        assert!(ProjectionBinding::builder().try_build().is_err());
916    }
917
918    #[test]
919    fn given_a_projection_built_with_the_default_when_inspected_then_should_inline_payload() {
920        let projection = Projection::builder("api.call.v1")
921            .name("api.call")
922            .version(1)
923            .fields(["endpoint", "status"])
924            .build();
925        assert!(
926            projection.inline_payload_default,
927            "Projection::builder default should inline payload"
928        );
929        assert!(
930            projection.extraction.inline_payload,
931            "Projection::builder default should mark extraction.inline_payload too"
932        );
933    }
934
935    #[test]
936    fn given_a_projection_with_index_only_when_inspected_then_should_skip_inlining() {
937        let projection = Projection::builder("api.call.v1")
938            .name("api.call")
939            .version(1)
940            .fields(["endpoint"])
941            .index_only()
942            .build();
943        assert!(
944            !projection.inline_payload_default,
945            "index_only must clear inline_payload_default"
946        );
947        assert!(
948            !projection.extraction.inline_payload,
949            "index_only must clear extraction.inline_payload"
950        );
951    }
952
953    #[test]
954    fn given_an_empty_projection_id_when_parsed_then_should_error() {
955        assert!("".parse::<ProjectionId>().is_err());
956        assert_eq!(
957            "order.v1"
958                .parse::<ProjectionId>()
959                .expect("non-empty id parses")
960                .as_str(),
961            "order.v1"
962        );
963    }
964}
965
966#[cfg(all(test, feature = "cbor"))]
967mod wire_tests {
968    use super::*;
969    use crate::codes::CONTROL_OP_VERSION;
970    use crate::content::ContentType;
971    use crate::framing::{decode_named, encode_named};
972
973    #[test]
974    fn given_an_apply_binding_when_round_tripped_then_should_preserve_index_and_version() {
975        let binding = ProjectionBinding::builder()
976            .source("shop", "orders")
977            .allow("order.v1")
978            .default_projection("order.v1")
979            .index("orders_rows")
980            .build();
981        let envelope = ControlEnvelope {
982            v: CONTROL_OP_VERSION,
983            timestamp_micros: 42,
984            command: ControlCommand::ApplyBinding(binding),
985        };
986        let bytes = encode_named(&envelope).expect("envelope serializes");
987        let back: ControlEnvelope = decode_named(&bytes).expect("envelope deserializes");
988        assert_eq!(back.v, CONTROL_OP_VERSION);
989        let ControlCommand::ApplyBinding(decoded) = back.command else {
990            panic!("expected ApplyBinding");
991        };
992        assert_eq!(decoded.index, "orders_rows");
993        assert_eq!(decoded.backend, None);
994        // Retention left unset stays unset across the wire (inherits LaserData Cloud
995        // default), and is omitted from the encoding entirely.
996        assert_eq!(decoded.retention, None);
997    }
998
999    #[test]
1000    fn given_a_register_schema_when_round_tripped_then_should_preserve_source() {
1001        let envelope = ControlEnvelope {
1002            v: CONTROL_OP_VERSION,
1003            timestamp_micros: 7,
1004            command: ControlCommand::RegisterSchema(SchemaDef {
1005                id: 11,
1006                source: SchemaSource::Avro {
1007                    schema: r#"{"type":"record","name":"Order","fields":[]}"#.to_owned(),
1008                },
1009                name: None,
1010                version: None,
1011            }),
1012        };
1013        let bytes = encode_named(&envelope).expect("envelope serializes");
1014        let back: ControlEnvelope = decode_named(&bytes).expect("envelope deserializes");
1015        let ControlCommand::RegisterSchema(decoded) = back.command else {
1016            panic!("expected RegisterSchema");
1017        };
1018        assert_eq!(decoded.id, 11);
1019        assert_eq!(decoded.content_type(), ContentType::Avro);
1020    }
1021
1022    #[test]
1023    fn given_a_protobuf_schema_source_when_round_tripped_then_should_preserve_bytes() {
1024        let source = SchemaSource::Protobuf {
1025            descriptor_set: vec![10, 20, 30],
1026            message_type: "shop.Order".to_owned(),
1027        };
1028        let bytes = encode_named(&source).expect("serializes");
1029        let back: SchemaSource = decode_named(&bytes).expect("deserializes");
1030        assert_eq!(back, source);
1031    }
1032
1033    #[test]
1034    fn given_retention_set_when_round_tripped_then_should_preserve_policy() {
1035        for policy in [
1036            // Explicit `MirrorLog` is the default *value* but not the default
1037            // *state* (`None`). Set explicitly it must survive as `Some(MirrorLog)`,
1038            // distinct from the unset binding that omits the field entirely.
1039            RetentionPolicy::MirrorLog,
1040            RetentionPolicy::Keep,
1041            RetentionPolicy::KeepUntilSourceDeleted,
1042            RetentionPolicy::TimeToLive {
1043                ttl_micros: 3_600_000_000,
1044            },
1045            RetentionPolicy::MaxRows { rows: 10_000 },
1046        ] {
1047            let binding = ProjectionBinding::builder()
1048                .source("shop", "telemetry")
1049                .allow("telemetry.v1")
1050                .index("telemetry_rows")
1051                .retention(policy)
1052                .build();
1053            assert_eq!(binding.retention, Some(policy));
1054            let bytes = encode_named(&binding).expect("binding serializes");
1055            let back: ProjectionBinding = decode_named(&bytes).expect("binding deserializes");
1056            assert_eq!(back.retention, Some(policy));
1057        }
1058    }
1059}
1060
1061#[cfg(all(test, feature = "codecs"))]
1062mod schema_tests {
1063    use super::*;
1064    use crate::framing::{decode_named, encode_named};
1065
1066    #[test]
1067    fn given_a_schema_def_with_version_when_round_tripped_then_should_preserve_it() {
1068        let def = SchemaDef {
1069            id: 7,
1070            source: SchemaSource::Avro {
1071                schema: "{}".to_owned(),
1072            },
1073            name: Some("orders".to_owned()),
1074            version: Some(2),
1075        };
1076        let bytes = encode_named(&def).expect("serializes");
1077        let back: SchemaDef = decode_named(&bytes).expect("deserializes");
1078        assert_eq!(back.version, Some(2));
1079        // Unset version is omitted from the wire entirely (back-compat with
1080        // pre-version registries).
1081        let unversioned = SchemaDef {
1082            name: None,
1083            version: None,
1084            ..def
1085        };
1086        let json = serde_json::to_string(&unversioned).expect("serializes");
1087        assert!(
1088            !json.contains("version"),
1089            "unset version must be omitted: {json}"
1090        );
1091    }
1092}