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