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