1use crate::ast::*;
2use std::collections::{BTreeMap, BTreeSet, HashSet};
3
4#[derive(Debug, Clone)]
6pub struct TypeScriptOutput {
7 pub interfaces: String,
8 pub stack_definition: String,
9 pub imports: String,
10 pub schema_names: Vec<String>,
11}
12
13impl TypeScriptOutput {
14 pub fn full_file(&self) -> String {
15 format!(
16 "{}\n\n{}\n\n{}",
17 self.imports, self.interfaces, self.stack_definition
18 )
19 }
20}
21
22#[derive(Debug, Clone)]
24pub struct TypeScriptConfig {
25 pub package_name: String,
26 pub generate_helpers: bool,
27 pub interface_prefix: String,
28 pub export_const_name: String,
29 pub url: Option<String>,
31}
32
33impl Default for TypeScriptConfig {
34 fn default() -> Self {
35 Self {
36 package_name: "hyperstack-react".to_string(),
37 generate_helpers: true,
38 interface_prefix: "".to_string(),
39 export_const_name: "STACK".to_string(),
40 url: None,
41 }
42 }
43}
44
45pub trait TypeScriptGenerator {
47 fn generate_typescript(&self, config: &TypeScriptConfig) -> String;
48}
49
50pub trait TypeScriptInterfaceGenerator {
52 fn generate_interface(&self, name: &str, config: &TypeScriptConfig) -> String;
53}
54
55pub trait TypeScriptTypeMapper {
57 fn to_typescript_type(&self) -> String;
58}
59
60pub struct TypeScriptCompiler<S> {
62 spec: TypedStreamSpec<S>,
63 entity_name: String,
64 config: TypeScriptConfig,
65 idl: Option<serde_json::Value>, handlers_json: Option<serde_json::Value>, views: Vec<ViewDef>, already_emitted_types: HashSet<String>,
69}
70
71impl<S> TypeScriptCompiler<S> {
72 pub fn new(spec: TypedStreamSpec<S>, entity_name: String) -> Self {
73 Self {
74 spec,
75 entity_name,
76 config: TypeScriptConfig::default(),
77 idl: None,
78 handlers_json: None,
79 views: Vec::new(),
80 already_emitted_types: HashSet::new(),
81 }
82 }
83
84 pub fn with_config(mut self, config: TypeScriptConfig) -> Self {
85 self.config = config;
86 self
87 }
88
89 pub fn with_idl(mut self, idl: Option<serde_json::Value>) -> Self {
90 self.idl = idl;
91 self
92 }
93
94 pub fn with_handlers_json(mut self, handlers: Option<serde_json::Value>) -> Self {
95 self.handlers_json = handlers;
96 self
97 }
98
99 pub fn with_views(mut self, views: Vec<ViewDef>) -> Self {
100 self.views = views;
101 self
102 }
103
104 pub fn with_already_emitted_types(mut self, types: HashSet<String>) -> Self {
105 self.already_emitted_types = types;
106 self
107 }
108
109 pub fn compile(&self) -> TypeScriptOutput {
110 let imports = self.generate_imports();
111 let interfaces = self.generate_interfaces();
112 let schema_output = self.generate_schemas();
113 let combined_interfaces = if schema_output.definitions.is_empty() {
114 interfaces
115 } else if interfaces.is_empty() {
116 schema_output.definitions.clone()
117 } else {
118 format!("{}\n\n{}", interfaces, schema_output.definitions)
119 };
120 let stack_definition = self.generate_stack_definition();
121
122 TypeScriptOutput {
123 imports,
124 interfaces: combined_interfaces,
125 stack_definition,
126 schema_names: schema_output.names,
127 }
128 }
129
130 fn generate_imports(&self) -> String {
131 "import { z } from 'zod';".to_string()
132 }
133
134 fn generate_view_helpers(&self) -> String {
135 r#"// ============================================================================
136// View Definition Types (framework-agnostic)
137// ============================================================================
138
139/** View definition with embedded entity type */
140export interface ViewDef<T, TMode extends 'state' | 'list'> {
141 readonly mode: TMode;
142 readonly view: string;
143 /** Phantom field for type inference - not present at runtime */
144 readonly _entity?: T;
145}
146
147/** Helper to create typed state view definitions (keyed lookups) */
148function stateView<T>(view: string): ViewDef<T, 'state'> {
149 return { mode: 'state', view } as const;
150}
151
152/** Helper to create typed list view definitions (collections) */
153function listView<T>(view: string): ViewDef<T, 'list'> {
154 return { mode: 'list', view } as const;
155}"#
156 .to_string()
157 }
158
159 fn generate_interfaces(&self) -> String {
160 let mut interfaces = Vec::new();
161 let mut processed_types = HashSet::new();
162 let all_sections = self.collect_interface_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();
180 interfaces.extend(nested_interfaces);
181
182 let builtin_interfaces = self.generate_builtin_resolver_interfaces();
183 interfaces.extend(builtin_interfaces);
184
185 if self.has_event_types() {
186 interfaces.push(self.generate_event_wrapper_interface());
187 }
188
189 interfaces.join("\n\n")
190 }
191
192 fn collect_interface_sections(&self) -> BTreeMap<String, Vec<TypeScriptField>> {
193 let mut all_sections: BTreeMap<String, Vec<TypeScriptField>> = BTreeMap::new();
194
195 for handler in &self.spec.handlers {
197 let interface_sections = self.extract_interface_sections_from_handler(handler);
198
199 for (section_name, mut fields) in interface_sections {
200 all_sections
201 .entry(section_name)
202 .or_default()
203 .append(&mut fields);
204 }
205 }
206
207 self.add_unmapped_fields(&mut all_sections);
210
211 all_sections
212 }
213
214 fn deduplicate_fields(&self, mut fields: Vec<TypeScriptField>) -> Vec<TypeScriptField> {
215 let mut seen = HashSet::new();
216 let mut unique_fields = Vec::new();
217
218 fields.sort_by(|a, b| a.name.cmp(&b.name));
220
221 for field in fields {
222 if seen.insert(field.name.clone()) {
223 unique_fields.push(field);
224 }
225 }
226
227 unique_fields
228 }
229
230 fn extract_interface_sections_from_handler(
231 &self,
232 handler: &TypedHandlerSpec<S>,
233 ) -> BTreeMap<String, Vec<TypeScriptField>> {
234 let mut sections: BTreeMap<String, Vec<TypeScriptField>> = BTreeMap::new();
235
236 for mapping in &handler.mappings {
237 if !mapping.emit {
238 continue;
239 }
240 let parts: Vec<&str> = mapping.target_path.split('.').collect();
241
242 if parts.len() > 1 {
243 let section_name = parts[0];
244 let field_name = parts[1];
245
246 let ts_field = TypeScriptField {
247 name: field_name.to_string(),
248 ts_type: self.mapping_to_typescript_type(mapping),
249 optional: self.is_field_optional(mapping),
250 description: None,
251 };
252
253 sections
254 .entry(section_name.to_string())
255 .or_default()
256 .push(ts_field);
257 } else {
258 let ts_field = TypeScriptField {
259 name: mapping.target_path.clone(),
260 ts_type: self.mapping_to_typescript_type(mapping),
261 optional: self.is_field_optional(mapping),
262 description: None,
263 };
264
265 sections
266 .entry("Root".to_string())
267 .or_default()
268 .push(ts_field);
269 }
270 }
271
272 sections
273 }
274
275 fn add_unmapped_fields(&self, sections: &mut BTreeMap<String, Vec<TypeScriptField>>) {
276 if !self.spec.sections.is_empty() {
278 for section in &self.spec.sections {
280 let section_fields = sections.entry(section.name.clone()).or_default();
281
282 for field_info in §ion.fields {
283 if !field_info.emit {
284 continue;
285 }
286 let already_exists = section_fields
288 .iter()
289 .any(|f| f.name == field_info.field_name);
290
291 if !already_exists {
292 let field_path = format!("{}.{}", section.name, field_info.field_name);
294 let effective_field_info =
295 if let Some(mapping) = self.spec.field_mappings.get(&field_path) {
296 if mapping
298 .inner_type
299 .as_ref()
300 .is_some_and(|t| is_builtin_resolver_type(t))
301 {
302 mapping
303 } else {
304 field_info
305 }
306 } else {
307 field_info
308 };
309
310 section_fields.push(TypeScriptField {
311 name: field_info.field_name.clone(),
312 ts_type: self.field_type_info_to_typescript(effective_field_info),
313 optional: field_info.is_optional,
314 description: None,
315 });
316 }
317 }
318 }
319 } else {
320 for (field_path, field_type_info) in &self.spec.field_mappings {
322 if !field_type_info.emit {
323 continue;
324 }
325 let parts: Vec<&str> = field_path.split('.').collect();
326 if parts.len() > 1 {
327 let section_name = parts[0];
328 let field_name = parts[1];
329
330 let section_fields = sections.entry(section_name.to_string()).or_default();
331
332 let already_exists = section_fields.iter().any(|f| f.name == field_name);
333
334 if !already_exists {
335 section_fields.push(TypeScriptField {
336 name: field_name.to_string(),
337 ts_type: self.base_type_to_typescript(
338 &field_type_info.base_type,
339 field_type_info.is_array,
340 ),
341 optional: field_type_info.is_optional,
342 description: None,
343 });
344 }
345 }
346 }
347 }
348 }
349
350 fn generate_interface_from_fields(&self, name: &str, fields: &[TypeScriptField]) -> String {
351 let interface_name = self.section_interface_name(name);
352
353 let field_definitions: Vec<String> = fields
356 .iter()
357 .map(|field| {
358 let ts_type = if field.optional {
359 format!("{} | null", field.ts_type)
361 } else {
362 field.ts_type.clone()
363 };
364 format!(" {}?: {};", field.name, ts_type)
365 })
366 .collect();
367
368 format!(
369 "export interface {} {{\n{}\n}}",
370 interface_name,
371 field_definitions.join("\n")
372 )
373 }
374
375 fn section_interface_name(&self, name: &str) -> String {
376 if name == "Root" {
377 format!(
378 "{}{}",
379 self.config.interface_prefix,
380 to_pascal_case(&self.entity_name)
381 )
382 } else {
383 let base_name = if self.entity_name.contains("Game") {
386 "Game"
387 } else {
388 &self.entity_name
389 };
390 format!(
391 "{}{}{}",
392 self.config.interface_prefix,
393 base_name,
394 to_pascal_case(name)
395 )
396 }
397 }
398
399 fn generate_main_entity_interface(&self) -> String {
400 let entity_name = to_pascal_case(&self.entity_name);
401
402 let mut sections = BTreeMap::new();
404
405 for handler in &self.spec.handlers {
406 for mapping in &handler.mappings {
407 if !mapping.emit {
408 continue;
409 }
410 let parts: Vec<&str> = mapping.target_path.split('.').collect();
411 if parts.len() > 1 {
412 sections.insert(parts[0], true);
413 }
414 }
415 }
416
417 if !self.spec.sections.is_empty() {
418 for section in &self.spec.sections {
419 if section.fields.iter().any(|field| field.emit) {
420 sections.insert(§ion.name, true);
421 }
422 }
423 } else {
424 for mapping in &self.spec.handlers {
425 for field_mapping in &mapping.mappings {
426 if !field_mapping.emit {
427 continue;
428 }
429 let parts: Vec<&str> = field_mapping.target_path.split('.').collect();
430 if parts.len() > 1 {
431 sections.insert(parts[0], true);
432 }
433 }
434 }
435 }
436
437 let mut fields = Vec::new();
438
439 for section in sections.keys() {
442 if !is_root_section(section) {
443 let base_name = if self.entity_name.contains("Game") {
444 "Game"
445 } else {
446 &self.entity_name
447 };
448 let section_interface_name = format!("{}{}", base_name, to_pascal_case(section));
449 fields.push(format!(" {}?: {};", section, section_interface_name));
451 }
452 }
453
454 for section in &self.spec.sections {
457 if is_root_section(§ion.name) {
458 for field in §ion.fields {
459 if !field.emit {
460 continue;
461 }
462 let base_ts_type = self.field_type_info_to_typescript(field);
463 let ts_type = if field.is_optional {
464 format!("{} | null", base_ts_type)
465 } else {
466 base_ts_type
467 };
468 fields.push(format!(" {}?: {};", field.field_name, ts_type));
469 }
470 }
471 }
472
473 if fields.is_empty() {
474 fields.push(" // Generated interface - extend as needed".to_string());
475 }
476
477 format!(
478 "export interface {} {{\n{}\n}}",
479 entity_name,
480 fields.join("\n")
481 )
482 }
483
484 fn generate_schemas(&self) -> SchemaOutput {
485 let mut definitions = Vec::new();
486 let mut names = Vec::new();
487 let mut seen = HashSet::new();
488
489 let mut push_schema = |schema_name: String, definition: String| {
490 if seen.insert(schema_name.clone()) {
491 names.push(schema_name);
492 definitions.push(definition);
493 }
494 };
495
496 for (schema_name, definition) in self.generate_builtin_resolver_schemas() {
497 push_schema(schema_name, definition);
498 }
499
500 if self.has_event_types() {
501 push_schema(
502 "EventWrapperSchema".to_string(),
503 self.generate_event_wrapper_schema(),
504 );
505 }
506
507 for (schema_name, definition) in self.generate_resolved_type_schemas() {
508 push_schema(schema_name, definition);
509 }
510
511 for (schema_name, definition) in self.generate_event_schemas() {
512 push_schema(schema_name, definition);
513 }
514
515 for (schema_name, definition) in self.generate_idl_enum_schemas() {
516 push_schema(schema_name, definition);
517 }
518
519 let all_sections = self.collect_interface_sections();
520
521 for (section_name, fields) in &all_sections {
522 if is_root_section(section_name) {
523 continue;
524 }
525 let deduplicated_fields = self.deduplicate_fields(fields.clone());
526 let interface_name = self.section_interface_name(section_name);
527 let schema_definition =
528 self.generate_schema_for_fields(&interface_name, &deduplicated_fields, false);
529 push_schema(format!("{}Schema", interface_name), schema_definition);
530 }
531
532 let entity_name = to_pascal_case(&self.entity_name);
533 let main_fields = self.collect_main_entity_fields();
534 let entity_schema = self.generate_schema_for_fields(&entity_name, &main_fields, false);
535 push_schema(format!("{}Schema", entity_name), entity_schema);
536
537 let completed_schema = self.generate_completed_entity_schema(&entity_name);
538 push_schema(format!("{}CompletedSchema", entity_name), completed_schema);
539
540 SchemaOutput {
541 definitions: definitions.join("\n\n"),
542 names,
543 }
544 }
545
546 fn generate_event_wrapper_schema(&self) -> String {
547 r#"export const EventWrapperSchema = <T extends z.ZodTypeAny>(data: T) => z.object({
548 timestamp: z.number(),
549 data,
550 slot: z.number().optional(),
551 signature: z.string().optional(),
552});"#
553 .to_string()
554 }
555
556 fn generate_builtin_resolver_schemas(&self) -> Vec<(String, String)> {
557 let mut schemas = Vec::new();
558 let registry = crate::resolvers::builtin_resolver_registry();
559
560 for resolver in registry.definitions() {
561 let output_type = resolver.output_type();
562 let should_emit = self.uses_builtin_type(output_type)
563 && !self.already_emitted_types.contains(output_type);
564
565 let extra_types_used = if let Some(ts_schema) = resolver.typescript_schema() {
567 ts_schema.definition.lines().any(|line| {
569 let line = line.trim();
570 if let Some(rest) = line.strip_prefix("export const ") {
572 let parts: Vec<&str> = rest.split_whitespace().collect();
573 if parts.len() >= 2 && parts[1] == "=" {
574 let schema_name = parts[0];
576 if let Some(type_name) = schema_name.strip_suffix("Schema") {
577 return self.uses_builtin_type(type_name)
578 && !self.already_emitted_types.contains(type_name);
579 }
580 }
581 }
582 false
583 })
584 } else {
585 false
586 };
587
588 if (should_emit || extra_types_used)
589 && !self.already_emitted_types.contains(output_type)
590 {
591 if let Some(schema) = resolver.typescript_schema() {
592 schemas.push((schema.name.to_string(), schema.definition.to_string()));
593 }
594 }
595 }
596
597 schemas
598 }
599
600 fn uses_builtin_type(&self, type_name: &str) -> bool {
601 for section in &self.spec.sections {
603 for field in §ion.fields {
604 if field.inner_type.as_deref() == Some(type_name) {
605 return true;
606 }
607 }
608 }
609 for field_info in self.spec.field_mappings.values() {
611 if field_info.inner_type.as_deref() == Some(type_name) {
612 return true;
613 }
614 }
615 false
616 }
617
618 fn generate_builtin_resolver_interfaces(&self) -> Vec<String> {
619 let mut interfaces = Vec::new();
620 let registry = crate::resolvers::builtin_resolver_registry();
621
622 for resolver in registry.definitions() {
623 let output_type = resolver.output_type();
624 let should_emit = self.uses_builtin_type(output_type)
625 && !self.already_emitted_types.contains(output_type);
626
627 let extra_types_used = if let Some(ts_interface) = resolver.typescript_interface() {
629 ts_interface.lines().any(|line| {
631 let line = line.trim();
632 if let Some(rest) = line.strip_prefix("export type ") {
634 if let Some(type_name) = rest.split_whitespace().next() {
635 return self.uses_builtin_type(type_name)
636 && !self.already_emitted_types.contains(type_name);
637 }
638 } else if let Some(rest) = line.strip_prefix("export interface ") {
639 if let Some(type_name) = rest.split_whitespace().next() {
640 return self.uses_builtin_type(type_name)
641 && !self.already_emitted_types.contains(type_name);
642 }
643 }
644 false
645 })
646 } else {
647 false
648 };
649
650 if should_emit || extra_types_used {
651 if let Some(interface) = resolver.typescript_interface() {
652 interfaces.push(interface.to_string());
653 }
654 }
655 }
656
657 interfaces
658 }
659
660 fn collect_main_entity_fields(&self) -> Vec<TypeScriptField> {
661 let mut sections = BTreeMap::new();
662
663 for handler in &self.spec.handlers {
664 for mapping in &handler.mappings {
665 if !mapping.emit {
666 continue;
667 }
668 let parts: Vec<&str> = mapping.target_path.split('.').collect();
669 if parts.len() > 1 {
670 sections.insert(parts[0], true);
671 }
672 }
673 }
674
675 if !self.spec.sections.is_empty() {
676 for section in &self.spec.sections {
677 if section.fields.iter().any(|field| field.emit) {
678 sections.insert(§ion.name, true);
679 }
680 }
681 } else {
682 for mapping in &self.spec.handlers {
683 for field_mapping in &mapping.mappings {
684 if !field_mapping.emit {
685 continue;
686 }
687 let parts: Vec<&str> = field_mapping.target_path.split('.').collect();
688 if parts.len() > 1 {
689 sections.insert(parts[0], true);
690 }
691 }
692 }
693 }
694
695 let mut fields = Vec::new();
696
697 for section in sections.keys() {
698 if !is_root_section(section) {
699 let base_name = if self.entity_name.contains("Game") {
700 "Game"
701 } else {
702 &self.entity_name
703 };
704 let section_interface_name = format!("{}{}", base_name, to_pascal_case(section));
705 fields.push(TypeScriptField {
706 name: section.to_string(),
707 ts_type: section_interface_name,
708 optional: false,
709 description: None,
710 });
711 }
712 }
713
714 for section in &self.spec.sections {
715 if is_root_section(§ion.name) {
716 for field in §ion.fields {
717 if !field.emit {
718 continue;
719 }
720 fields.push(TypeScriptField {
721 name: field.field_name.clone(),
722 ts_type: self.field_type_info_to_typescript(field),
723 optional: field.is_optional,
724 description: None,
725 });
726 }
727 }
728 }
729
730 fields
731 }
732
733 fn generate_schema_for_fields(
734 &self,
735 name: &str,
736 fields: &[TypeScriptField],
737 required: bool,
738 ) -> String {
739 let mut field_definitions = Vec::new();
740
741 for field in fields {
742 let base_schema = self.typescript_type_to_zod(&field.ts_type);
743 let schema = if required {
744 base_schema
745 } else {
746 let with_nullable = if field.optional {
747 format!("{}.nullable()", base_schema)
748 } else {
749 base_schema
750 };
751 format!("{}.optional()", with_nullable)
752 };
753
754 field_definitions.push(format!(" {}: {},", field.name, schema));
755 }
756
757 format!(
758 "export const {}Schema = z.object({{\n{}\n}});",
759 name,
760 field_definitions.join("\n")
761 )
762 }
763
764 fn generate_completed_entity_schema(&self, entity_name: &str) -> String {
765 let main_fields = self.collect_main_entity_fields();
766 self.generate_schema_for_fields(&format!("{}Completed", entity_name), &main_fields, true)
767 }
768
769 fn generate_resolved_type_schemas(&self) -> Vec<(String, String)> {
770 let mut schemas = Vec::new();
771 let mut generated_types = HashSet::new();
772
773 for section in &self.spec.sections {
774 for field_info in §ion.fields {
775 if let Some(resolved) = &field_info.resolved_type {
776 let type_name = to_pascal_case(&resolved.type_name);
777
778 if !generated_types.insert(type_name.clone()) {
779 continue;
780 }
781
782 if resolved.is_enum {
783 let variants: Vec<String> = resolved
784 .enum_variants
785 .iter()
786 .map(|v| format!("\"{}\"", to_pascal_case(v)))
787 .collect();
788 let schema = if variants.is_empty() {
789 format!("export const {}Schema = z.string();", type_name)
790 } else {
791 format!(
792 "export const {}Schema = z.enum([{}]);",
793 type_name,
794 variants.join(", ")
795 )
796 };
797 schemas.push((format!("{}Schema", type_name), schema));
798 continue;
799 }
800
801 let mut field_definitions = Vec::new();
802 for field in &resolved.fields {
803 let base = self.resolved_field_to_zod(field);
804 let schema = if field.is_optional {
805 format!("{}.nullable().optional()", base)
806 } else {
807 format!("{}.optional()", base)
808 };
809 field_definitions.push(format!(" {}: {},", field.field_name, schema));
810 }
811
812 let schema = format!(
813 "export const {}Schema = z.object({{\n{}\n}});",
814 type_name,
815 field_definitions.join("\n")
816 );
817 schemas.push((format!("{}Schema", type_name), schema));
818 }
819 }
820 }
821
822 schemas
823 }
824
825 fn generate_event_schemas(&self) -> Vec<(String, String)> {
826 let mut schemas = Vec::new();
827 let mut generated_types = HashSet::new();
828
829 let handlers = match &self.handlers_json {
830 Some(h) => h.as_array(),
831 None => return schemas,
832 };
833
834 let handlers_array = match handlers {
835 Some(arr) => arr,
836 None => return schemas,
837 };
838
839 for handler in handlers_array {
840 if let Some(mappings) = handler.get("mappings").and_then(|m| m.as_array()) {
841 for mapping in mappings {
842 if let Some(target_path) = mapping.get("target_path").and_then(|t| t.as_str()) {
843 if target_path.contains(".events.") || target_path.starts_with("events.") {
844 if let Some(source) = mapping.get("source") {
845 if let Some(event_data) = self.extract_event_data(source) {
846 if let Some(handler_source) = handler.get("source") {
847 if let Some(instruction_name) =
848 self.extract_instruction_name(handler_source)
849 {
850 let event_field_name =
851 target_path.split('.').next_back().unwrap_or("");
852 let interface_name = format!(
853 "{}Event",
854 to_pascal_case(event_field_name)
855 );
856
857 if generated_types.insert(interface_name.clone()) {
858 if let Some(schema) = self
859 .generate_event_schema_from_idl(
860 &interface_name,
861 &instruction_name,
862 &event_data,
863 )
864 {
865 schemas.push((
866 format!("{}Schema", interface_name),
867 schema,
868 ));
869 }
870 }
871 }
872 }
873 }
874 }
875 }
876 }
877 }
878 }
879 }
880
881 schemas
882 }
883
884 fn generate_event_schema_from_idl(
885 &self,
886 interface_name: &str,
887 rust_instruction_name: &str,
888 captured_fields: &[(String, Option<String>)],
889 ) -> Option<String> {
890 if captured_fields.is_empty() {
891 return Some(format!(
892 "export const {}Schema = z.object({{}});",
893 interface_name
894 ));
895 }
896
897 let idl_value = self.idl.as_ref()?;
898 let instructions = idl_value.get("instructions")?.as_array()?;
899
900 let instruction = self.find_instruction_in_idl(instructions, rust_instruction_name)?;
901 let args = instruction.get("args")?.as_array()?;
902
903 let mut fields = Vec::new();
904 for (field_name, transform) in captured_fields {
905 for arg in args {
906 if let Some(arg_name) = arg.get("name").and_then(|n| n.as_str()) {
907 if arg_name == field_name {
908 if let Some(arg_type) = arg.get("type") {
909 let ts_type =
910 self.idl_type_to_typescript(arg_type, transform.as_deref());
911 let schema = self.typescript_type_to_zod(&ts_type);
912 fields.push(format!(" {}: {},", field_name, schema));
913 }
914 break;
915 }
916 }
917 }
918 }
919
920 Some(format!(
921 "export const {}Schema = z.object({{\n{}\n}});",
922 interface_name,
923 fields.join("\n")
924 ))
925 }
926
927 fn generate_idl_enum_schemas(&self) -> Vec<(String, String)> {
928 let mut schemas = Vec::new();
929 let mut generated_types = self.already_emitted_types.clone();
930
931 let idl_value = match &self.idl {
932 Some(idl) => idl,
933 None => return schemas,
934 };
935
936 let types_array = match idl_value.get("types").and_then(|v| v.as_array()) {
937 Some(types) => types,
938 None => return schemas,
939 };
940
941 for type_def in types_array {
942 if let (Some(type_name), Some(type_obj)) = (
943 type_def.get("name").and_then(|v| v.as_str()),
944 type_def.get("type").and_then(|v| v.as_object()),
945 ) {
946 if type_obj.get("kind").and_then(|v| v.as_str()) == Some("enum") {
947 if !generated_types.insert(type_name.to_string()) {
948 continue;
949 }
950 if let Some(variants) = type_obj.get("variants").and_then(|v| v.as_array()) {
951 let variant_names: Vec<String> = variants
952 .iter()
953 .filter_map(|v| v.get("name").and_then(|n| n.as_str()))
954 .map(|s| format!("\"{}\"", to_pascal_case(s)))
955 .collect();
956
957 let interface_name = to_pascal_case(type_name);
958 let schema = if variant_names.is_empty() {
959 format!("export const {}Schema = z.string();", interface_name)
960 } else {
961 format!(
962 "export const {}Schema = z.enum([{}]);",
963 interface_name,
964 variant_names.join(", ")
965 )
966 };
967 schemas.push((format!("{}Schema", interface_name), schema));
968 }
969 }
970 }
971 }
972
973 schemas
974 }
975
976 fn typescript_type_to_zod(&self, ts_type: &str) -> String {
977 let trimmed = ts_type.trim();
978
979 if let Some(inner) = trimmed.strip_suffix("[]") {
980 return format!("z.array({})", self.typescript_type_to_zod(inner));
981 }
982
983 if let Some(inner) = trimmed.strip_prefix("EventWrapper<") {
984 if let Some(inner) = inner.strip_suffix('>') {
985 return format!("EventWrapperSchema({})", self.typescript_type_to_zod(inner));
986 }
987 }
988
989 match trimmed {
990 "string" => "z.string()".to_string(),
991 "number" => "z.number()".to_string(),
992 "boolean" => "z.boolean()".to_string(),
993 "any" => "z.any()".to_string(),
994 "Record<string, any>" => "z.record(z.any())".to_string(),
995 _ => format!("{}Schema", trimmed),
996 }
997 }
998
999 fn resolved_field_to_zod(&self, field: &ResolvedField) -> String {
1000 let base = self.base_type_to_zod(&field.base_type);
1001 if field.is_array {
1002 format!("z.array({})", base)
1003 } else {
1004 base
1005 }
1006 }
1007
1008 fn generate_stack_definition(&self) -> String {
1009 let stack_name = to_kebab_case(&self.entity_name);
1010 let entity_pascal = to_pascal_case(&self.entity_name);
1011 let export_name = format!(
1012 "{}_{}",
1013 self.entity_name.to_uppercase(),
1014 self.config.export_const_name
1015 );
1016
1017 let view_helpers = self.generate_view_helpers();
1018 let derived_views = self.generate_derived_view_entries();
1019 let schema_names = self.generate_schemas().names;
1020 let mut unique_schemas: BTreeSet<String> = BTreeSet::new();
1021 for name in schema_names {
1022 unique_schemas.insert(name);
1023 }
1024 let schemas_block = if unique_schemas.is_empty() {
1025 String::new()
1026 } else {
1027 let schema_entries: Vec<String> = unique_schemas
1028 .iter()
1029 .filter(|name| name.ends_with("Schema"))
1030 .map(|name| format!(" {}: {},", name.trim_end_matches("Schema"), name))
1031 .collect();
1032 if schema_entries.is_empty() {
1033 String::new()
1034 } else {
1035 format!("\n schemas: {{\n{}\n }},", schema_entries.join("\n"))
1036 }
1037 };
1038
1039 let url_line = match &self.config.url {
1041 Some(url) => format!(" url: '{}',", url),
1042 None => " // url: 'wss://your-stack-url.stack.usehyperstack.com', // TODO: Set after first deployment".to_string(),
1043 };
1044
1045 format!(
1046 r#"{}
1047
1048// ============================================================================
1049// Stack Definition
1050// ============================================================================
1051
1052/** Stack definition for {} */
1053export const {} = {{
1054 name: '{}',
1055{}
1056 views: {{
1057 {}: {{
1058 state: stateView<{}>('{}/state'),
1059 list: listView<{}>('{}/list'),{}
1060 }},
1061 }},{}
1062}} as const;
1063
1064/** Type alias for the stack */
1065export type {}Stack = typeof {};
1066
1067/** Default export for convenience */
1068export default {};"#,
1069 view_helpers,
1070 entity_pascal,
1071 export_name,
1072 stack_name,
1073 url_line,
1074 self.entity_name,
1075 entity_pascal,
1076 self.entity_name,
1077 entity_pascal,
1078 self.entity_name,
1079 derived_views,
1080 schemas_block,
1081 entity_pascal,
1082 export_name,
1083 export_name
1084 )
1085 }
1086
1087 fn generate_derived_view_entries(&self) -> String {
1088 let derived_views: Vec<&ViewDef> = self
1089 .views
1090 .iter()
1091 .filter(|v| {
1092 !v.id.ends_with("/state")
1093 && !v.id.ends_with("/list")
1094 && v.id.starts_with(&self.entity_name)
1095 })
1096 .collect();
1097
1098 if derived_views.is_empty() {
1099 return String::new();
1100 }
1101
1102 let entity_pascal = to_pascal_case(&self.entity_name);
1103 let mut entries = Vec::new();
1104
1105 for view in derived_views {
1106 let view_name = view.id.split('/').nth(1).unwrap_or("unknown");
1107
1108 entries.push(format!(
1109 "\n {}: listView<{}>('{}'),",
1110 view_name, entity_pascal, view.id
1111 ));
1112 }
1113
1114 entries.join("")
1115 }
1116
1117 fn mapping_to_typescript_type(&self, mapping: &TypedFieldMapping<S>) -> String {
1118 if let Some(field_info) = self.spec.field_mappings.get(&mapping.target_path) {
1120 let ts_type = self.field_type_info_to_typescript(field_info);
1121
1122 if matches!(mapping.population, PopulationStrategy::Append) {
1124 return if ts_type.ends_with("[]") {
1125 ts_type
1126 } else {
1127 format!("{}[]", ts_type)
1128 };
1129 }
1130
1131 return ts_type;
1132 }
1133
1134 match &mapping.population {
1136 PopulationStrategy::Append => {
1137 match &mapping.source {
1139 MappingSource::AsEvent { .. } => "any[]".to_string(),
1140 _ => "any[]".to_string(),
1141 }
1142 }
1143 _ => {
1144 let base_type = match &mapping.source {
1146 MappingSource::FromSource { .. } => {
1147 self.infer_type_from_field_name(&mapping.target_path)
1148 }
1149 MappingSource::Constant(value) => value_to_typescript_type(value),
1150 MappingSource::AsEvent { .. } => "any".to_string(),
1151 _ => "any".to_string(),
1152 };
1153
1154 if let Some(transform) = &mapping.transform {
1156 match transform {
1157 Transformation::HexEncode | Transformation::HexDecode => {
1158 "string".to_string()
1159 }
1160 Transformation::Base58Encode | Transformation::Base58Decode => {
1161 "string".to_string()
1162 }
1163 Transformation::ToString => "string".to_string(),
1164 Transformation::ToNumber => "number".to_string(),
1165 }
1166 } else {
1167 base_type
1168 }
1169 }
1170 }
1171 }
1172
1173 fn field_type_info_to_typescript(&self, field_info: &FieldTypeInfo) -> String {
1174 if let Some(resolved) = &field_info.resolved_type {
1175 let interface_name = self.resolved_type_to_interface_name(resolved);
1176
1177 let base_type = if resolved.is_event || (resolved.is_instruction && field_info.is_array)
1178 {
1179 format!("EventWrapper<{}>", interface_name)
1180 } else {
1181 interface_name
1182 };
1183
1184 let with_array = if field_info.is_array {
1185 format!("{}[]", base_type)
1186 } else {
1187 base_type
1188 };
1189
1190 return with_array;
1191 }
1192
1193 if let Some(inner_type) = &field_info.inner_type {
1194 if is_builtin_resolver_type(inner_type) {
1195 return inner_type.clone();
1196 }
1197 }
1198
1199 if field_info.base_type == BaseType::Any
1200 || (field_info.base_type == BaseType::Array
1201 && field_info.inner_type.as_deref() == Some("Value"))
1202 {
1203 if let Some(event_type) = self.find_event_interface_for_field(&field_info.field_name) {
1204 return if field_info.is_array {
1205 format!("{}[]", event_type)
1206 } else if field_info.is_optional {
1207 format!("{} | null", event_type)
1208 } else {
1209 event_type
1210 };
1211 }
1212 }
1213
1214 self.base_type_to_typescript(&field_info.base_type, field_info.is_array)
1215 }
1216
1217 fn find_event_interface_for_field(&self, field_name: &str) -> Option<String> {
1219 let handlers = self.handlers_json.as_ref()?.as_array()?;
1221
1222 for handler in handlers {
1224 if let Some(mappings) = handler.get("mappings").and_then(|m| m.as_array()) {
1225 for mapping in mappings {
1226 if let Some(target_path) = mapping.get("target_path").and_then(|t| t.as_str()) {
1227 let target_parts: Vec<&str> = target_path.split('.').collect();
1229 if let Some(target_field) = target_parts.last() {
1230 if *target_field == field_name {
1231 if let Some(source) = mapping.get("source") {
1233 if self.extract_event_data(source).is_some() {
1234 return Some(format!(
1236 "{}Event",
1237 to_pascal_case(field_name)
1238 ));
1239 }
1240 }
1241 }
1242 }
1243 }
1244 }
1245 }
1246 }
1247 None
1248 }
1249
1250 fn resolved_type_to_interface_name(&self, resolved: &ResolvedStructType) -> String {
1252 to_pascal_case(&resolved.type_name)
1253 }
1254
1255 fn generate_nested_interfaces(&self) -> Vec<String> {
1257 let mut interfaces = Vec::new();
1258 let mut generated_types = self.already_emitted_types.clone();
1259
1260 for section in &self.spec.sections {
1262 for field_info in §ion.fields {
1263 if let Some(resolved) = &field_info.resolved_type {
1264 let type_name = resolved.type_name.clone();
1265
1266 if generated_types.insert(type_name) {
1268 let interface = self.generate_interface_for_resolved_type(resolved);
1269 interfaces.push(interface);
1270 }
1271 }
1272 }
1273 }
1274
1275 interfaces.extend(self.generate_event_interfaces(&mut generated_types));
1277
1278 if let Some(idl_value) = &self.idl {
1280 if let Some(types_array) = idl_value.get("types").and_then(|v| v.as_array()) {
1281 for type_def in types_array {
1282 if let (Some(type_name), Some(type_obj)) = (
1283 type_def.get("name").and_then(|v| v.as_str()),
1284 type_def.get("type").and_then(|v| v.as_object()),
1285 ) {
1286 if type_obj.get("kind").and_then(|v| v.as_str()) == Some("enum") {
1287 if generated_types.insert(type_name.to_string()) {
1289 if let Some(variants) =
1290 type_obj.get("variants").and_then(|v| v.as_array())
1291 {
1292 let variant_names: Vec<String> = variants
1293 .iter()
1294 .filter_map(|v| {
1295 v.get("name")
1296 .and_then(|n| n.as_str())
1297 .map(|s| s.to_string())
1298 })
1299 .collect();
1300
1301 if !variant_names.is_empty() {
1302 let interface_name = to_pascal_case(type_name);
1303 let variant_strings: Vec<String> = variant_names
1304 .iter()
1305 .map(|v| format!("\"{}\"", to_pascal_case(v)))
1306 .collect();
1307
1308 let enum_type = format!(
1309 "export type {} = {};",
1310 interface_name,
1311 variant_strings.join(" | ")
1312 );
1313 interfaces.push(enum_type);
1314 }
1315 }
1316 }
1317 }
1318 }
1319 }
1320 }
1321 }
1322
1323 interfaces
1324 }
1325
1326 fn generate_event_interfaces(&self, generated_types: &mut HashSet<String>) -> Vec<String> {
1328 let mut interfaces = Vec::new();
1329
1330 let handlers = match &self.handlers_json {
1332 Some(h) => h.as_array(),
1333 None => return interfaces,
1334 };
1335
1336 let handlers_array = match handlers {
1337 Some(arr) => arr,
1338 None => return interfaces,
1339 };
1340
1341 for handler in handlers_array {
1343 if let Some(mappings) = handler.get("mappings").and_then(|m| m.as_array()) {
1345 for mapping in mappings {
1346 if let Some(target_path) = mapping.get("target_path").and_then(|t| t.as_str()) {
1347 if target_path.contains(".events.") || target_path.starts_with("events.") {
1349 if let Some(source) = mapping.get("source") {
1351 if let Some(event_data) = self.extract_event_data(source) {
1352 if let Some(handler_source) = handler.get("source") {
1354 if let Some(instruction_name) =
1355 self.extract_instruction_name(handler_source)
1356 {
1357 let event_field_name =
1359 target_path.split('.').next_back().unwrap_or("");
1360 let interface_name = format!(
1361 "{}Event",
1362 to_pascal_case(event_field_name)
1363 );
1364
1365 if generated_types.insert(interface_name.clone()) {
1367 if let Some(interface) = self
1368 .generate_event_interface_from_idl(
1369 &interface_name,
1370 &instruction_name,
1371 &event_data,
1372 )
1373 {
1374 interfaces.push(interface);
1375 }
1376 }
1377 }
1378 }
1379 }
1380 }
1381 }
1382 }
1383 }
1384 }
1385 }
1386
1387 interfaces
1388 }
1389
1390 fn extract_event_data(
1392 &self,
1393 source: &serde_json::Value,
1394 ) -> Option<Vec<(String, Option<String>)>> {
1395 if let Some(as_event) = source.get("AsEvent") {
1396 if let Some(fields) = as_event.get("fields").and_then(|f| f.as_array()) {
1397 let mut event_fields = Vec::new();
1398 for field in fields {
1399 if let Some(from_source) = field.get("FromSource") {
1400 if let Some(path) = from_source
1401 .get("path")
1402 .and_then(|p| p.get("segments"))
1403 .and_then(|s| s.as_array())
1404 {
1405 if let Some(field_name) = path.last().and_then(|v| v.as_str()) {
1407 let transform = from_source
1408 .get("transform")
1409 .and_then(|t| t.as_str())
1410 .map(|s| s.to_string());
1411 event_fields.push((field_name.to_string(), transform));
1412 }
1413 }
1414 }
1415 }
1416 return Some(event_fields);
1417 }
1418 }
1419 None
1420 }
1421
1422 fn extract_instruction_name(&self, source: &serde_json::Value) -> Option<String> {
1424 if let Some(source_obj) = source.get("Source") {
1425 if let Some(type_name) = source_obj.get("type_name").and_then(|t| t.as_str()) {
1426 let instruction_part =
1427 crate::event_type_helpers::strip_event_type_suffix(type_name);
1428 return Some(instruction_part.to_string());
1429 }
1430 }
1431 None
1432 }
1433
1434 fn find_instruction_in_idl<'a>(
1438 &self,
1439 instructions: &'a [serde_json::Value],
1440 rust_name: &str,
1441 ) -> Option<&'a serde_json::Value> {
1442 let normalized_search = normalize_for_comparison(rust_name);
1443
1444 for instruction in instructions {
1445 if let Some(idl_name) = instruction.get("name").and_then(|n| n.as_str()) {
1446 if normalize_for_comparison(idl_name) == normalized_search {
1447 return Some(instruction);
1448 }
1449 }
1450 }
1451 None
1452 }
1453
1454 fn generate_event_interface_from_idl(
1456 &self,
1457 interface_name: &str,
1458 rust_instruction_name: &str,
1459 captured_fields: &[(String, Option<String>)],
1460 ) -> Option<String> {
1461 if captured_fields.is_empty() {
1462 return Some(format!("export interface {} {{}}", interface_name));
1463 }
1464
1465 let idl_value = self.idl.as_ref()?;
1466 let instructions = idl_value.get("instructions")?.as_array()?;
1467
1468 let instruction = self.find_instruction_in_idl(instructions, rust_instruction_name)?;
1469 let args = instruction.get("args")?.as_array()?;
1470
1471 let mut fields = Vec::new();
1472 for (field_name, transform) in captured_fields {
1473 for arg in args {
1474 if let Some(arg_name) = arg.get("name").and_then(|n| n.as_str()) {
1475 if arg_name == field_name {
1476 if let Some(arg_type) = arg.get("type") {
1477 let ts_type =
1478 self.idl_type_to_typescript(arg_type, transform.as_deref());
1479 fields.push(format!(" {}: {};", field_name, ts_type));
1480 }
1481 break;
1482 }
1483 }
1484 }
1485 }
1486
1487 if !fields.is_empty() {
1488 return Some(format!(
1489 "export interface {} {{\n{}\n}}",
1490 interface_name,
1491 fields.join("\n")
1492 ));
1493 }
1494
1495 None
1496 }
1497
1498 fn idl_type_to_typescript(
1500 &self,
1501 idl_type: &serde_json::Value,
1502 transform: Option<&str>,
1503 ) -> String {
1504 #![allow(clippy::only_used_in_recursion)]
1505 if transform == Some("HexEncode") {
1507 return "string".to_string();
1508 }
1509
1510 if let Some(type_str) = idl_type.as_str() {
1512 return match type_str {
1513 "u8" | "u16" | "u32" | "u64" | "u128" | "i8" | "i16" | "i32" | "i64" | "i128" => {
1514 "number".to_string()
1515 }
1516 "f32" | "f64" => "number".to_string(),
1517 "bool" => "boolean".to_string(),
1518 "string" => "string".to_string(),
1519 "pubkey" | "publicKey" => "string".to_string(),
1520 "bytes" => "string".to_string(),
1521 _ => "any".to_string(),
1522 };
1523 }
1524
1525 if let Some(type_obj) = idl_type.as_object() {
1527 if let Some(option_type) = type_obj.get("option") {
1528 let inner = self.idl_type_to_typescript(option_type, None);
1529 return format!("{} | null", inner);
1530 }
1531 if let Some(vec_type) = type_obj.get("vec") {
1532 let inner = self.idl_type_to_typescript(vec_type, None);
1533 return format!("{}[]", inner);
1534 }
1535 }
1536
1537 "any".to_string()
1538 }
1539
1540 fn generate_interface_for_resolved_type(&self, resolved: &ResolvedStructType) -> String {
1542 let interface_name = to_pascal_case(&resolved.type_name);
1543
1544 if resolved.is_enum {
1546 let variants: Vec<String> = resolved
1547 .enum_variants
1548 .iter()
1549 .map(|v| format!("\"{}\"", to_pascal_case(v)))
1550 .collect();
1551
1552 return format!("export type {} = {};", interface_name, variants.join(" | "));
1553 }
1554
1555 let fields: Vec<String> = resolved
1558 .fields
1559 .iter()
1560 .map(|field| {
1561 let base_ts_type = self.resolved_field_to_typescript(field);
1562 let ts_type = if field.is_optional {
1563 format!("{} | null", base_ts_type)
1564 } else {
1565 base_ts_type
1566 };
1567 format!(" {}?: {};", field.field_name, ts_type)
1568 })
1569 .collect();
1570
1571 format!(
1572 "export interface {} {{\n{}\n}}",
1573 interface_name,
1574 fields.join("\n")
1575 )
1576 }
1577
1578 fn resolved_field_to_typescript(&self, field: &ResolvedField) -> String {
1580 let base_ts = self.base_type_to_typescript(&field.base_type, false);
1581
1582 if field.is_array {
1583 format!("{}[]", base_ts)
1584 } else {
1585 base_ts
1586 }
1587 }
1588
1589 fn has_event_types(&self) -> bool {
1591 for section in &self.spec.sections {
1592 for field_info in §ion.fields {
1593 if let Some(resolved) = &field_info.resolved_type {
1594 if resolved.is_event || (resolved.is_instruction && field_info.is_array) {
1595 return true;
1596 }
1597 }
1598 }
1599 }
1600 false
1601 }
1602
1603 fn generate_event_wrapper_interface(&self) -> String {
1605 r#"/**
1606 * Wrapper for event data that includes context metadata.
1607 * Events are automatically wrapped in this structure at runtime.
1608 */
1609export interface EventWrapper<T> {
1610 /** Unix timestamp when the event was processed */
1611 timestamp: number;
1612 /** The event-specific data */
1613 data: T;
1614 /** Optional blockchain slot number */
1615 slot?: number;
1616 /** Optional transaction signature */
1617 signature?: string;
1618}"#
1619 .to_string()
1620 }
1621
1622 fn infer_type_from_field_name(&self, field_name: &str) -> String {
1623 let lower_name = field_name.to_lowercase();
1624
1625 if lower_name.contains("events.") {
1627 return "any".to_string();
1629 }
1630
1631 if lower_name.contains("id")
1633 || lower_name.contains("count")
1634 || lower_name.contains("number")
1635 || lower_name.contains("timestamp")
1636 || lower_name.contains("time")
1637 || lower_name.contains("at")
1638 || lower_name.contains("volume")
1639 || lower_name.contains("amount")
1640 || lower_name.contains("ev")
1641 || lower_name.contains("fee")
1642 || lower_name.contains("payout")
1643 || lower_name.contains("distributed")
1644 || lower_name.contains("claimable")
1645 || lower_name.contains("total")
1646 || lower_name.contains("rate")
1647 || lower_name.contains("ratio")
1648 || lower_name.contains("current")
1649 || lower_name.contains("state")
1650 {
1651 "number".to_string()
1652 } else if lower_name.contains("status")
1653 || lower_name.contains("hash")
1654 || lower_name.contains("address")
1655 || lower_name.contains("key")
1656 {
1657 "string".to_string()
1658 } else {
1659 "any".to_string()
1660 }
1661 }
1662
1663 fn is_field_optional(&self, mapping: &TypedFieldMapping<S>) -> bool {
1664 match &mapping.source {
1666 MappingSource::Constant(_) => false,
1668 MappingSource::AsEvent { .. } => true,
1670 MappingSource::FromSource { .. } => true,
1672 _ => true,
1674 }
1675 }
1676
1677 fn base_type_to_typescript(&self, base_type: &BaseType, is_array: bool) -> String {
1679 let base_ts_type = match base_type {
1680 BaseType::Integer => "number",
1681 BaseType::Float => "number",
1682 BaseType::String => "string",
1683 BaseType::Boolean => "boolean",
1684 BaseType::Timestamp => "number", BaseType::Binary => "string", BaseType::Pubkey => "string", BaseType::Array => "any[]", BaseType::Object => "Record<string, any>", BaseType::Any => "any",
1690 };
1691
1692 if is_array && !matches!(base_type, BaseType::Array) {
1693 format!("{}[]", base_ts_type)
1694 } else {
1695 base_ts_type.to_string()
1696 }
1697 }
1698
1699 fn base_type_to_zod(&self, base_type: &BaseType) -> String {
1701 match base_type {
1702 BaseType::Integer | BaseType::Float | BaseType::Timestamp => "z.number()".to_string(),
1703 BaseType::String | BaseType::Pubkey | BaseType::Binary => "z.string()".to_string(),
1704 BaseType::Boolean => "z.boolean()".to_string(),
1705 BaseType::Array => "z.array(z.any())".to_string(),
1706 BaseType::Object => "z.record(z.any())".to_string(),
1707 BaseType::Any => "z.any()".to_string(),
1708 }
1709 }
1710}
1711
1712#[derive(Debug, Clone)]
1714struct TypeScriptField {
1715 name: String,
1716 ts_type: String,
1717 optional: bool,
1718 #[allow(dead_code)]
1719 description: Option<String>,
1720}
1721
1722#[derive(Debug, Clone)]
1723struct SchemaOutput {
1724 definitions: String,
1725 names: Vec<String>,
1726}
1727
1728fn value_to_typescript_type(value: &serde_json::Value) -> String {
1730 match value {
1731 serde_json::Value::Number(_) => "number".to_string(),
1732 serde_json::Value::String(_) => "string".to_string(),
1733 serde_json::Value::Bool(_) => "boolean".to_string(),
1734 serde_json::Value::Array(_) => "any[]".to_string(),
1735 serde_json::Value::Object(_) => "Record<string, any>".to_string(),
1736 serde_json::Value::Null => "null".to_string(),
1737 }
1738}
1739
1740fn extract_builtin_resolver_type_names(spec: &SerializableStreamSpec) -> HashSet<String> {
1741 let mut names = HashSet::new();
1742 let registry = crate::resolvers::builtin_resolver_registry();
1743 for resolver in registry.definitions() {
1744 let output_type = resolver.output_type();
1745 for section in &spec.sections {
1746 for field in §ion.fields {
1747 if field.inner_type.as_deref() == Some(output_type) {
1748 names.insert(output_type.to_string());
1749 }
1750 }
1751 }
1752 }
1753 names
1754}
1755
1756fn extract_idl_enum_type_names(idl: &serde_json::Value) -> HashSet<String> {
1757 let mut names = HashSet::new();
1758 if let Some(types_array) = idl.get("types").and_then(|v| v.as_array()) {
1759 for type_def in types_array {
1760 if let (Some(type_name), Some(type_obj)) = (
1761 type_def.get("name").and_then(|v| v.as_str()),
1762 type_def.get("type").and_then(|v| v.as_object()),
1763 ) {
1764 if type_obj.get("kind").and_then(|v| v.as_str()) == Some("enum") {
1765 names.insert(type_name.to_string());
1766 }
1767 }
1768 }
1769 }
1770 names
1771}
1772
1773fn extract_emitted_enum_type_names(interfaces: &str, idl: Option<&IdlSnapshot>) -> HashSet<String> {
1776 let mut names = HashSet::new();
1777
1778 let idl_enum_names: HashSet<String> = idl
1780 .and_then(|idl| serde_json::to_value(idl).ok())
1781 .map(|v| extract_idl_enum_type_names(&v))
1782 .unwrap_or_default();
1783
1784 for line in interfaces.lines() {
1787 if let Some(start) = line.find("export const ") {
1788 let end = line
1789 .find("Schema = z.enum")
1790 .or_else(|| line.find("Schema = z.string()"));
1791 if let Some(end) = end {
1792 let schema_name = line[start + 13..end].trim();
1793 if idl_enum_names.contains(schema_name) {
1795 names.insert(schema_name.to_string());
1796 }
1797 }
1798 }
1799 }
1800
1801 names
1802}
1803
1804fn to_pascal_case(s: &str) -> String {
1806 s.split(['_', '-', '.'])
1807 .map(|word| {
1808 let mut chars = word.chars();
1809 match chars.next() {
1810 None => String::new(),
1811 Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
1812 }
1813 })
1814 .collect()
1815}
1816
1817fn normalize_for_comparison(s: &str) -> String {
1820 s.chars()
1821 .filter(|c| *c != '_')
1822 .flat_map(|c| c.to_lowercase())
1823 .collect()
1824}
1825
1826fn is_root_section(name: &str) -> bool {
1827 name.eq_ignore_ascii_case("root")
1828}
1829
1830fn is_builtin_resolver_type(type_name: &str) -> bool {
1831 crate::resolvers::is_resolver_output_type(type_name)
1832}
1833
1834fn to_kebab_case(s: &str) -> String {
1836 let mut result = String::new();
1837
1838 for ch in s.chars() {
1839 if ch.is_uppercase() && !result.is_empty() {
1840 result.push('-');
1841 }
1842 result.push(ch.to_lowercase().next().unwrap());
1843 }
1844
1845 result
1846}
1847
1848pub fn generate_typescript_from_spec_fn<F, S>(
1851 spec_fn: F,
1852 entity_name: String,
1853 config: Option<TypeScriptConfig>,
1854) -> Result<TypeScriptOutput, String>
1855where
1856 F: Fn() -> TypedStreamSpec<S>,
1857{
1858 let spec = spec_fn();
1859 let compiler =
1860 TypeScriptCompiler::new(spec, entity_name).with_config(config.unwrap_or_default());
1861
1862 Ok(compiler.compile())
1863}
1864
1865pub fn write_typescript_to_file(
1867 output: &TypeScriptOutput,
1868 path: &std::path::Path,
1869) -> Result<(), std::io::Error> {
1870 std::fs::write(path, output.full_file())
1871}
1872
1873pub fn compile_serializable_spec(
1876 spec: SerializableStreamSpec,
1877 entity_name: String,
1878 config: Option<TypeScriptConfig>,
1879) -> Result<TypeScriptOutput, String> {
1880 compile_serializable_spec_with_emitted(spec, entity_name, config, HashSet::new())
1881}
1882
1883fn compile_serializable_spec_with_emitted(
1884 spec: SerializableStreamSpec,
1885 entity_name: String,
1886 config: Option<TypeScriptConfig>,
1887 already_emitted_types: HashSet<String>,
1888) -> Result<TypeScriptOutput, String> {
1889 let idl = spec
1890 .idl
1891 .as_ref()
1892 .and_then(|idl_snapshot| serde_json::to_value(idl_snapshot).ok());
1893
1894 let handlers = serde_json::to_value(&spec.handlers).ok();
1895 let views = spec.views.clone();
1896
1897 let typed_spec: TypedStreamSpec<()> = TypedStreamSpec::from_serializable(spec);
1898
1899 let compiler = TypeScriptCompiler::new(typed_spec, entity_name)
1900 .with_idl(idl)
1901 .with_handlers_json(handlers)
1902 .with_views(views)
1903 .with_config(config.unwrap_or_default())
1904 .with_already_emitted_types(already_emitted_types);
1905
1906 Ok(compiler.compile())
1907}
1908
1909#[derive(Debug, Clone)]
1910pub struct TypeScriptStackConfig {
1911 pub package_name: String,
1912 pub generate_helpers: bool,
1913 pub export_const_name: String,
1914 pub url: Option<String>,
1915}
1916
1917impl Default for TypeScriptStackConfig {
1918 fn default() -> Self {
1919 Self {
1920 package_name: "hyperstack-react".to_string(),
1921 generate_helpers: true,
1922 export_const_name: "STACK".to_string(),
1923 url: None,
1924 }
1925 }
1926}
1927
1928#[derive(Debug, Clone)]
1929pub struct TypeScriptStackOutput {
1930 pub interfaces: String,
1931 pub stack_definition: String,
1932 pub imports: String,
1933}
1934
1935impl TypeScriptStackOutput {
1936 pub fn full_file(&self) -> String {
1937 let mut parts = Vec::new();
1938 if !self.imports.is_empty() {
1939 parts.push(self.imports.as_str());
1940 }
1941 if !self.interfaces.is_empty() {
1942 parts.push(self.interfaces.as_str());
1943 }
1944 if !self.stack_definition.is_empty() {
1945 parts.push(self.stack_definition.as_str());
1946 }
1947 parts.join("\n\n")
1948 }
1949}
1950
1951pub fn compile_stack_spec(
1958 stack_spec: SerializableStackSpec,
1959 config: Option<TypeScriptStackConfig>,
1960) -> Result<TypeScriptStackOutput, String> {
1961 let config = config.unwrap_or_default();
1962 let stack_name = &stack_spec.stack_name;
1963 let stack_kebab = to_kebab_case(stack_name);
1964
1965 let mut all_interfaces = Vec::new();
1967 let mut entity_names = Vec::new();
1968 let mut schema_names: Vec<String> = Vec::new();
1969 let mut emitted_types: HashSet<String> = HashSet::new();
1970
1971 for entity_spec in &stack_spec.entities {
1972 let mut spec = entity_spec.clone();
1973 if spec.idl.is_none() {
1975 spec.idl = stack_spec.idls.first().cloned();
1976 }
1977 let entity_name = spec.state_name.clone();
1978 entity_names.push(entity_name.clone());
1979
1980 let per_entity_config = TypeScriptConfig {
1981 package_name: config.package_name.clone(),
1982 generate_helpers: false,
1983 interface_prefix: String::new(),
1984 export_const_name: config.export_const_name.clone(),
1985 url: config.url.clone(),
1986 };
1987
1988 let builtin_type_names = extract_builtin_resolver_type_names(&spec);
1990 let idl_for_check = spec.idl.clone();
1992
1993 let output = compile_serializable_spec_with_emitted(
1994 spec,
1995 entity_name,
1996 Some(per_entity_config),
1997 emitted_types.clone(),
1998 )?;
1999
2000 let emitted_enum_names =
2003 extract_emitted_enum_type_names(&output.interfaces, idl_for_check.as_ref());
2004 emitted_types.extend(emitted_enum_names);
2005 emitted_types.extend(builtin_type_names);
2006
2007 if !output.interfaces.is_empty() {
2009 all_interfaces.push(output.interfaces);
2010 }
2011
2012 schema_names.extend(output.schema_names);
2013 }
2014
2015 let interfaces = all_interfaces.join("\n\n");
2016
2017 let stack_definition = generate_stack_definition_multi(
2019 stack_name,
2020 &stack_kebab,
2021 &stack_spec.entities,
2022 &entity_names,
2023 &stack_spec.pdas,
2024 &stack_spec.program_ids,
2025 &schema_names,
2026 &config,
2027 );
2028
2029 let imports = if stack_spec.pdas.values().any(|p| !p.is_empty()) {
2030 "import { z } from 'zod';\nimport { pda, literal, account, arg, bytes } from 'hyperstack-typescript';".to_string()
2031 } else {
2032 "import { z } from 'zod';".to_string()
2033 };
2034
2035 Ok(TypeScriptStackOutput {
2036 imports,
2037 interfaces,
2038 stack_definition,
2039 })
2040}
2041
2042pub fn write_stack_typescript_to_file(
2044 output: &TypeScriptStackOutput,
2045 path: &std::path::Path,
2046) -> Result<(), std::io::Error> {
2047 std::fs::write(path, output.full_file())
2048}
2049
2050#[allow(clippy::too_many_arguments)]
2074fn generate_stack_definition_multi(
2075 stack_name: &str,
2076 stack_kebab: &str,
2077 entities: &[SerializableStreamSpec],
2078 entity_names: &[String],
2079 pdas: &BTreeMap<String, BTreeMap<String, PdaDefinition>>,
2080 program_ids: &[String],
2081 schema_names: &[String],
2082 config: &TypeScriptStackConfig,
2083) -> String {
2084 let export_name = format!(
2085 "{}_{}",
2086 to_screaming_snake_case(stack_name),
2087 config.export_const_name
2088 );
2089
2090 let view_helpers = generate_view_helpers_static();
2091
2092 let url_line = match &config.url {
2093 Some(url) => format!(" url: '{}',", url),
2094 None => " // url: 'wss://your-stack-url.stack.usehyperstack.com', // TODO: Set after first deployment".to_string(),
2095 };
2096
2097 let mut entity_view_blocks = Vec::new();
2099 for (i, entity_spec) in entities.iter().enumerate() {
2100 let entity_name = &entity_names[i];
2101 let entity_pascal = to_pascal_case(entity_name);
2102
2103 let mut view_entries = Vec::new();
2104
2105 view_entries.push(format!(
2106 " state: stateView<{entity}>('{entity_name}/state'),",
2107 entity = entity_pascal,
2108 entity_name = entity_name
2109 ));
2110
2111 view_entries.push(format!(
2112 " list: listView<{entity}>('{entity_name}/list'),",
2113 entity = entity_pascal,
2114 entity_name = entity_name
2115 ));
2116
2117 for view in &entity_spec.views {
2118 if !view.id.ends_with("/state")
2119 && !view.id.ends_with("/list")
2120 && view.id.starts_with(entity_name)
2121 {
2122 let view_name = view.id.split('/').nth(1).unwrap_or("unknown");
2123 view_entries.push(format!(
2124 " {}: listView<{entity}>('{}'),",
2125 view_name,
2126 view.id,
2127 entity = entity_pascal
2128 ));
2129 }
2130 }
2131
2132 entity_view_blocks.push(format!(
2133 " {}: {{\n{}\n }},",
2134 entity_name,
2135 view_entries.join("\n")
2136 ));
2137 }
2138
2139 let views_body = entity_view_blocks.join("\n");
2140
2141 let pdas_block = generate_pdas_block(pdas, program_ids);
2142
2143 let mut unique_schemas: BTreeSet<String> = BTreeSet::new();
2144 for name in schema_names {
2145 unique_schemas.insert(name.clone());
2146 }
2147 let schemas_block = if unique_schemas.is_empty() {
2148 String::new()
2149 } else {
2150 let schema_entries: Vec<String> = unique_schemas
2151 .iter()
2152 .filter(|name| name.ends_with("Schema"))
2153 .map(|name| format!(" {}: {},", name.trim_end_matches("Schema"), name))
2154 .collect();
2155 if schema_entries.is_empty() {
2156 String::new()
2157 } else {
2158 format!("\n schemas: {{\n{}\n }},", schema_entries.join("\n"))
2159 }
2160 };
2161
2162 let entity_types: Vec<String> = entity_names.iter().map(|n| to_pascal_case(n)).collect();
2163
2164 format!(
2165 r#"{view_helpers}
2166
2167// ============================================================================
2168// Stack Definition
2169// ============================================================================
2170
2171/** Stack definition for {stack_name} with {entity_count} entities */
2172export const {export_name} = {{
2173 name: '{stack_kebab}',
2174{url_line}
2175 views: {{
2176{views_body}
2177 }},{schemas_section}{pdas_section}
2178}} as const;
2179
2180/** Type alias for the stack */
2181export type {stack_name}Stack = typeof {export_name};
2182
2183/** Entity types in this stack */
2184export type {stack_name}Entity = {entity_union};
2185
2186/** Default export for convenience */
2187export default {export_name};"#,
2188 view_helpers = view_helpers,
2189 stack_name = stack_name,
2190 entity_count = entities.len(),
2191 export_name = export_name,
2192 stack_kebab = stack_kebab,
2193 url_line = url_line,
2194 views_body = views_body,
2195 schemas_section = schemas_block,
2196 pdas_section = pdas_block,
2197 entity_union = entity_types.join(" | "),
2198 )
2199}
2200
2201fn generate_pdas_block(
2202 pdas: &BTreeMap<String, BTreeMap<String, PdaDefinition>>,
2203 program_ids: &[String],
2204) -> String {
2205 if pdas.is_empty() {
2206 return String::new();
2207 }
2208
2209 let mut program_blocks = Vec::new();
2210
2211 for (program_name, program_pdas) in pdas {
2212 if program_pdas.is_empty() {
2213 continue;
2214 }
2215
2216 let program_id = program_ids.first().cloned().unwrap_or_default();
2217
2218 let mut pda_entries = Vec::new();
2219 for (pda_name, pda_def) in program_pdas {
2220 let seeds_str = pda_def
2221 .seeds
2222 .iter()
2223 .map(|seed| match seed {
2224 PdaSeedDef::Literal { value } => format!("literal('{}')", value),
2225 PdaSeedDef::AccountRef { account_name } => {
2226 format!("account('{}')", account_name)
2227 }
2228 PdaSeedDef::ArgRef { arg_name, arg_type } => {
2229 if let Some(t) = arg_type {
2230 format!("arg('{}', '{}')", arg_name, t)
2231 } else {
2232 format!("arg('{}')", arg_name)
2233 }
2234 }
2235 PdaSeedDef::Bytes { value } => {
2236 let bytes_arr: Vec<String> = value.iter().map(|b| b.to_string()).collect();
2237 format!("bytes(new Uint8Array([{}]))", bytes_arr.join(", "))
2238 }
2239 })
2240 .collect::<Vec<_>>()
2241 .join(", ");
2242
2243 let pid = pda_def.program_id.as_ref().unwrap_or(&program_id);
2244 pda_entries.push(format!(
2245 " {}: pda('{}', {}),",
2246 pda_name, pid, seeds_str
2247 ));
2248 }
2249
2250 program_blocks.push(format!(
2251 " {}: {{\n{}\n }},",
2252 program_name,
2253 pda_entries.join("\n")
2254 ));
2255 }
2256
2257 if program_blocks.is_empty() {
2258 return String::new();
2259 }
2260
2261 format!("\n pdas: {{\n{}\n }},", program_blocks.join("\n"))
2262}
2263
2264fn generate_view_helpers_static() -> String {
2265 r#"// ============================================================================
2266// View Definition Types (framework-agnostic)
2267// ============================================================================
2268
2269/** View definition with embedded entity type */
2270export interface ViewDef<T, TMode extends 'state' | 'list'> {
2271 readonly mode: TMode;
2272 readonly view: string;
2273 /** Phantom field for type inference - not present at runtime */
2274 readonly _entity?: T;
2275}
2276
2277/** Helper to create typed state view definitions (keyed lookups) */
2278function stateView<T>(view: string): ViewDef<T, 'state'> {
2279 return { mode: 'state', view } as const;
2280}
2281
2282/** Helper to create typed list view definitions (collections) */
2283function listView<T>(view: string): ViewDef<T, 'list'> {
2284 return { mode: 'list', view } as const;
2285}"#
2286 .to_string()
2287}
2288
2289fn to_screaming_snake_case(s: &str) -> String {
2291 let mut result = String::new();
2292 for (i, ch) in s.chars().enumerate() {
2293 if ch.is_uppercase() && i > 0 {
2294 result.push('_');
2295 }
2296 result.push(ch.to_uppercase().next().unwrap());
2297 }
2298 result
2299}
2300
2301#[cfg(test)]
2302mod tests {
2303 use super::*;
2304
2305 #[test]
2306 fn test_case_conversions() {
2307 assert_eq!(to_pascal_case("settlement_game"), "SettlementGame");
2308 assert_eq!(to_kebab_case("SettlementGame"), "settlement-game");
2309 }
2310
2311 #[test]
2312 fn test_normalize_for_comparison() {
2313 assert_eq!(normalize_for_comparison("claim_sol"), "claimsol");
2314 assert_eq!(normalize_for_comparison("claimSol"), "claimsol");
2315 assert_eq!(normalize_for_comparison("ClaimSol"), "claimsol");
2316 assert_eq!(
2317 normalize_for_comparison("admin_set_creator"),
2318 "adminsetcreator"
2319 );
2320 assert_eq!(
2321 normalize_for_comparison("AdminSetCreator"),
2322 "adminsetcreator"
2323 );
2324 }
2325
2326 #[test]
2327 fn test_value_to_typescript_type() {
2328 assert_eq!(value_to_typescript_type(&serde_json::json!(42)), "number");
2329 assert_eq!(
2330 value_to_typescript_type(&serde_json::json!("hello")),
2331 "string"
2332 );
2333 assert_eq!(
2334 value_to_typescript_type(&serde_json::json!(true)),
2335 "boolean"
2336 );
2337 assert_eq!(value_to_typescript_type(&serde_json::json!([])), "any[]");
2338 }
2339
2340 #[test]
2341 fn test_derived_view_codegen() {
2342 let spec = SerializableStreamSpec {
2343 state_name: "OreRound".to_string(),
2344 program_id: None,
2345 idl: None,
2346 identity: IdentitySpec {
2347 primary_keys: vec!["id".to_string()],
2348 lookup_indexes: vec![],
2349 },
2350 handlers: vec![],
2351 sections: vec![],
2352 field_mappings: BTreeMap::new(),
2353 resolver_hooks: vec![],
2354 resolver_specs: vec![],
2355 instruction_hooks: vec![],
2356 computed_fields: vec![],
2357 computed_field_specs: vec![],
2358 content_hash: None,
2359 views: vec![
2360 ViewDef {
2361 id: "OreRound/latest".to_string(),
2362 source: ViewSource::Entity {
2363 name: "OreRound".to_string(),
2364 },
2365 pipeline: vec![ViewTransform::Last],
2366 output: ViewOutput::Single,
2367 },
2368 ViewDef {
2369 id: "OreRound/top10".to_string(),
2370 source: ViewSource::Entity {
2371 name: "OreRound".to_string(),
2372 },
2373 pipeline: vec![ViewTransform::Take { count: 10 }],
2374 output: ViewOutput::Collection,
2375 },
2376 ],
2377 };
2378
2379 let output =
2380 compile_serializable_spec(spec, "OreRound".to_string(), None).expect("should compile");
2381
2382 let stack_def = &output.stack_definition;
2383
2384 assert!(
2385 stack_def.contains("listView<OreRound>('OreRound/latest')"),
2386 "Expected 'latest' derived view using listView, got:\n{}",
2387 stack_def
2388 );
2389 assert!(
2390 stack_def.contains("listView<OreRound>('OreRound/top10')"),
2391 "Expected 'top10' derived view using listView, got:\n{}",
2392 stack_def
2393 );
2394 assert!(
2395 stack_def.contains("latest:"),
2396 "Expected 'latest' key, got:\n{}",
2397 stack_def
2398 );
2399 assert!(
2400 stack_def.contains("top10:"),
2401 "Expected 'top10' key, got:\n{}",
2402 stack_def
2403 );
2404 assert!(
2405 stack_def.contains("function listView<T>(view: string): ViewDef<T, 'list'>"),
2406 "Expected listView helper function, got:\n{}",
2407 stack_def
2408 );
2409 }
2410}