Skip to main content

laser_wire/
browse.rs

1use crate::control::{Projection, ProjectionBinding, SchemaDef, SchemaSource};
2use crate::query::QueryError;
3use serde::{Deserialize, Serialize};
4
5/// A registered projection plus the bindings that route topics into it. The full
6/// picture of one materialized view: its extraction schema, expected content
7/// type, indexed fields, and where it applies.
8#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
9pub struct ProjectionInfo {
10    pub projection: Projection,
11    #[serde(default, skip_serializing_if = "Vec::is_empty")]
12    pub bindings: Vec<ProjectionBinding>,
13}
14
15/// A writer schema plus its lifecycle state. A `dropped` schema is hidden
16/// from the active set and its id rejects re-registration with a different
17/// definition, but records stamped with the id keep decoding (ids are
18/// permanent). Wire mirrors stay constructible (wire-stability-bound, not
19/// API-stability-bound).
20#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
21pub struct SchemaInfo {
22    pub schema: SchemaDef,
23    #[serde(default)]
24    pub dropped: bool,
25}
26
27/// Request to read one projection's details by id.
28#[derive(Clone, Debug, Serialize, Deserialize)]
29pub struct GetProjection {
30    pub v: u32,
31    pub id: String,
32}
33
34/// Request to list registered projections, optionally filtered. Empty filters
35/// list every projection. `topics` keeps projections bound to any of the named
36/// source topics. `name_contains` keeps those whose name contains the substring.
37/// `id_prefix` keeps those whose id starts with it. `search` is the single-box
38/// convenience that matches the substring against the name OR the id. The
39/// filters compose (AND).
40#[derive(Clone, Debug, Serialize, Deserialize)]
41pub struct ListProjections {
42    pub v: u32,
43    #[serde(default, skip_serializing_if = "Vec::is_empty")]
44    pub topics: Vec<String>,
45    #[serde(default, skip_serializing_if = "Option::is_none")]
46    pub name_contains: Option<String>,
47    #[serde(default, skip_serializing_if = "Option::is_none")]
48    pub id_prefix: Option<String>,
49    #[serde(default, skip_serializing_if = "Option::is_none")]
50    pub search: Option<String>,
51}
52
53/// Request to read one registered writer schema by id.
54#[derive(Clone, Debug, Serialize, Deserialize)]
55pub struct GetSchema {
56    pub v: u32,
57    pub id: u32,
58}
59
60/// Request to list registered writer schemas, optionally filtered.
61/// `name_contains` keeps those whose optional name contains the substring, and
62/// an absent filter lists every schema. The filter lives here, not only on the
63/// HTTP query, so the server pushes it down rather than the client paging the
64/// whole set.
65#[derive(Clone, Debug, Serialize, Deserialize)]
66pub struct ListSchemas {
67    pub v: u32,
68    #[serde(default, skip_serializing_if = "Option::is_none")]
69    pub name_contains: Option<String>,
70}
71
72/// The synchronous register request: no id, LaserData Cloud validates the
73/// definition, allocates the next free id, durably appends the control
74/// event, and replies `SchemaRegistered(id)`. Distinct from
75/// `ControlCommand::RegisterSchema`, the durable log form that carries the
76/// allocated id.
77#[derive(Clone, Debug, Serialize, Deserialize)]
78pub struct RegisterSchema {
79    pub v: u32,
80    pub source: SchemaSource,
81    #[serde(default, skip_serializing_if = "Option::is_none")]
82    pub name: Option<String>,
83    #[serde(default, skip_serializing_if = "Option::is_none")]
84    pub version: Option<u32>,
85}
86
87/// Request to decode one record body under the schema registered for `id`,
88/// the same decode the projector runs on `agdx.sid`-stamped records. Read-only
89/// convenience for consoles and tools that hold a schema-first (Avro/Protobuf)
90/// payload and want its JSON form without re-implementing the codecs.
91#[derive(Clone, Debug, Serialize, Deserialize)]
92pub struct DecodeRecord {
93    pub v: u32,
94    pub id: u32,
95    #[serde(with = "crate::encoding::bin_bytes")]
96    pub payload: Vec<u8>,
97}
98
99/// Reply to a registry browse: `Ok` with the result, or `Err`.
100#[derive(Clone, Debug, Serialize, Deserialize)]
101#[non_exhaustive]
102pub enum BrowseReply {
103    Ok(BrowseOutcome),
104    Err(QueryError),
105}
106
107/// The result of a registry browse, shaped per request.
108#[derive(Clone, Debug, Serialize, Deserialize)]
109#[non_exhaustive]
110pub enum BrowseOutcome {
111    /// `list_projections`: every registered projection.
112    Projections(Vec<ProjectionInfo>),
113    /// `get projection`: the projection with the requested id, or `None`.
114    Projection(Option<ProjectionInfo>),
115    /// `list schemas`: every known writer schema, active and tombstoned.
116    Schemas(Vec<SchemaInfo>),
117    /// `get schema`: the schema occupying the requested id, or `None`.
118    Schema(Option<SchemaInfo>),
119    /// `register schema`: the LaserData-Cloud-allocated id, already durably appended
120    /// to the control topic (visibility follows within the apply latency).
121    SchemaRegistered(u32),
122    /// `decode record`: the payload's JSON form under the requested schema,
123    /// or `None` when the body does not decode under it. A `serde_json::Value`
124    /// because the decoded shape is arbitrary (object, array, scalar) and the
125    /// LaserData Cloud encodes exactly that value into the reply.
126    Decoded(Option<serde_json::Value>),
127}
128
129#[cfg(all(test, feature = "cbor"))]
130mod tests {
131    use super::*;
132    use crate::codes::QUERY_OP_VERSION;
133    use crate::content::ContentType;
134    use crate::control::ProjectionBinding;
135    use crate::framing::{decode_named, encode_named};
136
137    #[test]
138    fn given_a_browse_reply_when_round_tripped_then_should_preserve_projection_details() {
139        let info = ProjectionInfo {
140            projection: Projection::builder("order.v1")
141                .name("order")
142                .version(1)
143                .content_type(ContentType::Json)
144                .fields(["order_id", "amount"])
145                .build(),
146            bindings: vec![
147                ProjectionBinding::builder()
148                    .source("shop", "orders")
149                    .allow("order.v1")
150                    .default_projection("order.v1")
151                    .target_table("orders_rows")
152                    .build(),
153            ],
154        };
155        let reply = BrowseReply::Ok(BrowseOutcome::Projections(vec![info]));
156        let bytes = encode_named(&reply).expect("the reply serializes");
157        let back: BrowseReply = decode_named(&bytes).expect("the reply deserializes");
158        let BrowseReply::Ok(BrowseOutcome::Projections(list)) = back else {
159            panic!("expected an Ok(Projections) browse reply");
160        };
161        assert_eq!(list.len(), 1);
162        assert_eq!(list[0].projection.id.as_str(), "order.v1");
163        assert_eq!(list[0].projection.extraction.fields.len(), 2);
164        let targets = &list[0].bindings[0].targets;
165        assert_eq!(targets.len(), 1);
166        assert_eq!(targets[0].table, "orders_rows");
167    }
168
169    #[test]
170    fn given_a_decode_record_when_round_tripped_then_should_preserve_payload_bytes() {
171        let request = DecodeRecord {
172            v: QUERY_OP_VERSION,
173            id: 7,
174            payload: vec![0xff, 0x00, 0x10],
175        };
176        let bytes = encode_named(&request).expect("serializes");
177        let back: DecodeRecord = decode_named(&bytes).expect("deserializes");
178        assert_eq!(back.id, 7);
179        assert_eq!(back.payload, vec![0xff, 0x00, 0x10]);
180    }
181}