Skip to main content

laser_wire/
control.rs

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