1use crate::ast::*;
2use std::collections::{BTreeMap, HashSet};
3
4#[derive(Debug, Clone)]
6pub struct TypeScriptOutput {
7 pub interfaces: String,
8 pub stack_definition: String,
9 pub imports: String,
10}
11
12impl TypeScriptOutput {
13 pub fn full_file(&self) -> String {
14 format!(
15 "{}\n\n{}\n\n{}",
16 self.imports, self.interfaces, self.stack_definition
17 )
18 }
19}
20
21#[derive(Debug, Clone)]
23pub struct TypeScriptConfig {
24 pub package_name: String,
25 pub generate_helpers: bool,
26 pub interface_prefix: String,
27 pub export_const_name: String,
28 pub url: Option<String>,
30}
31
32impl Default for TypeScriptConfig {
33 fn default() -> Self {
34 Self {
35 package_name: "hyperstack-react".to_string(),
36 generate_helpers: true,
37 interface_prefix: "".to_string(),
38 export_const_name: "STACK".to_string(),
39 url: None,
40 }
41 }
42}
43
44pub trait TypeScriptGenerator {
46 fn generate_typescript(&self, config: &TypeScriptConfig) -> String;
47}
48
49pub trait TypeScriptInterfaceGenerator {
51 fn generate_interface(&self, name: &str, config: &TypeScriptConfig) -> String;
52}
53
54pub trait TypeScriptTypeMapper {
56 fn to_typescript_type(&self) -> String;
57}
58
59pub struct TypeScriptCompiler<S> {
61 spec: TypedStreamSpec<S>,
62 entity_name: String,
63 config: TypeScriptConfig,
64 idl: Option<serde_json::Value>, handlers_json: Option<serde_json::Value>, views: Vec<ViewDef>, }
68
69impl<S> TypeScriptCompiler<S> {
70 pub fn new(spec: TypedStreamSpec<S>, entity_name: String) -> Self {
71 Self {
72 spec,
73 entity_name,
74 config: TypeScriptConfig::default(),
75 idl: None,
76 handlers_json: None,
77 views: Vec::new(),
78 }
79 }
80
81 pub fn with_config(mut self, config: TypeScriptConfig) -> Self {
82 self.config = config;
83 self
84 }
85
86 pub fn with_idl(mut self, idl: Option<serde_json::Value>) -> Self {
87 self.idl = idl;
88 self
89 }
90
91 pub fn with_handlers_json(mut self, handlers: Option<serde_json::Value>) -> Self {
92 self.handlers_json = handlers;
93 self
94 }
95
96 pub fn with_views(mut self, views: Vec<ViewDef>) -> Self {
97 self.views = views;
98 self
99 }
100
101 pub fn compile(&self) -> TypeScriptOutput {
102 let imports = self.generate_imports();
103 let interfaces = self.generate_interfaces();
104 let stack_definition = self.generate_stack_definition();
105
106 TypeScriptOutput {
107 imports,
108 interfaces,
109 stack_definition,
110 }
111 }
112
113 fn generate_imports(&self) -> String {
114 String::new()
116 }
117
118 fn generate_view_helpers(&self) -> String {
119 r#"// ============================================================================
120// View Definition Types (framework-agnostic)
121// ============================================================================
122
123/** View definition with embedded entity type */
124export interface ViewDef<T, TMode extends 'state' | 'list'> {
125 readonly mode: TMode;
126 readonly view: string;
127 /** Phantom field for type inference - not present at runtime */
128 readonly _entity?: T;
129}
130
131/** Helper to create typed state view definitions (keyed lookups) */
132function stateView<T>(view: string): ViewDef<T, 'state'> {
133 return { mode: 'state', view } as const;
134}
135
136/** Helper to create typed list view definitions (collections) */
137function listView<T>(view: string): ViewDef<T, 'list'> {
138 return { mode: 'list', view } as const;
139}"#
140 .to_string()
141 }
142
143 fn generate_interfaces(&self) -> String {
144 let mut interfaces = Vec::new();
145 let mut processed_types = HashSet::new();
146 let mut all_sections: BTreeMap<String, Vec<TypeScriptField>> = BTreeMap::new();
147
148 for handler in &self.spec.handlers {
150 let interface_sections = self.extract_interface_sections_from_handler(handler);
151
152 for (section_name, mut fields) in interface_sections {
153 all_sections
154 .entry(section_name)
155 .or_default()
156 .append(&mut fields);
157 }
158 }
159
160 self.add_unmapped_fields(&mut all_sections);
163
164 for (section_name, fields) in all_sections {
167 if !is_root_section(§ion_name) && processed_types.insert(section_name.clone()) {
168 let deduplicated_fields = self.deduplicate_fields(fields);
169 let interface =
170 self.generate_interface_from_fields(§ion_name, &deduplicated_fields);
171 interfaces.push(interface);
172 }
173 }
174
175 let main_interface = self.generate_main_entity_interface();
177 interfaces.push(main_interface);
178
179 let nested_interfaces = self.generate_nested_interfaces();
181 interfaces.extend(nested_interfaces);
182
183 if self.has_event_types() {
185 interfaces.push(self.generate_event_wrapper_interface());
186 }
187
188 interfaces.join("\n\n")
189 }
190
191 fn deduplicate_fields(&self, mut fields: Vec<TypeScriptField>) -> Vec<TypeScriptField> {
192 let mut seen = HashSet::new();
193 let mut unique_fields = Vec::new();
194
195 fields.sort_by(|a, b| a.name.cmp(&b.name));
197
198 for field in fields {
199 if seen.insert(field.name.clone()) {
200 unique_fields.push(field);
201 }
202 }
203
204 unique_fields
205 }
206
207 fn extract_interface_sections_from_handler(
208 &self,
209 handler: &TypedHandlerSpec<S>,
210 ) -> BTreeMap<String, Vec<TypeScriptField>> {
211 let mut sections: BTreeMap<String, Vec<TypeScriptField>> = BTreeMap::new();
212
213 for mapping in &handler.mappings {
214 if !mapping.emit {
215 continue;
216 }
217 let parts: Vec<&str> = mapping.target_path.split('.').collect();
218
219 if parts.len() > 1 {
220 let section_name = parts[0];
221 let field_name = parts[1];
222
223 let ts_field = TypeScriptField {
224 name: field_name.to_string(),
225 ts_type: self.mapping_to_typescript_type(mapping),
226 optional: self.is_field_optional(mapping),
227 description: None,
228 };
229
230 sections
231 .entry(section_name.to_string())
232 .or_default()
233 .push(ts_field);
234 } else {
235 let ts_field = TypeScriptField {
236 name: mapping.target_path.clone(),
237 ts_type: self.mapping_to_typescript_type(mapping),
238 optional: self.is_field_optional(mapping),
239 description: None,
240 };
241
242 sections
243 .entry("Root".to_string())
244 .or_default()
245 .push(ts_field);
246 }
247 }
248
249 sections
250 }
251
252 fn add_unmapped_fields(&self, sections: &mut BTreeMap<String, Vec<TypeScriptField>>) {
253 if !self.spec.sections.is_empty() {
255 for section in &self.spec.sections {
257 let section_fields = sections.entry(section.name.clone()).or_default();
258
259 for field_info in §ion.fields {
260 if !field_info.emit {
261 continue;
262 }
263 let already_exists = section_fields
265 .iter()
266 .any(|f| f.name == field_info.field_name);
267
268 if !already_exists {
269 section_fields.push(TypeScriptField {
270 name: field_info.field_name.clone(),
271 ts_type: self.field_type_info_to_typescript(field_info),
272 optional: field_info.is_optional,
273 description: None,
274 });
275 }
276 }
277 }
278 } else {
279 for (field_path, field_type_info) in &self.spec.field_mappings {
281 if !field_type_info.emit {
282 continue;
283 }
284 let parts: Vec<&str> = field_path.split('.').collect();
285 if parts.len() > 1 {
286 let section_name = parts[0];
287 let field_name = parts[1];
288
289 let section_fields = sections.entry(section_name.to_string()).or_default();
290
291 let already_exists = section_fields.iter().any(|f| f.name == field_name);
292
293 if !already_exists {
294 section_fields.push(TypeScriptField {
295 name: field_name.to_string(),
296 ts_type: self.base_type_to_typescript(
297 &field_type_info.base_type,
298 field_type_info.is_array,
299 ),
300 optional: field_type_info.is_optional,
301 description: None,
302 });
303 }
304 }
305 }
306 }
307 }
308
309 fn generate_interface_from_fields(&self, name: &str, fields: &[TypeScriptField]) -> String {
310 let interface_name = if name == "Root" {
312 format!(
313 "{}{}",
314 self.config.interface_prefix,
315 to_pascal_case(&self.entity_name)
316 )
317 } else {
318 let base_name = if self.entity_name.contains("Game") {
321 "Game"
322 } else {
323 &self.entity_name
324 };
325 format!(
326 "{}{}{}",
327 self.config.interface_prefix,
328 base_name,
329 to_pascal_case(name)
330 )
331 };
332
333 let field_definitions: Vec<String> = fields
336 .iter()
337 .map(|field| {
338 let ts_type = if field.optional {
339 format!("{} | null", field.ts_type)
341 } else {
342 field.ts_type.clone()
343 };
344 format!(" {}?: {};", field.name, ts_type)
345 })
346 .collect();
347
348 format!(
349 "export interface {} {{\n{}\n}}",
350 interface_name,
351 field_definitions.join("\n")
352 )
353 }
354
355 fn generate_main_entity_interface(&self) -> String {
356 let entity_name = to_pascal_case(&self.entity_name);
357
358 let mut sections = BTreeMap::new();
360
361 for handler in &self.spec.handlers {
362 for mapping in &handler.mappings {
363 if !mapping.emit {
364 continue;
365 }
366 let parts: Vec<&str> = mapping.target_path.split('.').collect();
367 if parts.len() > 1 {
368 sections.insert(parts[0], true);
369 }
370 }
371 }
372
373 if !self.spec.sections.is_empty() {
374 for section in &self.spec.sections {
375 if section.fields.iter().any(|field| field.emit) {
376 sections.insert(§ion.name, true);
377 }
378 }
379 } else {
380 for mapping in &self.spec.handlers {
381 for field_mapping in &mapping.mappings {
382 if !field_mapping.emit {
383 continue;
384 }
385 let parts: Vec<&str> = field_mapping.target_path.split('.').collect();
386 if parts.len() > 1 {
387 sections.insert(parts[0], true);
388 }
389 }
390 }
391 }
392
393 let mut fields = Vec::new();
394
395 for section in sections.keys() {
398 if !is_root_section(section) {
399 let base_name = if self.entity_name.contains("Game") {
400 "Game"
401 } else {
402 &self.entity_name
403 };
404 let section_interface_name = format!("{}{}", base_name, to_pascal_case(section));
405 fields.push(format!(" {}?: {};", section, section_interface_name));
407 }
408 }
409
410 for section in &self.spec.sections {
413 if is_root_section(§ion.name) {
414 for field in §ion.fields {
415 if !field.emit {
416 continue;
417 }
418 let base_ts_type = self.field_type_info_to_typescript(field);
419 let ts_type = if field.is_optional {
420 format!("{} | null", base_ts_type)
421 } else {
422 base_ts_type
423 };
424 fields.push(format!(" {}?: {};", field.field_name, ts_type));
425 }
426 }
427 }
428
429 if fields.is_empty() {
430 fields.push(" // Generated interface - extend as needed".to_string());
431 }
432
433 format!(
434 "export interface {} {{\n{}\n}}",
435 entity_name,
436 fields.join("\n")
437 )
438 }
439
440 fn generate_stack_definition(&self) -> String {
441 let stack_name = to_kebab_case(&self.entity_name);
442 let entity_pascal = to_pascal_case(&self.entity_name);
443 let export_name = format!(
444 "{}_{}",
445 self.entity_name.to_uppercase(),
446 self.config.export_const_name
447 );
448
449 let view_helpers = self.generate_view_helpers();
450 let derived_views = self.generate_derived_view_entries();
451
452 let url_line = match &self.config.url {
454 Some(url) => format!(" url: '{}',", url),
455 None => " // url: 'wss://your-stack-url.stack.usehyperstack.com', // TODO: Set after first deployment".to_string(),
456 };
457
458 format!(
459 r#"{}
460
461// ============================================================================
462// Stack Definition
463// ============================================================================
464
465/** Stack definition for {} */
466export const {} = {{
467 name: '{}',
468{}
469 views: {{
470 {}: {{
471 state: stateView<{}>('{}/state'),
472 list: listView<{}>('{}/list'),{}
473 }},
474 }},
475}} as const;
476
477/** Type alias for the stack */
478export type {}Stack = typeof {};
479
480/** Default export for convenience */
481export default {};"#,
482 view_helpers,
483 entity_pascal,
484 export_name,
485 stack_name,
486 url_line,
487 self.entity_name,
488 entity_pascal,
489 self.entity_name,
490 entity_pascal,
491 self.entity_name,
492 derived_views,
493 entity_pascal,
494 export_name,
495 export_name
496 )
497 }
498
499 fn generate_derived_view_entries(&self) -> String {
500 let derived_views: Vec<&ViewDef> = self
501 .views
502 .iter()
503 .filter(|v| {
504 !v.id.ends_with("/state")
505 && !v.id.ends_with("/list")
506 && v.id.starts_with(&self.entity_name)
507 })
508 .collect();
509
510 if derived_views.is_empty() {
511 return String::new();
512 }
513
514 let entity_pascal = to_pascal_case(&self.entity_name);
515 let mut entries = Vec::new();
516
517 for view in derived_views {
518 let view_name = view.id.split('/').nth(1).unwrap_or("unknown");
519
520 entries.push(format!(
521 "\n {}: listView<{}>('{}'),",
522 view_name, entity_pascal, view.id
523 ));
524 }
525
526 entries.join("")
527 }
528
529 fn mapping_to_typescript_type(&self, mapping: &TypedFieldMapping<S>) -> String {
530 if let Some(field_info) = self.spec.field_mappings.get(&mapping.target_path) {
532 let ts_type = self.field_type_info_to_typescript(field_info);
533
534 if matches!(mapping.population, PopulationStrategy::Append) {
536 return if ts_type.ends_with("[]") {
537 ts_type
538 } else {
539 format!("{}[]", ts_type)
540 };
541 }
542
543 return ts_type;
544 }
545
546 match &mapping.population {
548 PopulationStrategy::Append => {
549 match &mapping.source {
551 MappingSource::AsEvent { .. } => "any[]".to_string(),
552 _ => "any[]".to_string(),
553 }
554 }
555 _ => {
556 let base_type = match &mapping.source {
558 MappingSource::FromSource { .. } => {
559 self.infer_type_from_field_name(&mapping.target_path)
560 }
561 MappingSource::Constant(value) => value_to_typescript_type(value),
562 MappingSource::AsEvent { .. } => "any".to_string(),
563 _ => "any".to_string(),
564 };
565
566 if let Some(transform) = &mapping.transform {
568 match transform {
569 Transformation::HexEncode | Transformation::HexDecode => {
570 "string".to_string()
571 }
572 Transformation::Base58Encode | Transformation::Base58Decode => {
573 "string".to_string()
574 }
575 Transformation::ToString => "string".to_string(),
576 Transformation::ToNumber => "number".to_string(),
577 }
578 } else {
579 base_type
580 }
581 }
582 }
583 }
584
585 fn field_type_info_to_typescript(&self, field_info: &FieldTypeInfo) -> String {
587 if let Some(resolved) = &field_info.resolved_type {
589 let interface_name = self.resolved_type_to_interface_name(resolved);
590
591 let base_type = if resolved.is_event || (resolved.is_instruction && field_info.is_array)
593 {
594 format!("EventWrapper<{}>", interface_name)
595 } else {
596 interface_name
597 };
598
599 let with_array = if field_info.is_array {
601 format!("{}[]", base_type)
602 } else {
603 base_type
604 };
605
606 return with_array;
607 }
608
609 if field_info.base_type == BaseType::Any
612 || (field_info.base_type == BaseType::Array
613 && field_info.inner_type.as_deref() == Some("Value"))
614 {
615 if let Some(event_type) = self.find_event_interface_for_field(&field_info.field_name) {
616 return if field_info.is_array {
617 format!("{}[]", event_type)
618 } else if field_info.is_optional {
619 format!("{} | null", event_type)
620 } else {
621 event_type
622 };
623 }
624 }
625
626 self.base_type_to_typescript(&field_info.base_type, field_info.is_array)
628 }
629
630 fn find_event_interface_for_field(&self, field_name: &str) -> Option<String> {
632 let handlers = self.handlers_json.as_ref()?.as_array()?;
634
635 for handler in handlers {
637 if let Some(mappings) = handler.get("mappings").and_then(|m| m.as_array()) {
638 for mapping in mappings {
639 if let Some(target_path) = mapping.get("target_path").and_then(|t| t.as_str()) {
640 let target_parts: Vec<&str> = target_path.split('.').collect();
642 if let Some(target_field) = target_parts.last() {
643 if *target_field == field_name {
644 if let Some(source) = mapping.get("source") {
646 if self.extract_event_data(source).is_some() {
647 return Some(format!(
649 "{}Event",
650 to_pascal_case(field_name)
651 ));
652 }
653 }
654 }
655 }
656 }
657 }
658 }
659 }
660 None
661 }
662
663 fn resolved_type_to_interface_name(&self, resolved: &ResolvedStructType) -> String {
665 to_pascal_case(&resolved.type_name)
666 }
667
668 fn generate_nested_interfaces(&self) -> Vec<String> {
670 let mut interfaces = Vec::new();
671 let mut generated_types = HashSet::new();
672
673 for section in &self.spec.sections {
675 for field_info in §ion.fields {
676 if let Some(resolved) = &field_info.resolved_type {
677 let type_name = resolved.type_name.clone();
678
679 if generated_types.insert(type_name) {
681 let interface = self.generate_interface_for_resolved_type(resolved);
682 interfaces.push(interface);
683 }
684 }
685 }
686 }
687
688 interfaces.extend(self.generate_event_interfaces(&mut generated_types));
690
691 if let Some(idl_value) = &self.idl {
693 if let Some(types_array) = idl_value.get("types").and_then(|v| v.as_array()) {
694 for type_def in types_array {
695 if let (Some(type_name), Some(type_obj)) = (
696 type_def.get("name").and_then(|v| v.as_str()),
697 type_def.get("type").and_then(|v| v.as_object()),
698 ) {
699 if type_obj.get("kind").and_then(|v| v.as_str()) == Some("enum") {
700 if generated_types.insert(type_name.to_string()) {
702 if let Some(variants) =
703 type_obj.get("variants").and_then(|v| v.as_array())
704 {
705 let variant_names: Vec<String> = variants
706 .iter()
707 .filter_map(|v| {
708 v.get("name")
709 .and_then(|n| n.as_str())
710 .map(|s| s.to_string())
711 })
712 .collect();
713
714 if !variant_names.is_empty() {
715 let interface_name = to_pascal_case(type_name);
716 let variant_strings: Vec<String> = variant_names
717 .iter()
718 .map(|v| format!("\"{}\"", to_pascal_case(v)))
719 .collect();
720
721 let enum_type = format!(
722 "export type {} = {};",
723 interface_name,
724 variant_strings.join(" | ")
725 );
726 interfaces.push(enum_type);
727 }
728 }
729 }
730 }
731 }
732 }
733 }
734 }
735
736 interfaces
737 }
738
739 fn generate_event_interfaces(&self, generated_types: &mut HashSet<String>) -> Vec<String> {
741 let mut interfaces = Vec::new();
742
743 let handlers = match &self.handlers_json {
745 Some(h) => h.as_array(),
746 None => return interfaces,
747 };
748
749 let handlers_array = match handlers {
750 Some(arr) => arr,
751 None => return interfaces,
752 };
753
754 for handler in handlers_array {
756 if let Some(mappings) = handler.get("mappings").and_then(|m| m.as_array()) {
758 for mapping in mappings {
759 if let Some(target_path) = mapping.get("target_path").and_then(|t| t.as_str()) {
760 if target_path.contains(".events.") || target_path.starts_with("events.") {
762 if let Some(source) = mapping.get("source") {
764 if let Some(event_data) = self.extract_event_data(source) {
765 if let Some(handler_source) = handler.get("source") {
767 if let Some(instruction_name) =
768 self.extract_instruction_name(handler_source)
769 {
770 let event_field_name =
772 target_path.split('.').next_back().unwrap_or("");
773 let interface_name = format!(
774 "{}Event",
775 to_pascal_case(event_field_name)
776 );
777
778 if generated_types.insert(interface_name.clone()) {
780 if let Some(interface) = self
781 .generate_event_interface_from_idl(
782 &interface_name,
783 &instruction_name,
784 &event_data,
785 )
786 {
787 interfaces.push(interface);
788 }
789 }
790 }
791 }
792 }
793 }
794 }
795 }
796 }
797 }
798 }
799
800 interfaces
801 }
802
803 fn extract_event_data(
805 &self,
806 source: &serde_json::Value,
807 ) -> Option<Vec<(String, Option<String>)>> {
808 if let Some(as_event) = source.get("AsEvent") {
809 if let Some(fields) = as_event.get("fields").and_then(|f| f.as_array()) {
810 let mut event_fields = Vec::new();
811 for field in fields {
812 if let Some(from_source) = field.get("FromSource") {
813 if let Some(path) = from_source
814 .get("path")
815 .and_then(|p| p.get("segments"))
816 .and_then(|s| s.as_array())
817 {
818 if let Some(field_name) = path.last().and_then(|v| v.as_str()) {
820 let transform = from_source
821 .get("transform")
822 .and_then(|t| t.as_str())
823 .map(|s| s.to_string());
824 event_fields.push((field_name.to_string(), transform));
825 }
826 }
827 }
828 }
829 return Some(event_fields);
830 }
831 }
832 None
833 }
834
835 fn extract_instruction_name(&self, source: &serde_json::Value) -> Option<String> {
837 if let Some(source_obj) = source.get("Source") {
838 if let Some(type_name) = source_obj.get("type_name").and_then(|t| t.as_str()) {
839 let instruction_part =
840 crate::event_type_helpers::strip_event_type_suffix(type_name);
841 return Some(instruction_part.to_string());
842 }
843 }
844 None
845 }
846
847 fn find_instruction_in_idl<'a>(
851 &self,
852 instructions: &'a [serde_json::Value],
853 rust_name: &str,
854 ) -> Option<&'a serde_json::Value> {
855 let normalized_search = normalize_for_comparison(rust_name);
856
857 for instruction in instructions {
858 if let Some(idl_name) = instruction.get("name").and_then(|n| n.as_str()) {
859 if normalize_for_comparison(idl_name) == normalized_search {
860 return Some(instruction);
861 }
862 }
863 }
864 None
865 }
866
867 fn generate_event_interface_from_idl(
869 &self,
870 interface_name: &str,
871 rust_instruction_name: &str,
872 captured_fields: &[(String, Option<String>)],
873 ) -> Option<String> {
874 if captured_fields.is_empty() {
875 return Some(format!("export interface {} {{}}", interface_name));
876 }
877
878 let idl_value = self.idl.as_ref()?;
879 let instructions = idl_value.get("instructions")?.as_array()?;
880
881 let instruction = self.find_instruction_in_idl(instructions, rust_instruction_name)?;
882 let args = instruction.get("args")?.as_array()?;
883
884 let mut fields = Vec::new();
885 for (field_name, transform) in captured_fields {
886 for arg in args {
887 if let Some(arg_name) = arg.get("name").and_then(|n| n.as_str()) {
888 if arg_name == field_name {
889 if let Some(arg_type) = arg.get("type") {
890 let ts_type =
891 self.idl_type_to_typescript(arg_type, transform.as_deref());
892 fields.push(format!(" {}: {};", field_name, ts_type));
893 }
894 break;
895 }
896 }
897 }
898 }
899
900 if !fields.is_empty() {
901 return Some(format!(
902 "export interface {} {{\n{}\n}}",
903 interface_name,
904 fields.join("\n")
905 ));
906 }
907
908 None
909 }
910
911 fn idl_type_to_typescript(
913 &self,
914 idl_type: &serde_json::Value,
915 transform: Option<&str>,
916 ) -> String {
917 #![allow(clippy::only_used_in_recursion)]
918 if transform == Some("HexEncode") {
920 return "string".to_string();
921 }
922
923 if let Some(type_str) = idl_type.as_str() {
925 return match type_str {
926 "u8" | "u16" | "u32" | "u64" | "u128" | "i8" | "i16" | "i32" | "i64" | "i128" => {
927 "number".to_string()
928 }
929 "f32" | "f64" => "number".to_string(),
930 "bool" => "boolean".to_string(),
931 "string" => "string".to_string(),
932 "pubkey" | "publicKey" => "string".to_string(),
933 "bytes" => "string".to_string(),
934 _ => "any".to_string(),
935 };
936 }
937
938 if let Some(type_obj) = idl_type.as_object() {
940 if let Some(option_type) = type_obj.get("option") {
941 let inner = self.idl_type_to_typescript(option_type, None);
942 return format!("{} | null", inner);
943 }
944 if let Some(vec_type) = type_obj.get("vec") {
945 let inner = self.idl_type_to_typescript(vec_type, None);
946 return format!("{}[]", inner);
947 }
948 }
949
950 "any".to_string()
951 }
952
953 fn generate_interface_for_resolved_type(&self, resolved: &ResolvedStructType) -> String {
955 let interface_name = to_pascal_case(&resolved.type_name);
956
957 if resolved.is_enum {
959 let variants: Vec<String> = resolved
960 .enum_variants
961 .iter()
962 .map(|v| format!("\"{}\"", to_pascal_case(v)))
963 .collect();
964
965 return format!("export type {} = {};", interface_name, variants.join(" | "));
966 }
967
968 let fields: Vec<String> = resolved
971 .fields
972 .iter()
973 .map(|field| {
974 let base_ts_type = self.resolved_field_to_typescript(field);
975 let ts_type = if field.is_optional {
976 format!("{} | null", base_ts_type)
977 } else {
978 base_ts_type
979 };
980 format!(" {}?: {};", field.field_name, ts_type)
981 })
982 .collect();
983
984 format!(
985 "export interface {} {{\n{}\n}}",
986 interface_name,
987 fields.join("\n")
988 )
989 }
990
991 fn resolved_field_to_typescript(&self, field: &ResolvedField) -> String {
993 let base_ts = self.base_type_to_typescript(&field.base_type, false);
994
995 if field.is_array {
996 format!("{}[]", base_ts)
997 } else {
998 base_ts
999 }
1000 }
1001
1002 fn has_event_types(&self) -> bool {
1004 for section in &self.spec.sections {
1005 for field_info in §ion.fields {
1006 if let Some(resolved) = &field_info.resolved_type {
1007 if resolved.is_event || (resolved.is_instruction && field_info.is_array) {
1008 return true;
1009 }
1010 }
1011 }
1012 }
1013 false
1014 }
1015
1016 fn generate_event_wrapper_interface(&self) -> String {
1018 r#"/**
1019 * Wrapper for event data that includes context metadata.
1020 * Events are automatically wrapped in this structure at runtime.
1021 */
1022export interface EventWrapper<T> {
1023 /** Unix timestamp when the event was processed */
1024 timestamp: number;
1025 /** The event-specific data */
1026 data: T;
1027 /** Optional blockchain slot number */
1028 slot?: number;
1029 /** Optional transaction signature */
1030 signature?: string;
1031}"#
1032 .to_string()
1033 }
1034
1035 fn infer_type_from_field_name(&self, field_name: &str) -> String {
1036 let lower_name = field_name.to_lowercase();
1037
1038 if lower_name.contains("events.") {
1040 return "any".to_string();
1042 }
1043
1044 if lower_name.contains("id")
1046 || lower_name.contains("count")
1047 || lower_name.contains("number")
1048 || lower_name.contains("timestamp")
1049 || lower_name.contains("time")
1050 || lower_name.contains("at")
1051 || lower_name.contains("volume")
1052 || lower_name.contains("amount")
1053 || lower_name.contains("ev")
1054 || lower_name.contains("fee")
1055 || lower_name.contains("payout")
1056 || lower_name.contains("distributed")
1057 || lower_name.contains("claimable")
1058 || lower_name.contains("total")
1059 || lower_name.contains("rate")
1060 || lower_name.contains("ratio")
1061 || lower_name.contains("current")
1062 || lower_name.contains("state")
1063 {
1064 "number".to_string()
1065 } else if lower_name.contains("status")
1066 || lower_name.contains("hash")
1067 || lower_name.contains("address")
1068 || lower_name.contains("key")
1069 {
1070 "string".to_string()
1071 } else {
1072 "any".to_string()
1073 }
1074 }
1075
1076 fn is_field_optional(&self, mapping: &TypedFieldMapping<S>) -> bool {
1077 match &mapping.source {
1079 MappingSource::Constant(_) => false,
1081 MappingSource::AsEvent { .. } => true,
1083 MappingSource::FromSource { .. } => true,
1085 _ => true,
1087 }
1088 }
1089
1090 fn base_type_to_typescript(&self, base_type: &BaseType, is_array: bool) -> String {
1092 let base_ts_type = match base_type {
1093 BaseType::Integer => "number",
1094 BaseType::Float => "number",
1095 BaseType::String => "string",
1096 BaseType::Boolean => "boolean",
1097 BaseType::Timestamp => "number", BaseType::Binary => "string", BaseType::Pubkey => "string", BaseType::Array => "any[]", BaseType::Object => "Record<string, any>", BaseType::Any => "any",
1103 };
1104
1105 if is_array && !matches!(base_type, BaseType::Array) {
1106 format!("{}[]", base_ts_type)
1107 } else {
1108 base_ts_type.to_string()
1109 }
1110 }
1111}
1112
1113#[derive(Debug, Clone)]
1115struct TypeScriptField {
1116 name: String,
1117 ts_type: String,
1118 optional: bool,
1119 #[allow(dead_code)]
1120 description: Option<String>,
1121}
1122
1123fn value_to_typescript_type(value: &serde_json::Value) -> String {
1125 match value {
1126 serde_json::Value::Number(_) => "number".to_string(),
1127 serde_json::Value::String(_) => "string".to_string(),
1128 serde_json::Value::Bool(_) => "boolean".to_string(),
1129 serde_json::Value::Array(_) => "any[]".to_string(),
1130 serde_json::Value::Object(_) => "Record<string, any>".to_string(),
1131 serde_json::Value::Null => "null".to_string(),
1132 }
1133}
1134
1135fn to_pascal_case(s: &str) -> String {
1137 s.split(['_', '-', '.'])
1138 .map(|word| {
1139 let mut chars = word.chars();
1140 match chars.next() {
1141 None => String::new(),
1142 Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
1143 }
1144 })
1145 .collect()
1146}
1147
1148fn normalize_for_comparison(s: &str) -> String {
1151 s.chars()
1152 .filter(|c| *c != '_')
1153 .flat_map(|c| c.to_lowercase())
1154 .collect()
1155}
1156
1157fn is_root_section(name: &str) -> bool {
1159 name.eq_ignore_ascii_case("root")
1160}
1161
1162fn to_kebab_case(s: &str) -> String {
1164 let mut result = String::new();
1165
1166 for ch in s.chars() {
1167 if ch.is_uppercase() && !result.is_empty() {
1168 result.push('-');
1169 }
1170 result.push(ch.to_lowercase().next().unwrap());
1171 }
1172
1173 result
1174}
1175
1176pub fn generate_typescript_from_spec_fn<F, S>(
1179 spec_fn: F,
1180 entity_name: String,
1181 config: Option<TypeScriptConfig>,
1182) -> Result<TypeScriptOutput, String>
1183where
1184 F: Fn() -> TypedStreamSpec<S>,
1185{
1186 let spec = spec_fn();
1187 let compiler =
1188 TypeScriptCompiler::new(spec, entity_name).with_config(config.unwrap_or_default());
1189
1190 Ok(compiler.compile())
1191}
1192
1193pub fn write_typescript_to_file(
1195 output: &TypeScriptOutput,
1196 path: &std::path::Path,
1197) -> Result<(), std::io::Error> {
1198 std::fs::write(path, output.full_file())
1199}
1200
1201pub fn compile_serializable_spec(
1204 spec: SerializableStreamSpec,
1205 entity_name: String,
1206 config: Option<TypeScriptConfig>,
1207) -> Result<TypeScriptOutput, String> {
1208 let idl = spec
1209 .idl
1210 .as_ref()
1211 .and_then(|idl_snapshot| serde_json::to_value(idl_snapshot).ok());
1212
1213 let handlers = serde_json::to_value(&spec.handlers).ok();
1214 let views = spec.views.clone();
1215
1216 let typed_spec: TypedStreamSpec<()> = TypedStreamSpec::from_serializable(spec);
1217
1218 let compiler = TypeScriptCompiler::new(typed_spec, entity_name)
1219 .with_idl(idl)
1220 .with_handlers_json(handlers)
1221 .with_views(views)
1222 .with_config(config.unwrap_or_default());
1223
1224 Ok(compiler.compile())
1225}
1226
1227#[derive(Debug, Clone)]
1228pub struct TypeScriptStackConfig {
1229 pub package_name: String,
1230 pub generate_helpers: bool,
1231 pub export_const_name: String,
1232 pub url: Option<String>,
1233}
1234
1235impl Default for TypeScriptStackConfig {
1236 fn default() -> Self {
1237 Self {
1238 package_name: "hyperstack-react".to_string(),
1239 generate_helpers: true,
1240 export_const_name: "STACK".to_string(),
1241 url: None,
1242 }
1243 }
1244}
1245
1246#[derive(Debug, Clone)]
1247pub struct TypeScriptStackOutput {
1248 pub interfaces: String,
1249 pub stack_definition: String,
1250 pub imports: String,
1251}
1252
1253impl TypeScriptStackOutput {
1254 pub fn full_file(&self) -> String {
1255 let mut parts = Vec::new();
1256 if !self.imports.is_empty() {
1257 parts.push(self.imports.as_str());
1258 }
1259 if !self.interfaces.is_empty() {
1260 parts.push(self.interfaces.as_str());
1261 }
1262 if !self.stack_definition.is_empty() {
1263 parts.push(self.stack_definition.as_str());
1264 }
1265 parts.join("\n\n")
1266 }
1267}
1268
1269pub fn compile_stack_spec(
1276 stack_spec: SerializableStackSpec,
1277 config: Option<TypeScriptStackConfig>,
1278) -> Result<TypeScriptStackOutput, String> {
1279 let config = config.unwrap_or_default();
1280 let stack_name = &stack_spec.stack_name;
1281 let stack_kebab = to_kebab_case(stack_name);
1282
1283 let mut all_interfaces = Vec::new();
1285 let mut entity_names = Vec::new();
1286
1287 for entity_spec in &stack_spec.entities {
1288 let mut spec = entity_spec.clone();
1289 if spec.idl.is_none() {
1291 spec.idl = stack_spec.idls.first().cloned();
1292 }
1293 let entity_name = spec.state_name.clone();
1294 entity_names.push(entity_name.clone());
1295
1296 let per_entity_config = TypeScriptConfig {
1297 package_name: config.package_name.clone(),
1298 generate_helpers: false,
1299 interface_prefix: String::new(),
1300 export_const_name: config.export_const_name.clone(),
1301 url: config.url.clone(),
1302 };
1303
1304 let output = compile_serializable_spec(spec, entity_name, Some(per_entity_config))?;
1305
1306 if !output.interfaces.is_empty() {
1308 all_interfaces.push(output.interfaces);
1309 }
1310 }
1311
1312 let interfaces = all_interfaces.join("\n\n");
1313
1314 let stack_definition = generate_stack_definition_multi(
1316 stack_name,
1317 &stack_kebab,
1318 &stack_spec.entities,
1319 &entity_names,
1320 &config,
1321 );
1322
1323 Ok(TypeScriptStackOutput {
1324 imports: String::new(),
1325 interfaces,
1326 stack_definition,
1327 })
1328}
1329
1330pub fn write_stack_typescript_to_file(
1332 output: &TypeScriptStackOutput,
1333 path: &std::path::Path,
1334) -> Result<(), std::io::Error> {
1335 std::fs::write(path, output.full_file())
1336}
1337
1338fn generate_stack_definition_multi(
1362 stack_name: &str,
1363 stack_kebab: &str,
1364 entities: &[SerializableStreamSpec],
1365 entity_names: &[String],
1366 config: &TypeScriptStackConfig,
1367) -> String {
1368 let export_name = format!(
1369 "{}_{}",
1370 to_screaming_snake_case(stack_name),
1371 config.export_const_name
1372 );
1373
1374 let view_helpers = generate_view_helpers_static();
1375
1376 let url_line = match &config.url {
1377 Some(url) => format!(" url: '{}',", url),
1378 None => " // url: 'wss://your-stack-url.stack.usehyperstack.com', // TODO: Set after first deployment".to_string(),
1379 };
1380
1381 let mut entity_view_blocks = Vec::new();
1383 for (i, entity_spec) in entities.iter().enumerate() {
1384 let entity_name = &entity_names[i];
1385 let entity_pascal = to_pascal_case(entity_name);
1386
1387 let mut view_entries = Vec::new();
1388
1389 view_entries.push(format!(
1391 " state: stateView<{entity}>('{entity_name}/state'),",
1392 entity = entity_pascal,
1393 entity_name = entity_name
1394 ));
1395
1396 view_entries.push(format!(
1398 " list: listView<{entity}>('{entity_name}/list'),",
1399 entity = entity_pascal,
1400 entity_name = entity_name
1401 ));
1402
1403 for view in &entity_spec.views {
1405 if !view.id.ends_with("/state")
1406 && !view.id.ends_with("/list")
1407 && view.id.starts_with(entity_name)
1408 {
1409 let view_name = view.id.split('/').nth(1).unwrap_or("unknown");
1410 view_entries.push(format!(
1411 " {}: listView<{entity}>('{}'),",
1412 view_name,
1413 view.id,
1414 entity = entity_pascal
1415 ));
1416 }
1417 }
1418
1419 entity_view_blocks.push(format!(
1420 " {}: {{\n{}\n }},",
1421 entity_name,
1422 view_entries.join("\n")
1423 ));
1424 }
1425
1426 let views_body = entity_view_blocks.join("\n");
1427
1428 let entity_types: Vec<String> = entity_names.iter().map(|n| to_pascal_case(n)).collect();
1430
1431 format!(
1432 r#"{view_helpers}
1433
1434// ============================================================================
1435// Stack Definition
1436// ============================================================================
1437
1438/** Stack definition for {stack_name} with {entity_count} entities */
1439export const {export_name} = {{
1440 name: '{stack_kebab}',
1441{url_line}
1442 views: {{
1443{views_body}
1444 }},
1445}} as const;
1446
1447/** Type alias for the stack */
1448export type {stack_name}Stack = typeof {export_name};
1449
1450/** Entity types in this stack */
1451export type {stack_name}Entity = {entity_union};
1452
1453/** Default export for convenience */
1454export default {export_name};"#,
1455 view_helpers = view_helpers,
1456 stack_name = stack_name,
1457 entity_count = entities.len(),
1458 export_name = export_name,
1459 stack_kebab = stack_kebab,
1460 url_line = url_line,
1461 views_body = views_body,
1462 entity_union = entity_types.join(" | "),
1463 )
1464}
1465
1466fn generate_view_helpers_static() -> String {
1467 r#"// ============================================================================
1468// View Definition Types (framework-agnostic)
1469// ============================================================================
1470
1471/** View definition with embedded entity type */
1472export interface ViewDef<T, TMode extends 'state' | 'list'> {
1473 readonly mode: TMode;
1474 readonly view: string;
1475 /** Phantom field for type inference - not present at runtime */
1476 readonly _entity?: T;
1477}
1478
1479/** Helper to create typed state view definitions (keyed lookups) */
1480function stateView<T>(view: string): ViewDef<T, 'state'> {
1481 return { mode: 'state', view } as const;
1482}
1483
1484/** Helper to create typed list view definitions (collections) */
1485function listView<T>(view: string): ViewDef<T, 'list'> {
1486 return { mode: 'list', view } as const;
1487}"#
1488 .to_string()
1489}
1490
1491fn to_screaming_snake_case(s: &str) -> String {
1493 let mut result = String::new();
1494 for (i, ch) in s.chars().enumerate() {
1495 if ch.is_uppercase() && i > 0 {
1496 result.push('_');
1497 }
1498 result.push(ch.to_uppercase().next().unwrap());
1499 }
1500 result
1501}
1502
1503#[cfg(test)]
1504mod tests {
1505 use super::*;
1506
1507 #[test]
1508 fn test_case_conversions() {
1509 assert_eq!(to_pascal_case("settlement_game"), "SettlementGame");
1510 assert_eq!(to_kebab_case("SettlementGame"), "settlement-game");
1511 }
1512
1513 #[test]
1514 fn test_normalize_for_comparison() {
1515 assert_eq!(normalize_for_comparison("claim_sol"), "claimsol");
1516 assert_eq!(normalize_for_comparison("claimSol"), "claimsol");
1517 assert_eq!(normalize_for_comparison("ClaimSol"), "claimsol");
1518 assert_eq!(
1519 normalize_for_comparison("admin_set_creator"),
1520 "adminsetcreator"
1521 );
1522 assert_eq!(
1523 normalize_for_comparison("AdminSetCreator"),
1524 "adminsetcreator"
1525 );
1526 }
1527
1528 #[test]
1529 fn test_value_to_typescript_type() {
1530 assert_eq!(value_to_typescript_type(&serde_json::json!(42)), "number");
1531 assert_eq!(
1532 value_to_typescript_type(&serde_json::json!("hello")),
1533 "string"
1534 );
1535 assert_eq!(
1536 value_to_typescript_type(&serde_json::json!(true)),
1537 "boolean"
1538 );
1539 assert_eq!(value_to_typescript_type(&serde_json::json!([])), "any[]");
1540 }
1541
1542 #[test]
1543 fn test_derived_view_codegen() {
1544 let spec = SerializableStreamSpec {
1545 state_name: "OreRound".to_string(),
1546 program_id: None,
1547 idl: None,
1548 identity: IdentitySpec {
1549 primary_keys: vec!["id".to_string()],
1550 lookup_indexes: vec![],
1551 },
1552 handlers: vec![],
1553 sections: vec![],
1554 field_mappings: BTreeMap::new(),
1555 resolver_hooks: vec![],
1556 instruction_hooks: vec![],
1557 computed_fields: vec![],
1558 computed_field_specs: vec![],
1559 content_hash: None,
1560 views: vec![
1561 ViewDef {
1562 id: "OreRound/latest".to_string(),
1563 source: ViewSource::Entity {
1564 name: "OreRound".to_string(),
1565 },
1566 pipeline: vec![ViewTransform::Last],
1567 output: ViewOutput::Single,
1568 },
1569 ViewDef {
1570 id: "OreRound/top10".to_string(),
1571 source: ViewSource::Entity {
1572 name: "OreRound".to_string(),
1573 },
1574 pipeline: vec![ViewTransform::Take { count: 10 }],
1575 output: ViewOutput::Collection,
1576 },
1577 ],
1578 };
1579
1580 let output =
1581 compile_serializable_spec(spec, "OreRound".to_string(), None).expect("should compile");
1582
1583 let stack_def = &output.stack_definition;
1584
1585 assert!(
1586 stack_def.contains("listView<OreRound>('OreRound/latest')"),
1587 "Expected 'latest' derived view using listView, got:\n{}",
1588 stack_def
1589 );
1590 assert!(
1591 stack_def.contains("listView<OreRound>('OreRound/top10')"),
1592 "Expected 'top10' derived view using listView, got:\n{}",
1593 stack_def
1594 );
1595 assert!(
1596 stack_def.contains("latest:"),
1597 "Expected 'latest' key, got:\n{}",
1598 stack_def
1599 );
1600 assert!(
1601 stack_def.contains("top10:"),
1602 "Expected 'top10' key, got:\n{}",
1603 stack_def
1604 );
1605 assert!(
1606 stack_def.contains("function listView<T>(view: string): ViewDef<T, 'list'>"),
1607 "Expected listView helper function, got:\n{}",
1608 stack_def
1609 );
1610 }
1611}