Skip to main content

brain_edge/dto/
schema.rs

1//! Schema verbs: get / upload / validate / replace.
2//!
3//! The four together are what make `PUT` coherent. Exposing the destructive
4//! replace on its own would let a caller demolish a schema they had no way to
5//! create through the same API, so the read and additive paths ship with it.
6//!
7//! `SCHEMA_LIST` is deliberately absent: it is a streaming verb, and every
8//! streaming verb on this edge is either flattened or omitted rather than
9//! half-exposed. Listing versions is introspection an operator does against the
10//! admin listener, not something a data-plane client needs.
11
12// The DTOs below mirror the HTTP contract one-for-one: the JSON field names are
13// the API, and `tools/http_manifest.py` emits every one of them — with its type
14// and serde attributes — into `contract/http-routes.json`, which the three SDK
15// clients are checked against. A doc comment on each of ~312 fields would
16// restate the field name; the ones that carry meaning beyond their name have
17// one written below.
18#![allow(missing_docs)]
19
20use brain_db_sdk::wire::types::{
21    SchemaGetRequest, SchemaGetResponse, SchemaReplaceRequest, SchemaReplaceResponse,
22    SchemaUploadRequest, SchemaUploadResponse, SchemaValidateRequest, SchemaValidateResponse,
23    SchemaValidationErrorWire,
24};
25use serde::{Deserialize, Serialize};
26
27/// One structured parse-or-validate diagnostic.
28#[derive(Debug, Serialize)]
29pub struct SchemaErrorDto {
30    pub code: String,
31    pub message: String,
32    /// 1-based; `0` when no source position is known.
33    pub line: u32,
34    pub column: u32,
35    pub length: u32,
36    /// `0` info / `1` warning / `2` error. Always `2` in v1.
37    pub severity: u8,
38}
39
40impl From<&SchemaValidationErrorWire> for SchemaErrorDto {
41    fn from(e: &SchemaValidationErrorWire) -> Self {
42        Self {
43            code: e.code.clone(),
44            message: e.message.clone(),
45            line: e.line,
46            column: e.column,
47            length: e.length,
48            severity: e.severity,
49        }
50    }
51}
52
53fn errors(src: &[SchemaValidationErrorWire]) -> Vec<SchemaErrorDto> {
54    src.iter().map(SchemaErrorDto::from).collect()
55}
56
57/// `GET /v1/schema` response.
58#[derive(Debug, Serialize)]
59pub struct SchemaDto {
60    pub namespace: String,
61    pub schema_version: u32,
62    /// Verbatim DSL text if it was uploaded as such; empty for programmatic
63    /// uploads, which carry only the parsed AST.
64    pub schema_document: String,
65    pub uploaded_at_unix_nanos: u64,
66    pub validator_version: u32,
67}
68
69impl From<SchemaGetResponse> for SchemaDto {
70    fn from(r: SchemaGetResponse) -> Self {
71        Self {
72            namespace: r.namespace,
73            schema_version: r.schema_version,
74            schema_document: r.schema_document,
75            uploaded_at_unix_nanos: r.uploaded_at_unix_nanos,
76            validator_version: r.validator_version,
77        }
78    }
79}
80
81/// `POST /v1/schema` body — merge a document into the active namespace.
82#[derive(Debug, Deserialize)]
83pub struct SchemaUploadBody {
84    pub schema_document: String,
85    /// Validate and report without persisting.
86    #[serde(default)]
87    pub dry_run: bool,
88    /// Permit a backward-incompatible change.
89    #[serde(default)]
90    pub allow_breaking: bool,
91}
92
93/// `POST /v1/schema` response.
94#[derive(Debug, Serialize)]
95pub struct SchemaUploadDto {
96    pub namespace: String,
97    /// `0` means the upload was rejected — a validation failure, or `dry_run`.
98    pub schema_version: u32,
99    pub backward_compatible: bool,
100    pub validation_errors: Vec<SchemaErrorDto>,
101}
102
103impl From<SchemaUploadResponse> for SchemaUploadDto {
104    fn from(r: SchemaUploadResponse) -> Self {
105        Self {
106            namespace: r.namespace,
107            schema_version: r.schema_version,
108            backward_compatible: r.backward_compatible,
109            validation_errors: errors(&r.validation_errors),
110        }
111    }
112}
113
114/// `POST /v1/schema/validate` body — dry run, never touches storage.
115#[derive(Debug, Deserialize)]
116pub struct SchemaValidateBody {
117    pub schema_document: String,
118}
119
120/// `POST /v1/schema/validate` response.
121#[derive(Debug, Serialize)]
122pub struct SchemaValidateDto {
123    /// Namespace parsed from the document; empty if the parse failed before
124    /// reaching it.
125    pub namespace: String,
126    /// What the version WOULD become — `0` when validation failed.
127    pub would_be_version: u32,
128    pub validation_errors: Vec<SchemaErrorDto>,
129}
130
131impl From<SchemaValidateResponse> for SchemaValidateDto {
132    fn from(r: SchemaValidateResponse) -> Self {
133        Self {
134            namespace: r.namespace,
135            would_be_version: r.would_be_version,
136            validation_errors: errors(&r.validation_errors),
137        }
138    }
139}
140
141/// `PUT /v1/schema` body — DESTRUCTIVE namespace swap.
142///
143/// `force_drop_existing` has no default on purpose. Brain rejects `false` with
144/// `InvalidRequest`, and requiring the caller to spell it out keeps an
145/// irreversible operation from being reachable by an empty body.
146#[derive(Debug, Deserialize)]
147pub struct SchemaReplaceBody {
148    pub schema_document: String,
149    pub force_drop_existing: bool,
150}
151
152/// `PUT /v1/schema` response.
153#[derive(Debug, Serialize)]
154pub struct SchemaReplaceDto {
155    pub namespace: String,
156    pub schema_version: u32,
157    /// How many declared rows were dropped before the new schema landed. This
158    /// is the number that tells a caller what the swap destroyed.
159    pub dropped_count: u32,
160    pub validation_errors: Vec<SchemaErrorDto>,
161}
162
163impl From<SchemaReplaceResponse> for SchemaReplaceDto {
164    fn from(r: SchemaReplaceResponse) -> Self {
165        Self {
166            namespace: r.namespace,
167            schema_version: r.schema_version,
168            dropped_count: r.dropped_count,
169            validation_errors: errors(&r.validation_errors),
170        }
171    }
172}
173
174/// `GET /v1/schema` query — namespace and version are both optional.
175///
176/// `version = 0` asks Brain for the active version, which is the default a
177/// caller wants; an explicit version fetches a historical one.
178#[derive(Debug, Deserialize)]
179pub struct SchemaGetQuery {
180    #[serde(default)]
181    pub namespace: String,
182    #[serde(default)]
183    pub version: u32,
184}
185
186impl SchemaGetQuery {
187    #[must_use]
188    pub fn to_request(&self) -> SchemaGetRequest {
189        SchemaGetRequest {
190            namespace: self.namespace.clone(),
191            version: self.version,
192            act_as: None,
193        }
194    }
195}
196
197impl SchemaUploadBody {
198    /// Build the wire request (mints a fresh `request_id`).
199    #[must_use]
200    pub fn to_request(self) -> SchemaUploadRequest {
201        SchemaUploadRequest {
202            schema_document: self.schema_document,
203            dry_run: self.dry_run,
204            allow_breaking: self.allow_breaking,
205            request_id: brain_db_sdk::new_id(),
206            act_as: None,
207        }
208    }
209}
210
211impl SchemaValidateBody {
212    #[must_use]
213    pub fn to_request(self) -> SchemaValidateRequest {
214        SchemaValidateRequest {
215            schema_document: self.schema_document,
216            act_as: None,
217        }
218    }
219}
220
221impl SchemaReplaceBody {
222    /// Build the wire request (mints a fresh `request_id`).
223    ///
224    /// Rejects `force_drop_existing = false` here rather than forwarding it:
225    /// Brain answers that with `InvalidRequest`, and a 400 naming the field is
226    /// a far better answer than a relayed protocol error.
227    pub fn to_request(self) -> Result<SchemaReplaceRequest, String> {
228        if !self.force_drop_existing {
229            return Err(
230                "force_drop_existing must be true — PUT /v1/schema drops every declared row in \
231                 the namespace before the new document lands"
232                    .into(),
233            );
234        }
235        Ok(SchemaReplaceRequest {
236            schema_document: self.schema_document,
237            force_drop_existing: true,
238            request_id: brain_db_sdk::new_id(),
239            act_as: None,
240        })
241    }
242}
243
244#[cfg(test)]
245mod schema_tests {
246    use super::*;
247    use brain_db_sdk::wire::types::SchemaUploadResponse;
248
249    fn sample_error() -> SchemaValidationErrorWire {
250        SchemaValidationErrorWire {
251            code: "E001".into(),
252            message: "undeclared type".into(),
253            line: 3,
254            column: 5,
255            length: 7,
256            severity: 2,
257        }
258    }
259
260    #[test]
261    fn get_query_forwards_namespace_and_version() {
262        let req = SchemaGetQuery {
263            namespace: "people".into(),
264            version: 4,
265        }
266        .to_request();
267        assert_eq!(req.namespace, "people");
268        assert_eq!(req.version, 4);
269    }
270
271    #[test]
272    fn upload_and_validate_bodies_build_requests() {
273        let up = SchemaUploadBody {
274            schema_document: "entity P {}".into(),
275            dry_run: true,
276            allow_breaking: true,
277        }
278        .to_request();
279        assert_eq!(up.schema_document, "entity P {}");
280        assert!(up.dry_run);
281        assert!(up.allow_breaking);
282        assert_ne!(up.request_id, [0u8; 16]); // a fresh id was minted
283
284        let val = SchemaValidateBody {
285            schema_document: "entity Q {}".into(),
286        }
287        .to_request();
288        assert_eq!(val.schema_document, "entity Q {}");
289    }
290
291    #[test]
292    fn replace_body_requires_force_drop_existing() {
293        assert!(
294            SchemaReplaceBody {
295                schema_document: "entity P {}".into(),
296                force_drop_existing: false,
297            }
298            .to_request()
299            .is_err()
300        );
301        let req = SchemaReplaceBody {
302            schema_document: "entity P {}".into(),
303            force_drop_existing: true,
304        }
305        .to_request()
306        .unwrap();
307        assert!(req.force_drop_existing);
308        assert_eq!(req.schema_document, "entity P {}");
309    }
310
311    #[test]
312    fn error_dto_maps_position_and_severity() {
313        let dto = SchemaErrorDto::from(&sample_error());
314        assert_eq!(dto.code, "E001");
315        assert_eq!(dto.message, "undeclared type");
316        assert_eq!((dto.line, dto.column, dto.length), (3, 5, 7));
317        assert_eq!(dto.severity, 2);
318    }
319
320    #[test]
321    fn get_response_maps_and_drops_source_blob() {
322        let dto = SchemaDto::from(SchemaGetResponse {
323            namespace: "people".into(),
324            schema_version: 2,
325            schema_document: "entity Person {}".into(),
326            source_blob: vec![1, 2, 3], // AST blob is not surfaced
327            uploaded_at_unix_nanos: 99,
328            validator_version: 1,
329        });
330        assert_eq!(dto.namespace, "people");
331        assert_eq!(dto.schema_version, 2);
332        assert_eq!(dto.schema_document, "entity Person {}");
333        assert_eq!(dto.uploaded_at_unix_nanos, 99);
334        assert_eq!(dto.validator_version, 1);
335    }
336
337    #[test]
338    fn upload_response_maps_errors_and_compat() {
339        let dto = SchemaUploadDto::from(SchemaUploadResponse {
340            namespace: "people".into(),
341            schema_version: 0, // rejected
342            validation_errors: vec![sample_error()],
343            backward_compatible: false,
344            migration_summary_blob: vec![],
345        });
346        assert_eq!(dto.namespace, "people");
347        assert_eq!(dto.schema_version, 0);
348        assert!(!dto.backward_compatible);
349        assert_eq!(dto.validation_errors.len(), 1);
350        assert_eq!(dto.validation_errors[0].code, "E001");
351    }
352
353    #[test]
354    fn validate_response_maps_would_be_version_and_errors() {
355        let dto = SchemaValidateDto::from(SchemaValidateResponse {
356            namespace: "people".into(),
357            would_be_version: 3,
358            validation_errors: vec![],
359        });
360        assert_eq!(dto.namespace, "people");
361        assert_eq!(dto.would_be_version, 3);
362        assert!(dto.validation_errors.is_empty());
363    }
364
365    #[test]
366    fn replace_response_surfaces_dropped_count() {
367        let dto = SchemaReplaceDto::from(SchemaReplaceResponse {
368            namespace: "people".into(),
369            schema_version: 5,
370            dropped_count: 12,
371            validation_errors: vec![sample_error()],
372        });
373        assert_eq!(dto.namespace, "people");
374        assert_eq!(dto.schema_version, 5);
375        assert_eq!(dto.dropped_count, 12);
376        assert_eq!(dto.validation_errors.len(), 1);
377    }
378}