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