Skip to main content

roam_codegen/targets/typescript/
schema.rs

1//! TypeScript schema generation for runtime service descriptors.
2//!
3//! Generates the `ServiceDescriptor` constant used by the runtime to perform
4//! schema-driven decode/encode for all methods. The generated code has zero
5//! serialization logic — everything is driven by the descriptor at runtime.
6//!
7//! Each method descriptor has:
8//! - `args`: a tuple schema covering all arguments (decoded once before dispatch)
9//! - `result`: the full `Result<T, RoamError<E>>` enum schema for encoding responses
10//!
11//! The `RoamError<E>` schema always has four variants at fixed indices:
12//! - 0: User(E)        — user-defined error (null fields for infallible methods)
13//! - 1: UnknownMethod  — unit
14//! - 2: InvalidPayload — unit
15//! - 3: Cancelled      — unit
16
17use std::collections::HashSet;
18
19use facet_core::{Field, ScalarType, Shape};
20use heck::ToLowerCamelCase;
21use roam_types::{
22    EnumInfo, ServiceDescriptor, ShapeKind, StructInfo, VariantKind, classify_shape,
23    classify_variant, is_bytes,
24};
25
26/// Generate a TypeScript Schema object literal for a type.
27pub fn generate_schema(shape: &'static Shape) -> String {
28    let mut state = SchemaGenState::default();
29    generate_schema_with_field(shape, None, &mut state)
30}
31
32#[derive(Default)]
33struct SchemaGenState {
34    /// When true, named structs/enums are emitted as `{ kind: 'ref', name: 'T' }`
35    /// unless we're currently generating that type's registry entry (`root_name`).
36    use_named_refs: bool,
37    /// Name of the registry entry currently being generated, if any.
38    root_name: Option<String>,
39    /// Active shape pointers for recursion detection.
40    active: HashSet<usize>,
41}
42
43fn named_shape_name(shape: &'static Shape) -> Option<&'static str> {
44    match classify_shape(shape) {
45        ShapeKind::Struct(StructInfo {
46            name: Some(name), ..
47        })
48        | ShapeKind::Enum(EnumInfo {
49            name: Some(name), ..
50        }) => Some(name),
51        _ => None,
52    }
53}
54
55fn extract_initial_credit(shape: &'static Shape) -> u32 {
56    shape
57        .const_params
58        .iter()
59        .find(|cp| cp.name == "N")
60        .map(|cp| cp.value as u32)
61        .unwrap_or(16)
62}
63
64fn generate_schema_with_field(
65    shape: &'static Shape,
66    field: Option<&Field>,
67    state: &mut SchemaGenState,
68) -> String {
69    if state.use_named_refs
70        && let Some(name) = named_shape_name(shape)
71        && state.root_name.as_deref() != Some(name)
72    {
73        return format!("{{ kind: 'ref', name: '{name}' }}");
74    }
75
76    let shape_ptr = shape as *const Shape as usize;
77    if !state.active.insert(shape_ptr) {
78        if let Some(name) = named_shape_name(shape) {
79            return format!("{{ kind: 'ref', name: '{name}' }}");
80        }
81        panic!(
82            "encountered recursive anonymous shape in TypeScript schema generation; \
83             recursive shapes must be named to generate refs"
84        );
85    }
86
87    let bytes_schema = if field.is_some_and(|f| f.has_builtin_attr("trailing")) {
88        "{ kind: 'bytes', trailing: true }"
89    } else {
90        "{ kind: 'bytes' }"
91    };
92
93    // Check for bytes first (Vec<u8>)
94    if is_bytes(shape) {
95        state.active.remove(&shape_ptr);
96        return bytes_schema.into();
97    }
98
99    let rendered = match classify_shape(shape) {
100        ShapeKind::Scalar(scalar) => generate_scalar_schema(scalar),
101        ShapeKind::Tx { inner } => {
102            format!(
103                "{{ kind: 'tx', initial_credit: {}, element: {} }}",
104                extract_initial_credit(shape),
105                generate_schema_with_field(inner, None, state)
106            )
107        }
108        ShapeKind::Rx { inner } => {
109            format!(
110                "{{ kind: 'rx', initial_credit: {}, element: {} }}",
111                extract_initial_credit(shape),
112                generate_schema_with_field(inner, None, state)
113            )
114        }
115        ShapeKind::List { element } => {
116            format!(
117                "{{ kind: 'vec', element: {} }}",
118                generate_schema_with_field(element, None, state)
119            )
120        }
121        ShapeKind::Option { inner } => {
122            format!(
123                "{{ kind: 'option', inner: {} }}",
124                generate_schema_with_field(inner, None, state)
125            )
126        }
127        ShapeKind::Array { element, .. } | ShapeKind::Slice { element } => {
128            format!(
129                "{{ kind: 'vec', element: {} }}",
130                generate_schema_with_field(element, None, state)
131            )
132        }
133        ShapeKind::Map { key, value } => {
134            format!(
135                "{{ kind: 'map', key: {}, value: {} }}",
136                generate_schema_with_field(key, None, state),
137                generate_schema_with_field(value, None, state)
138            )
139        }
140        ShapeKind::Set { element } => {
141            format!(
142                "{{ kind: 'vec', element: {} }}",
143                generate_schema_with_field(element, None, state)
144            )
145        }
146        ShapeKind::Tuple { elements } => {
147            let element_schemas: Vec<_> = elements
148                .iter()
149                .map(|p| generate_schema_with_field(p.shape, None, state))
150                .collect();
151            format!(
152                "{{ kind: 'tuple', elements: [{}] }}",
153                element_schemas.join(", ")
154            )
155        }
156        ShapeKind::Struct(StructInfo { fields, .. }) => {
157            let field_schemas: Vec<_> = fields
158                .iter()
159                .map(|f| {
160                    format!(
161                        "'{}': {}",
162                        f.name,
163                        generate_schema_with_field(f.shape(), Some(f), state)
164                    )
165                })
166                .collect();
167            format!(
168                "{{ kind: 'struct', fields: {{ {} }} }}",
169                field_schemas.join(", ")
170            )
171        }
172        ShapeKind::Enum(EnumInfo { variants, .. }) => {
173            let variant_schemas: Vec<_> = variants
174                .iter()
175                .map(|variant| generate_enum_variant(variant, state))
176                .collect();
177            format!(
178                "{{ kind: 'enum', variants: [{}] }}",
179                variant_schemas.join(", ")
180            )
181        }
182        ShapeKind::Pointer { pointee } => generate_schema_with_field(pointee, None, state),
183        ShapeKind::Result { ok, err } => {
184            // Represent Result as enum with Ok/Err variants
185            format!(
186                "{{ kind: 'enum', variants: [{{ name: 'Ok', fields: {} }}, {{ name: 'Err', fields: {} }}] }}",
187                generate_schema_with_field(ok, None, state),
188                generate_schema_with_field(err, None, state)
189            )
190        }
191        ShapeKind::TupleStruct { fields } => {
192            let inner: Vec<String> = fields
193                .iter()
194                .map(|f| generate_schema_with_field(f.shape(), Some(f), state))
195                .collect();
196            format!("{{ kind: 'tuple', elements: [{}] }}", inner.join(", "))
197        }
198        ShapeKind::Opaque => bytes_schema.into(),
199    };
200
201    state.active.remove(&shape_ptr);
202    rendered
203}
204
205/// Generate an EnumVariant object literal.
206fn generate_enum_variant(variant: &facet_core::Variant, state: &mut SchemaGenState) -> String {
207    match classify_variant(variant) {
208        VariantKind::Unit => {
209            format!("{{ name: '{}', fields: null }}", variant.name)
210        }
211        VariantKind::Newtype { inner } => {
212            let field = variant.data.fields.first();
213            format!(
214                "{{ name: '{}', fields: {} }}",
215                variant.name,
216                generate_schema_with_field(inner, field, state)
217            )
218        }
219        VariantKind::Tuple { fields } => {
220            let field_schemas: Vec<_> = fields
221                .iter()
222                .map(|f| generate_schema_with_field(f.shape(), Some(f), state))
223                .collect();
224            format!(
225                "{{ name: '{}', fields: [{}] }}",
226                variant.name,
227                field_schemas.join(", ")
228            )
229        }
230        VariantKind::Struct { fields } => {
231            let field_schemas: Vec<_> = fields
232                .iter()
233                .map(|f| {
234                    format!(
235                        "'{}': {}",
236                        f.name,
237                        generate_schema_with_field(f.shape(), Some(f), state)
238                    )
239                })
240                .collect();
241            format!(
242                "{{ name: '{}', fields: {{ {} }} }}",
243                variant.name,
244                field_schemas.join(", ")
245            )
246        }
247    }
248}
249
250/// Generate schema for scalar types.
251fn generate_scalar_schema(scalar: ScalarType) -> String {
252    match scalar {
253        ScalarType::Bool => "{ kind: 'bool' }".into(),
254        ScalarType::U8 => "{ kind: 'u8' }".into(),
255        ScalarType::U16 => "{ kind: 'u16' }".into(),
256        ScalarType::U32 => "{ kind: 'u32' }".into(),
257        ScalarType::U64 | ScalarType::USize => "{ kind: 'u64' }".into(),
258        ScalarType::I8 => "{ kind: 'i8' }".into(),
259        ScalarType::I16 => "{ kind: 'i16' }".into(),
260        ScalarType::I32 => "{ kind: 'i32' }".into(),
261        ScalarType::I64 | ScalarType::ISize => "{ kind: 'i64' }".into(),
262        ScalarType::U128 | ScalarType::I128 => {
263            panic!(
264                "u128/i128 types are not supported in TypeScript codegen - use smaller integer types or encode as bytes"
265            )
266        }
267        ScalarType::F32 => "{ kind: 'f32' }".into(),
268        ScalarType::F64 => "{ kind: 'f64' }".into(),
269        ScalarType::Char | ScalarType::Str | ScalarType::String | ScalarType::CowStr => {
270            "{ kind: 'string' }".into()
271        }
272        ScalarType::Unit => "{ kind: 'struct', fields: {} }".into(),
273        _ => "{ kind: 'bytes' }".into(),
274    }
275}
276
277/// Generate the `RoamError<E>` enum schema.
278///
279/// Always has four variants at fixed indices:
280/// - 0: User(E)        — user-defined error (null for infallible)
281/// - 1: UnknownMethod  — unit
282/// - 2: InvalidPayload — unit
283/// - 3: Cancelled      — unit
284fn generate_roam_error_schema(err_schema: &str) -> String {
285    format!(
286        "{{ kind: 'enum', variants: [\
287          {{ name: 'User', fields: {err_schema} }}, \
288          {{ name: 'UnknownMethod', fields: null }}, \
289          {{ name: 'InvalidPayload', fields: null }}, \
290          {{ name: 'Cancelled', fields: null }}\
291        ] }}"
292    )
293}
294
295/// Generate the full `Result<T, RoamError<E>>` enum schema for a method.
296///
297/// - Ok (index 0): T
298/// - Err (index 1): RoamError<E>
299fn generate_result_schema(ok_schema: &str, err_schema: &str) -> String {
300    let roam_error = generate_roam_error_schema(err_schema);
301    format!(
302        "{{ kind: 'enum', variants: [{{ name: 'Ok', fields: {ok_schema} }}, {{ name: 'Err', fields: {roam_error} }}] }}"
303    )
304}
305
306/// Generate the service descriptor constant.
307///
308/// The descriptor contains all method descriptors with their args tuple schemas
309/// and full result schemas. The runtime uses this for schema-driven dispatch.
310pub fn generate_descriptor(service: &ServiceDescriptor) -> String {
311    use super::types::collect_named_types;
312    use crate::render::hex_u64;
313
314    let mut out = String::new();
315    let service_name_lower = service.service_name.to_lower_camel_case();
316    let named_types = collect_named_types(service);
317
318    out.push_str("// Named schema registry (for recursive / shared named types)\n");
319    out.push_str(&format!(
320        "const {service_name_lower}_schema_registry: SchemaRegistry = new Map<string, Schema>([\n"
321    ));
322    for (name, shape) in &named_types {
323        let mut state = SchemaGenState {
324            use_named_refs: true,
325            root_name: Some(name.clone()),
326            active: HashSet::new(),
327        };
328        let schema = generate_schema_with_field(shape, None, &mut state);
329        out.push_str(&format!("  [\"{name}\", {schema}],\n"));
330    }
331    out.push_str("]);\n\n");
332
333    out.push_str("// Service descriptor for runtime schema-driven dispatch\n");
334    out.push_str(&format!(
335        "export const {service_name_lower}_descriptor: ServiceDescriptor = {{\n"
336    ));
337    out.push_str(&format!("  service_name: '{}',\n", service.service_name));
338    out.push_str(&format!(
339        "  schema_registry: {service_name_lower}_schema_registry,\n"
340    ));
341    out.push_str("  methods: [\n");
342
343    for method in service.methods {
344        let method_name = method.method_name.to_lower_camel_case();
345        let id = crate::method_id(method);
346
347        // Args as a tuple schema
348        let mut args_state = SchemaGenState {
349            use_named_refs: true,
350            root_name: None,
351            active: HashSet::new(),
352        };
353        let arg_schemas: Vec<_> = method
354            .args
355            .iter()
356            .map(|a| generate_schema_with_field(a.shape, None, &mut args_state))
357            .collect();
358        let args_schema = format!(
359            "{{ kind: 'tuple', elements: [{}] }}",
360            arg_schemas.join(", ")
361        );
362
363        // Result schema: Result<T, RoamError<E>>
364        let result_schema = match classify_shape(method.return_shape) {
365            ShapeKind::Result { ok, err } => {
366                let mut ok_state = SchemaGenState {
367                    use_named_refs: true,
368                    root_name: None,
369                    active: HashSet::new(),
370                };
371                let mut err_state = SchemaGenState {
372                    use_named_refs: true,
373                    root_name: None,
374                    active: HashSet::new(),
375                };
376                let ok_schema = generate_schema_with_field(ok, None, &mut ok_state);
377                let err_schema = generate_schema_with_field(err, None, &mut err_state);
378                generate_result_schema(&ok_schema, &err_schema)
379            }
380            _ => {
381                // Infallible: ok = return type, err = null (User variant never sent)
382                let mut ok_state = SchemaGenState {
383                    use_named_refs: true,
384                    root_name: None,
385                    active: HashSet::new(),
386                };
387                let ok_schema =
388                    generate_schema_with_field(method.return_shape, None, &mut ok_state);
389                generate_result_schema(&ok_schema, "null")
390            }
391        };
392
393        out.push_str("    {\n");
394        out.push_str(&format!("      name: '{method_name}',\n"));
395        out.push_str(&format!("      id: {}n,\n", hex_u64(id)));
396        out.push_str(&format!("      args: {args_schema},\n"));
397        out.push_str(&format!("      result: {result_schema},\n"));
398        out.push_str("    },\n");
399    }
400
401    out.push_str("  ],\n");
402    out.push_str("};\n\n");
403    out
404}