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/// Global reusable projection definition. Names the extraction rules that turn
214/// a payload into a queryable row. Not attached to a topic on its own:
215/// bindings ([`ProjectionBinding`]) declare where projections may apply.
216///
217/// # Storage model
218///
219/// A published record lives in up to three places, controlled by the projection:
220///
221/// 1. **Iggy log** (always): the original wire bytes, partitioned, replayable
222///    from offset 0. The source of truth.
223/// 2. **Indexed columns** (always): the scalar fields declared via
224///    `field` / `field_at`, extracted from the payload at materialize time
225///    and stored in the row. These drive filters, ordering, and aggregates.
226/// 3. **Inline body** (opt-in via `inline_payload`, on by default through
227///    `Projection::builder`): a copy of the full original payload alongside
228///    the row, so typed fetches can decode it without going back to the log.
229///
230/// The body may carry fields that are not indexed. Only the declared fields
231/// are queryable. Everything else rides through as part of the inlined body
232/// (when on) or is reachable only by Iggy replay (when off).
233/// What a projection materializes: queryable rows, or a knowledge graph of
234/// nodes and edges. Rides the wire as a u8 code (the growable-dictionary pattern)
235/// so a future kind flows through an old reader as
236/// [`Unrecognized`](Self::Unrecognized) rather than failing the listing.
237#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
238#[serde(from = "u8", into = "u8")]
239pub enum ProjectionKind {
240    /// Materialize queryable rows (the default).
241    #[default]
242    Row,
243    /// Materialize a knowledge graph (nodes, edges, triplets).
244    Graph,
245    /// A code this build does not know, passed through.
246    Unrecognized(u8),
247}
248
249impl ProjectionKind {
250    /// The pinned wire code.
251    pub const fn code(self) -> u8 {
252        match self {
253            ProjectionKind::Row => 0,
254            ProjectionKind::Graph => 1,
255            ProjectionKind::Unrecognized(code) => code,
256        }
257    }
258
259    /// The kind for a wire code (unknown codes become
260    /// [`Unrecognized`](Self::Unrecognized)).
261    pub const fn from_code(code: u8) -> Self {
262        match code {
263            0 => ProjectionKind::Row,
264            1 => ProjectionKind::Graph,
265            other => ProjectionKind::Unrecognized(other),
266        }
267    }
268
269    /// Whether this is the default `Row` kind (omitted on the wire).
270    pub const fn is_row(&self) -> bool {
271        matches!(self, ProjectionKind::Row)
272    }
273}
274
275impl From<u8> for ProjectionKind {
276    fn from(code: u8) -> Self {
277        Self::from_code(code)
278    }
279}
280
281impl From<ProjectionKind> for u8 {
282    fn from(kind: ProjectionKind) -> u8 {
283        kind.code()
284    }
285}
286
287/// The declared plan for extracting nodes and edges from a payload. Pointer-based
288/// (RFC 6901) and deterministic, so no model call is needed. Recorded on a graph
289/// projection for discovery and as the extraction contract. Graph data is written
290/// via the graph upsert op. Applying this plan to a bound source is managed-side.
291#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
292pub struct EntitySchema {
293    pub nodes: Vec<NodeExtract>,
294    #[serde(default, skip_serializing_if = "Vec::is_empty")]
295    pub edges: Vec<EdgeExtract>,
296}
297
298/// One node-extraction rule: the node's label and the pointer to its canonical
299/// value (which content-addresses its id, so the same entity converges).
300#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
301pub struct NodeExtract {
302    pub label: String,
303    pub value_pointer: String,
304    #[serde(default, skip_serializing_if = "Option::is_none")]
305    pub embedding_pointer: Option<String>,
306}
307
308/// One edge-extraction rule: the edge type, the pointers to its endpoint values,
309/// and optional pointers to the bitemporal valid-time window.
310#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
311pub struct EdgeExtract {
312    pub edge_type: String,
313    pub from_pointer: String,
314    pub to_pointer: String,
315    /// RFC-6901 pointer to the valid-time start (epoch micros) for the extracted
316    /// edge, making it a bitemporal fact. `None` leaves the window open.
317    #[serde(default, skip_serializing_if = "Option::is_none")]
318    pub valid_from_pointer: Option<String>,
319    /// RFC-6901 pointer to the valid-time end (epoch micros). `None` leaves the
320    /// edge open-ended (still valid).
321    #[serde(default, skip_serializing_if = "Option::is_none")]
322    pub valid_to_pointer: Option<String>,
323}
324
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    /// Opt into the change feed: one `ChangeRecord` per committed projector
702    /// batch for this binding, on the changes topic.
703    #[must_use]
704    pub fn notify(mut self) -> Self {
705        self.notify = true;
706        self
707    }
708
709    /// to several backends at once (e.g. the embedded engine for low-latency
710    /// reads plus an external warehouse for analytics).
711    pub fn mirror_to(self, backend: impl Into<String>, table: impl Into<String>) -> Self {
712        self.add_target(Target {
713            backend: backend.into(),
714            table: table.into(),
715            role: TargetRole::WriteOnly,
716            delivery: Delivery::EffectivelyOnce,
717            required: false,
718        })
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}
933
934/// Versioned wrapper around a [`ControlCommand`], CBOR-named on the wire.
935#[derive(Clone, Debug, Serialize, Deserialize)]
936pub struct ControlEnvelope {
937    pub v: u32,
938    pub timestamp_micros: u64,
939    pub command: ControlCommand,
940}
941
942#[cfg(test)]
943mod tests {
944    use super::*;
945
946    #[test]
947    fn given_an_unknown_schema_source_kind_when_decoded_then_should_degrade_not_fail() {
948        // A newer server names a `kind` this build does not know. The decode
949        // must degrade to `Unknown` (the forward-compat catch-all) rather than
950        // failing the whole reply, and any extra fields are ignored.
951        let source: SchemaSource =
952            serde_json::from_str(r#"{"kind":"parquet_descriptor","blob":[1,2,3]}"#)
953                .expect("an unknown schema kind decodes to Unknown, not an error");
954        assert_eq!(source, SchemaSource::Unknown);
955        // A `SchemaDef` wrapping it reports the best-effort content type.
956        let def = SchemaDef {
957            id: 1,
958            source,
959            name: None,
960            version: None,
961        };
962        assert_eq!(def.content_type(), ContentType::Any);
963    }
964
965    #[test]
966    fn given_an_unknown_retention_kind_when_decoded_then_should_degrade_not_fail() {
967        let policy: RetentionPolicy = serde_json::from_str(r#"{"kind":"keep_for_eras","eras":3}"#)
968            .expect("an unknown retention kind decodes to Unknown, not an error");
969        assert_eq!(policy, RetentionPolicy::Unknown);
970    }
971
972    #[test]
973    fn given_target_table_sugar_when_built_then_should_be_single_read_write_target() {
974        let binding = ProjectionBinding::builder()
975            .source("shop", "orders")
976            .allow("order.v1")
977            .target_table("orders_rows")
978            .build();
979        assert_eq!(binding.targets.len(), 1);
980        assert_eq!(binding.targets[0].backend, "embedded");
981        assert_eq!(binding.targets[0].table, "orders_rows");
982        assert_eq!(binding.targets[0].role, TargetRole::ReadWrite);
983        assert_eq!(binding.targets[0].delivery, Delivery::EffectivelyOnce);
984        assert!(binding.targets[0].required);
985    }
986
987    #[test]
988    fn given_target_on_named_backend_when_built_then_should_route_read_write_to_it() {
989        let binding = ProjectionBinding::builder()
990            .source("shop", "orders")
991            .allow("order.v1")
992            .target_on("warehouse", "orders_rows")
993            .build();
994        assert_eq!(binding.targets.len(), 1);
995        assert_eq!(binding.targets[0].backend, "warehouse");
996        assert_eq!(binding.targets[0].table, "orders_rows");
997        assert_eq!(binding.targets[0].role, TargetRole::ReadWrite);
998        assert!(binding.targets[0].required);
999    }
1000
1001    #[test]
1002    fn given_target_on_and_mirror_to_when_built_then_should_fan_one_projection_to_two_backends() {
1003        // Read-serve from the embedded engine, mirror the same rows into an
1004        // external warehouse: one projection, two backends at once.
1005        let binding = ProjectionBinding::builder()
1006            .source("shop", "orders")
1007            .allow("order.v1")
1008            .target_on("embedded", "orders_rows")
1009            .mirror_to("warehouse", "orders_warehouse")
1010            .build();
1011        assert_eq!(binding.targets.len(), 2);
1012        assert_eq!(binding.targets[0].role, TargetRole::ReadWrite);
1013        assert_eq!(binding.targets[0].backend, "embedded");
1014        assert_eq!(binding.targets[1].role, TargetRole::WriteOnly);
1015        assert_eq!(binding.targets[1].backend, "warehouse");
1016        assert_eq!(binding.targets[1].table, "orders_warehouse");
1017        assert!(!binding.targets[1].required, "a mirror is non-blocking");
1018    }
1019
1020    #[test]
1021    fn given_a_read_write_target_and_a_mirror_when_added_then_should_keep_both_in_order() {
1022        let binding = ProjectionBinding::builder()
1023            .source("shop", "orders")
1024            .allow("order.v1")
1025            .target_table("orders_rows")
1026            .add_target(Target {
1027                backend: "warehouse".to_owned(),
1028                table: "orders_mirror".to_owned(),
1029                role: TargetRole::WriteOnly,
1030                delivery: Delivery::AtMostOnce,
1031                required: false,
1032            })
1033            .build();
1034        assert_eq!(binding.targets.len(), 2);
1035        assert_eq!(binding.targets[0].role, TargetRole::ReadWrite);
1036        assert_eq!(binding.targets[1].role, TargetRole::WriteOnly);
1037        assert_eq!(binding.targets[1].backend, "warehouse");
1038    }
1039
1040    #[test]
1041    fn given_no_retention_when_built_then_should_default_to_none() {
1042        let binding = ProjectionBinding::builder()
1043            .source("shop", "orders")
1044            .target_table("orders_rows")
1045            .build();
1046        assert_eq!(binding.retention, None);
1047    }
1048
1049    #[test]
1050    fn given_no_source_when_try_built_then_should_error() {
1051        assert!(ProjectionBinding::builder().try_build().is_err());
1052    }
1053
1054    #[test]
1055    fn given_a_projection_built_with_the_default_when_inspected_then_should_inline_payload() {
1056        let projection = Projection::builder("api.call.v1")
1057            .name("api.call")
1058            .version(1)
1059            .fields(["endpoint", "status"])
1060            .build();
1061        assert!(
1062            projection.inline_payload_default,
1063            "Projection::builder default should inline payload"
1064        );
1065        assert!(
1066            projection.extraction.inline_payload,
1067            "Projection::builder default should mark extraction.inline_payload too"
1068        );
1069    }
1070
1071    #[test]
1072    fn given_a_projection_with_index_only_when_inspected_then_should_skip_inlining() {
1073        let projection = Projection::builder("api.call.v1")
1074            .name("api.call")
1075            .version(1)
1076            .fields(["endpoint"])
1077            .index_only()
1078            .build();
1079        assert!(
1080            !projection.inline_payload_default,
1081            "index_only must clear inline_payload_default"
1082        );
1083        assert!(
1084            !projection.extraction.inline_payload,
1085            "index_only must clear extraction.inline_payload"
1086        );
1087    }
1088
1089    #[test]
1090    fn given_an_empty_projection_id_when_parsed_then_should_error() {
1091        assert!("".parse::<ProjectionId>().is_err());
1092        assert_eq!(
1093            "order.v1"
1094                .parse::<ProjectionId>()
1095                .expect("non-empty id parses")
1096                .as_str(),
1097            "order.v1"
1098        );
1099    }
1100}
1101
1102#[cfg(all(test, feature = "cbor"))]
1103mod wire_tests {
1104    use super::*;
1105    use crate::codes::CONTROL_OP_VERSION;
1106    use crate::content::ContentType;
1107    use crate::framing::{decode_named, encode_named};
1108
1109    #[test]
1110    fn given_an_apply_binding_when_round_tripped_then_should_preserve_targets_and_version() {
1111        let binding = ProjectionBinding::builder()
1112            .source("shop", "orders")
1113            .allow("order.v1")
1114            .default_projection("order.v1")
1115            .target_table("orders_rows")
1116            .add_target(Target {
1117                backend: "warehouse".to_owned(),
1118                table: "orders_mirror".to_owned(),
1119                role: TargetRole::WriteOnly,
1120                delivery: Delivery::AtMostOnce,
1121                required: false,
1122            })
1123            .build();
1124        let envelope = ControlEnvelope {
1125            v: CONTROL_OP_VERSION,
1126            timestamp_micros: 42,
1127            command: ControlCommand::ApplyBinding(binding),
1128        };
1129        let bytes = encode_named(&envelope).expect("envelope serializes");
1130        let back: ControlEnvelope = decode_named(&bytes).expect("envelope deserializes");
1131        assert_eq!(back.v, CONTROL_OP_VERSION);
1132        let ControlCommand::ApplyBinding(decoded) = back.command else {
1133            panic!("expected ApplyBinding");
1134        };
1135        assert_eq!(decoded.targets.len(), 2);
1136        assert_eq!(decoded.targets[0].table, "orders_rows");
1137        assert_eq!(decoded.targets[0].role, TargetRole::ReadWrite);
1138        assert_eq!(decoded.targets[1].table, "orders_mirror");
1139        assert_eq!(decoded.targets[1].delivery, Delivery::AtMostOnce);
1140        // Retention left unset stays unset across the wire (inherits LaserData Cloud
1141        // default), and is omitted from the encoding entirely.
1142        assert_eq!(decoded.retention, None);
1143    }
1144
1145    #[test]
1146    fn given_a_register_schema_when_round_tripped_then_should_preserve_source() {
1147        let envelope = ControlEnvelope {
1148            v: CONTROL_OP_VERSION,
1149            timestamp_micros: 7,
1150            command: ControlCommand::RegisterSchema(SchemaDef {
1151                id: 11,
1152                source: SchemaSource::Avro {
1153                    schema: r#"{"type":"record","name":"Order","fields":[]}"#.to_owned(),
1154                },
1155                name: None,
1156                version: None,
1157            }),
1158        };
1159        let bytes = encode_named(&envelope).expect("envelope serializes");
1160        let back: ControlEnvelope = decode_named(&bytes).expect("envelope deserializes");
1161        let ControlCommand::RegisterSchema(decoded) = back.command else {
1162            panic!("expected RegisterSchema");
1163        };
1164        assert_eq!(decoded.id, 11);
1165        assert_eq!(decoded.content_type(), ContentType::Avro);
1166    }
1167
1168    #[test]
1169    fn given_a_protobuf_schema_source_when_round_tripped_then_should_preserve_bytes() {
1170        let source = SchemaSource::Protobuf {
1171            descriptor_set: vec![10, 20, 30],
1172            message_type: "shop.Order".to_owned(),
1173        };
1174        let bytes = encode_named(&source).expect("serializes");
1175        let back: SchemaSource = decode_named(&bytes).expect("deserializes");
1176        assert_eq!(back, source);
1177    }
1178
1179    #[test]
1180    fn given_retention_set_when_round_tripped_then_should_preserve_policy() {
1181        for policy in [
1182            // Explicit `MirrorLog` is the default *value* but not the default
1183            // *state* (`None`). Set explicitly it must survive as `Some(MirrorLog)`,
1184            // distinct from the unset binding that omits the field entirely.
1185            RetentionPolicy::MirrorLog,
1186            RetentionPolicy::Keep,
1187            RetentionPolicy::KeepUntilSourceDeleted,
1188            RetentionPolicy::TimeToLive {
1189                ttl_micros: 3_600_000_000,
1190            },
1191            RetentionPolicy::MaxRows { rows: 10_000 },
1192        ] {
1193            let binding = ProjectionBinding::builder()
1194                .source("shop", "telemetry")
1195                .allow("telemetry.v1")
1196                .target_table("telemetry_rows")
1197                .retention(policy)
1198                .build();
1199            assert_eq!(binding.retention, Some(policy));
1200            let bytes = encode_named(&binding).expect("binding serializes");
1201            let back: ProjectionBinding = decode_named(&bytes).expect("binding deserializes");
1202            assert_eq!(back.retention, Some(policy));
1203        }
1204    }
1205}
1206
1207#[cfg(all(test, feature = "codecs"))]
1208mod schema_tests {
1209    use super::*;
1210    use crate::framing::{decode_named, encode_named};
1211
1212    #[test]
1213    fn given_a_schema_def_with_version_when_round_tripped_then_should_preserve_it() {
1214        let def = SchemaDef {
1215            id: 7,
1216            source: SchemaSource::Avro {
1217                schema: "{}".to_owned(),
1218            },
1219            name: Some("orders".to_owned()),
1220            version: Some(2),
1221        };
1222        let bytes = encode_named(&def).expect("serializes");
1223        let back: SchemaDef = decode_named(&bytes).expect("deserializes");
1224        assert_eq!(back.version, Some(2));
1225        // Unset version is omitted from the wire entirely (back-compat with
1226        // pre-version registries).
1227        let unversioned = SchemaDef {
1228            name: None,
1229            version: None,
1230            ..def
1231        };
1232        let json = serde_json::to_string(&unversioned).expect("serializes");
1233        assert!(
1234            !json.contains("version"),
1235            "unset version must be omitted: {json}"
1236        );
1237    }
1238}