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 section_fields.push(TypeScriptField {
293 name: field_info.field_name.clone(),
294 ts_type: self.field_type_info_to_typescript(field_info),
295 optional: field_info.is_optional,
296 description: None,
297 });
298 }
299 }
300 }
301 } else {
302 for (field_path, field_type_info) in &self.spec.field_mappings {
304 if !field_type_info.emit {
305 continue;
306 }
307 let parts: Vec<&str> = field_path.split('.').collect();
308 if parts.len() > 1 {
309 let section_name = parts[0];
310 let field_name = parts[1];
311
312 let section_fields = sections.entry(section_name.to_string()).or_default();
313
314 let already_exists = section_fields.iter().any(|f| f.name == field_name);
315
316 if !already_exists {
317 section_fields.push(TypeScriptField {
318 name: field_name.to_string(),
319 ts_type: self.base_type_to_typescript(
320 &field_type_info.base_type,
321 field_type_info.is_array,
322 ),
323 optional: field_type_info.is_optional,
324 description: None,
325 });
326 }
327 }
328 }
329 }
330 }
331
332 fn generate_interface_from_fields(&self, name: &str, fields: &[TypeScriptField]) -> String {
333 let interface_name = self.section_interface_name(name);
334
335 let field_definitions: Vec<String> = fields
338 .iter()
339 .map(|field| {
340 let ts_type = if field.optional {
341 format!("{} | null", field.ts_type)
343 } else {
344 field.ts_type.clone()
345 };
346 format!(" {}?: {};", field.name, ts_type)
347 })
348 .collect();
349
350 format!(
351 "export interface {} {{\n{}\n}}",
352 interface_name,
353 field_definitions.join("\n")
354 )
355 }
356
357 fn section_interface_name(&self, name: &str) -> String {
358 if name == "Root" {
359 format!(
360 "{}{}",
361 self.config.interface_prefix,
362 to_pascal_case(&self.entity_name)
363 )
364 } else {
365 let base_name = if self.entity_name.contains("Game") {
368 "Game"
369 } else {
370 &self.entity_name
371 };
372 format!(
373 "{}{}{}",
374 self.config.interface_prefix,
375 base_name,
376 to_pascal_case(name)
377 )
378 }
379 }
380
381 fn generate_main_entity_interface(&self) -> String {
382 let entity_name = to_pascal_case(&self.entity_name);
383
384 let mut sections = BTreeMap::new();
386
387 for handler in &self.spec.handlers {
388 for mapping in &handler.mappings {
389 if !mapping.emit {
390 continue;
391 }
392 let parts: Vec<&str> = mapping.target_path.split('.').collect();
393 if parts.len() > 1 {
394 sections.insert(parts[0], true);
395 }
396 }
397 }
398
399 if !self.spec.sections.is_empty() {
400 for section in &self.spec.sections {
401 if section.fields.iter().any(|field| field.emit) {
402 sections.insert(§ion.name, true);
403 }
404 }
405 } else {
406 for mapping in &self.spec.handlers {
407 for field_mapping in &mapping.mappings {
408 if !field_mapping.emit {
409 continue;
410 }
411 let parts: Vec<&str> = field_mapping.target_path.split('.').collect();
412 if parts.len() > 1 {
413 sections.insert(parts[0], true);
414 }
415 }
416 }
417 }
418
419 let mut fields = Vec::new();
420
421 for section in sections.keys() {
424 if !is_root_section(section) {
425 let base_name = if self.entity_name.contains("Game") {
426 "Game"
427 } else {
428 &self.entity_name
429 };
430 let section_interface_name = format!("{}{}", base_name, to_pascal_case(section));
431 fields.push(format!(" {}?: {};", section, section_interface_name));
433 }
434 }
435
436 for section in &self.spec.sections {
439 if is_root_section(§ion.name) {
440 for field in §ion.fields {
441 if !field.emit {
442 continue;
443 }
444 let base_ts_type = self.field_type_info_to_typescript(field);
445 let ts_type = if field.is_optional {
446 format!("{} | null", base_ts_type)
447 } else {
448 base_ts_type
449 };
450 fields.push(format!(" {}?: {};", field.field_name, ts_type));
451 }
452 }
453 }
454
455 if fields.is_empty() {
456 fields.push(" // Generated interface - extend as needed".to_string());
457 }
458
459 format!(
460 "export interface {} {{\n{}\n}}",
461 entity_name,
462 fields.join("\n")
463 )
464 }
465
466 fn generate_schemas(&self) -> SchemaOutput {
467 let mut definitions = Vec::new();
468 let mut names = Vec::new();
469 let mut seen = HashSet::new();
470
471 let mut push_schema = |schema_name: String, definition: String| {
472 if seen.insert(schema_name.clone()) {
473 names.push(schema_name);
474 definitions.push(definition);
475 }
476 };
477
478 for (schema_name, definition) in self.generate_builtin_resolver_schemas() {
479 push_schema(schema_name, definition);
480 }
481
482 if self.has_event_types() {
483 push_schema(
484 "EventWrapperSchema".to_string(),
485 self.generate_event_wrapper_schema(),
486 );
487 }
488
489 for (schema_name, definition) in self.generate_resolved_type_schemas() {
490 push_schema(schema_name, definition);
491 }
492
493 for (schema_name, definition) in self.generate_event_schemas() {
494 push_schema(schema_name, definition);
495 }
496
497 for (schema_name, definition) in self.generate_idl_enum_schemas() {
498 push_schema(schema_name, definition);
499 }
500
501 let all_sections = self.collect_interface_sections();
502
503 for (section_name, fields) in &all_sections {
504 if is_root_section(section_name) {
505 continue;
506 }
507 let deduplicated_fields = self.deduplicate_fields(fields.clone());
508 let interface_name = self.section_interface_name(section_name);
509 let schema_definition =
510 self.generate_schema_for_fields(&interface_name, &deduplicated_fields, false);
511 push_schema(format!("{}Schema", interface_name), schema_definition);
512 }
513
514 let entity_name = to_pascal_case(&self.entity_name);
515 let main_fields = self.collect_main_entity_fields();
516 let entity_schema = self.generate_schema_for_fields(&entity_name, &main_fields, false);
517 push_schema(format!("{}Schema", entity_name), entity_schema);
518
519 let completed_schema = self.generate_completed_entity_schema(&entity_name);
520 push_schema(format!("{}CompletedSchema", entity_name), completed_schema);
521
522 SchemaOutput {
523 definitions: definitions.join("\n\n"),
524 names,
525 }
526 }
527
528 fn generate_event_wrapper_schema(&self) -> String {
529 r#"export const EventWrapperSchema = <T extends z.ZodTypeAny>(data: T) => z.object({
530 timestamp: z.number(),
531 data,
532 slot: z.number().optional(),
533 signature: z.string().optional(),
534});"#
535 .to_string()
536 }
537
538 fn generate_builtin_resolver_schemas(&self) -> Vec<(String, String)> {
539 let mut schemas = Vec::new();
540 let registry = crate::resolvers::builtin_resolver_registry();
541
542 for resolver in registry.definitions() {
543 if self.uses_builtin_type(resolver.output_type())
544 && !self.already_emitted_types.contains(resolver.output_type())
545 {
546 if let Some(schema) = resolver.typescript_schema() {
547 schemas.push((schema.name.to_string(), schema.definition.to_string()));
548 }
549 }
550 }
551
552 schemas
553 }
554
555 fn uses_builtin_type(&self, type_name: &str) -> bool {
556 for section in &self.spec.sections {
557 for field in §ion.fields {
558 if field.inner_type.as_deref() == Some(type_name) {
559 return true;
560 }
561 }
562 }
563 false
564 }
565
566 fn generate_builtin_resolver_interfaces(&self) -> Vec<String> {
567 let mut interfaces = Vec::new();
568 let registry = crate::resolvers::builtin_resolver_registry();
569
570 for resolver in registry.definitions() {
571 if self.uses_builtin_type(resolver.output_type())
572 && !self.already_emitted_types.contains(resolver.output_type())
573 {
574 if let Some(interface) = resolver.typescript_interface() {
575 interfaces.push(interface.to_string());
576 }
577 }
578 }
579
580 interfaces
581 }
582
583 fn collect_main_entity_fields(&self) -> Vec<TypeScriptField> {
584 let mut sections = BTreeMap::new();
585
586 for handler in &self.spec.handlers {
587 for mapping in &handler.mappings {
588 if !mapping.emit {
589 continue;
590 }
591 let parts: Vec<&str> = mapping.target_path.split('.').collect();
592 if parts.len() > 1 {
593 sections.insert(parts[0], true);
594 }
595 }
596 }
597
598 if !self.spec.sections.is_empty() {
599 for section in &self.spec.sections {
600 if section.fields.iter().any(|field| field.emit) {
601 sections.insert(§ion.name, true);
602 }
603 }
604 } else {
605 for mapping in &self.spec.handlers {
606 for field_mapping in &mapping.mappings {
607 if !field_mapping.emit {
608 continue;
609 }
610 let parts: Vec<&str> = field_mapping.target_path.split('.').collect();
611 if parts.len() > 1 {
612 sections.insert(parts[0], true);
613 }
614 }
615 }
616 }
617
618 let mut fields = Vec::new();
619
620 for section in sections.keys() {
621 if !is_root_section(section) {
622 let base_name = if self.entity_name.contains("Game") {
623 "Game"
624 } else {
625 &self.entity_name
626 };
627 let section_interface_name = format!("{}{}", base_name, to_pascal_case(section));
628 fields.push(TypeScriptField {
629 name: section.to_string(),
630 ts_type: section_interface_name,
631 optional: false,
632 description: None,
633 });
634 }
635 }
636
637 for section in &self.spec.sections {
638 if is_root_section(§ion.name) {
639 for field in §ion.fields {
640 if !field.emit {
641 continue;
642 }
643 fields.push(TypeScriptField {
644 name: field.field_name.clone(),
645 ts_type: self.field_type_info_to_typescript(field),
646 optional: field.is_optional,
647 description: None,
648 });
649 }
650 }
651 }
652
653 fields
654 }
655
656 fn generate_schema_for_fields(
657 &self,
658 name: &str,
659 fields: &[TypeScriptField],
660 required: bool,
661 ) -> String {
662 let mut field_definitions = Vec::new();
663
664 for field in fields {
665 let base_schema = self.typescript_type_to_zod(&field.ts_type);
666 let schema = if required {
667 base_schema
668 } else {
669 let with_nullable = if field.optional {
670 format!("{}.nullable()", base_schema)
671 } else {
672 base_schema
673 };
674 format!("{}.optional()", with_nullable)
675 };
676
677 field_definitions.push(format!(" {}: {},", field.name, schema));
678 }
679
680 format!(
681 "export const {}Schema = z.object({{\n{}\n}});",
682 name,
683 field_definitions.join("\n")
684 )
685 }
686
687 fn generate_completed_entity_schema(&self, entity_name: &str) -> String {
688 let main_fields = self.collect_main_entity_fields();
689 self.generate_schema_for_fields(&format!("{}Completed", entity_name), &main_fields, true)
690 }
691
692 fn generate_resolved_type_schemas(&self) -> Vec<(String, String)> {
693 let mut schemas = Vec::new();
694 let mut generated_types = HashSet::new();
695
696 for section in &self.spec.sections {
697 for field_info in §ion.fields {
698 if let Some(resolved) = &field_info.resolved_type {
699 let type_name = to_pascal_case(&resolved.type_name);
700
701 if !generated_types.insert(type_name.clone()) {
702 continue;
703 }
704
705 if resolved.is_enum {
706 let variants: Vec<String> = resolved
707 .enum_variants
708 .iter()
709 .map(|v| format!("\"{}\"", to_pascal_case(v)))
710 .collect();
711 let schema = if variants.is_empty() {
712 format!("export const {}Schema = z.string();", type_name)
713 } else {
714 format!(
715 "export const {}Schema = z.enum([{}]);",
716 type_name,
717 variants.join(", ")
718 )
719 };
720 schemas.push((format!("{}Schema", type_name), schema));
721 continue;
722 }
723
724 let mut field_definitions = Vec::new();
725 for field in &resolved.fields {
726 let base = self.resolved_field_to_zod(field);
727 let schema = if field.is_optional {
728 format!("{}.nullable().optional()", base)
729 } else {
730 format!("{}.optional()", base)
731 };
732 field_definitions.push(format!(" {}: {},", field.field_name, schema));
733 }
734
735 let schema = format!(
736 "export const {}Schema = z.object({{\n{}\n}});",
737 type_name,
738 field_definitions.join("\n")
739 );
740 schemas.push((format!("{}Schema", type_name), schema));
741 }
742 }
743 }
744
745 schemas
746 }
747
748 fn generate_event_schemas(&self) -> Vec<(String, String)> {
749 let mut schemas = Vec::new();
750 let mut generated_types = HashSet::new();
751
752 let handlers = match &self.handlers_json {
753 Some(h) => h.as_array(),
754 None => return schemas,
755 };
756
757 let handlers_array = match handlers {
758 Some(arr) => arr,
759 None => return schemas,
760 };
761
762 for handler in handlers_array {
763 if let Some(mappings) = handler.get("mappings").and_then(|m| m.as_array()) {
764 for mapping in mappings {
765 if let Some(target_path) = mapping.get("target_path").and_then(|t| t.as_str()) {
766 if target_path.contains(".events.") || target_path.starts_with("events.") {
767 if let Some(source) = mapping.get("source") {
768 if let Some(event_data) = self.extract_event_data(source) {
769 if let Some(handler_source) = handler.get("source") {
770 if let Some(instruction_name) =
771 self.extract_instruction_name(handler_source)
772 {
773 let event_field_name =
774 target_path.split('.').next_back().unwrap_or("");
775 let interface_name = format!(
776 "{}Event",
777 to_pascal_case(event_field_name)
778 );
779
780 if generated_types.insert(interface_name.clone()) {
781 if let Some(schema) = self
782 .generate_event_schema_from_idl(
783 &interface_name,
784 &instruction_name,
785 &event_data,
786 )
787 {
788 schemas.push((
789 format!("{}Schema", interface_name),
790 schema,
791 ));
792 }
793 }
794 }
795 }
796 }
797 }
798 }
799 }
800 }
801 }
802 }
803
804 schemas
805 }
806
807 fn generate_event_schema_from_idl(
808 &self,
809 interface_name: &str,
810 rust_instruction_name: &str,
811 captured_fields: &[(String, Option<String>)],
812 ) -> Option<String> {
813 if captured_fields.is_empty() {
814 return Some(format!(
815 "export const {}Schema = z.object({{}});",
816 interface_name
817 ));
818 }
819
820 let idl_value = self.idl.as_ref()?;
821 let instructions = idl_value.get("instructions")?.as_array()?;
822
823 let instruction = self.find_instruction_in_idl(instructions, rust_instruction_name)?;
824 let args = instruction.get("args")?.as_array()?;
825
826 let mut fields = Vec::new();
827 for (field_name, transform) in captured_fields {
828 for arg in args {
829 if let Some(arg_name) = arg.get("name").and_then(|n| n.as_str()) {
830 if arg_name == field_name {
831 if let Some(arg_type) = arg.get("type") {
832 let ts_type =
833 self.idl_type_to_typescript(arg_type, transform.as_deref());
834 let schema = self.typescript_type_to_zod(&ts_type);
835 fields.push(format!(" {}: {},", field_name, schema));
836 }
837 break;
838 }
839 }
840 }
841 }
842
843 Some(format!(
844 "export const {}Schema = z.object({{\n{}\n}});",
845 interface_name,
846 fields.join("\n")
847 ))
848 }
849
850 fn generate_idl_enum_schemas(&self) -> Vec<(String, String)> {
851 let mut schemas = Vec::new();
852 let mut generated_types = self.already_emitted_types.clone();
853
854 let idl_value = match &self.idl {
855 Some(idl) => idl,
856 None => return schemas,
857 };
858
859 let types_array = match idl_value.get("types").and_then(|v| v.as_array()) {
860 Some(types) => types,
861 None => return schemas,
862 };
863
864 for type_def in types_array {
865 if let (Some(type_name), Some(type_obj)) = (
866 type_def.get("name").and_then(|v| v.as_str()),
867 type_def.get("type").and_then(|v| v.as_object()),
868 ) {
869 if type_obj.get("kind").and_then(|v| v.as_str()) == Some("enum") {
870 if !generated_types.insert(type_name.to_string()) {
871 continue;
872 }
873 if let Some(variants) = type_obj.get("variants").and_then(|v| v.as_array()) {
874 let variant_names: Vec<String> = variants
875 .iter()
876 .filter_map(|v| v.get("name").and_then(|n| n.as_str()))
877 .map(|s| format!("\"{}\"", to_pascal_case(s)))
878 .collect();
879
880 let interface_name = to_pascal_case(type_name);
881 let schema = if variant_names.is_empty() {
882 format!("export const {}Schema = z.string();", interface_name)
883 } else {
884 format!(
885 "export const {}Schema = z.enum([{}]);",
886 interface_name,
887 variant_names.join(", ")
888 )
889 };
890 schemas.push((format!("{}Schema", interface_name), schema));
891 }
892 }
893 }
894 }
895
896 schemas
897 }
898
899 fn typescript_type_to_zod(&self, ts_type: &str) -> String {
900 let trimmed = ts_type.trim();
901
902 if let Some(inner) = trimmed.strip_suffix("[]") {
903 return format!("z.array({})", self.typescript_type_to_zod(inner));
904 }
905
906 if let Some(inner) = trimmed.strip_prefix("EventWrapper<") {
907 if let Some(inner) = inner.strip_suffix('>') {
908 return format!("EventWrapperSchema({})", self.typescript_type_to_zod(inner));
909 }
910 }
911
912 match trimmed {
913 "string" => "z.string()".to_string(),
914 "number" => "z.number()".to_string(),
915 "boolean" => "z.boolean()".to_string(),
916 "any" => "z.any()".to_string(),
917 "Record<string, any>" => "z.record(z.any())".to_string(),
918 _ => format!("{}Schema", trimmed),
919 }
920 }
921
922 fn resolved_field_to_zod(&self, field: &ResolvedField) -> String {
923 let base = self.base_type_to_zod(&field.base_type);
924 if field.is_array {
925 format!("z.array({})", base)
926 } else {
927 base
928 }
929 }
930
931 fn generate_stack_definition(&self) -> String {
932 let stack_name = to_kebab_case(&self.entity_name);
933 let entity_pascal = to_pascal_case(&self.entity_name);
934 let export_name = format!(
935 "{}_{}",
936 self.entity_name.to_uppercase(),
937 self.config.export_const_name
938 );
939
940 let view_helpers = self.generate_view_helpers();
941 let derived_views = self.generate_derived_view_entries();
942 let schema_names = self.generate_schemas().names;
943 let mut unique_schemas: BTreeSet<String> = BTreeSet::new();
944 for name in schema_names {
945 unique_schemas.insert(name);
946 }
947 let schemas_block = if unique_schemas.is_empty() {
948 String::new()
949 } else {
950 let schema_entries: Vec<String> = unique_schemas
951 .iter()
952 .map(|name| format!(" {}: {},", name.trim_end_matches("Schema"), name))
953 .collect();
954 format!("\n schemas: {{\n{}\n }},", schema_entries.join("\n"))
955 };
956
957 let url_line = match &self.config.url {
959 Some(url) => format!(" url: '{}',", url),
960 None => " // url: 'wss://your-stack-url.stack.usehyperstack.com', // TODO: Set after first deployment".to_string(),
961 };
962
963 format!(
964 r#"{}
965
966// ============================================================================
967// Stack Definition
968// ============================================================================
969
970/** Stack definition for {} */
971export const {} = {{
972 name: '{}',
973{}
974 views: {{
975 {}: {{
976 state: stateView<{}>('{}/state'),
977 list: listView<{}>('{}/list'),{}
978 }},
979 }},{}
980}} as const;
981
982/** Type alias for the stack */
983export type {}Stack = typeof {};
984
985/** Default export for convenience */
986export default {};"#,
987 view_helpers,
988 entity_pascal,
989 export_name,
990 stack_name,
991 url_line,
992 self.entity_name,
993 entity_pascal,
994 self.entity_name,
995 entity_pascal,
996 self.entity_name,
997 derived_views,
998 schemas_block,
999 entity_pascal,
1000 export_name,
1001 export_name
1002 )
1003 }
1004
1005 fn generate_derived_view_entries(&self) -> String {
1006 let derived_views: Vec<&ViewDef> = self
1007 .views
1008 .iter()
1009 .filter(|v| {
1010 !v.id.ends_with("/state")
1011 && !v.id.ends_with("/list")
1012 && v.id.starts_with(&self.entity_name)
1013 })
1014 .collect();
1015
1016 if derived_views.is_empty() {
1017 return String::new();
1018 }
1019
1020 let entity_pascal = to_pascal_case(&self.entity_name);
1021 let mut entries = Vec::new();
1022
1023 for view in derived_views {
1024 let view_name = view.id.split('/').nth(1).unwrap_or("unknown");
1025
1026 entries.push(format!(
1027 "\n {}: listView<{}>('{}'),",
1028 view_name, entity_pascal, view.id
1029 ));
1030 }
1031
1032 entries.join("")
1033 }
1034
1035 fn mapping_to_typescript_type(&self, mapping: &TypedFieldMapping<S>) -> String {
1036 if let Some(field_info) = self.spec.field_mappings.get(&mapping.target_path) {
1038 let ts_type = self.field_type_info_to_typescript(field_info);
1039
1040 if matches!(mapping.population, PopulationStrategy::Append) {
1042 return if ts_type.ends_with("[]") {
1043 ts_type
1044 } else {
1045 format!("{}[]", ts_type)
1046 };
1047 }
1048
1049 return ts_type;
1050 }
1051
1052 match &mapping.population {
1054 PopulationStrategy::Append => {
1055 match &mapping.source {
1057 MappingSource::AsEvent { .. } => "any[]".to_string(),
1058 _ => "any[]".to_string(),
1059 }
1060 }
1061 _ => {
1062 let base_type = match &mapping.source {
1064 MappingSource::FromSource { .. } => {
1065 self.infer_type_from_field_name(&mapping.target_path)
1066 }
1067 MappingSource::Constant(value) => value_to_typescript_type(value),
1068 MappingSource::AsEvent { .. } => "any".to_string(),
1069 _ => "any".to_string(),
1070 };
1071
1072 if let Some(transform) = &mapping.transform {
1074 match transform {
1075 Transformation::HexEncode | Transformation::HexDecode => {
1076 "string".to_string()
1077 }
1078 Transformation::Base58Encode | Transformation::Base58Decode => {
1079 "string".to_string()
1080 }
1081 Transformation::ToString => "string".to_string(),
1082 Transformation::ToNumber => "number".to_string(),
1083 }
1084 } else {
1085 base_type
1086 }
1087 }
1088 }
1089 }
1090
1091 fn field_type_info_to_typescript(&self, field_info: &FieldTypeInfo) -> String {
1092 if let Some(resolved) = &field_info.resolved_type {
1093 let interface_name = self.resolved_type_to_interface_name(resolved);
1094
1095 let base_type = if resolved.is_event || (resolved.is_instruction && field_info.is_array)
1096 {
1097 format!("EventWrapper<{}>", interface_name)
1098 } else {
1099 interface_name
1100 };
1101
1102 let with_array = if field_info.is_array {
1103 format!("{}[]", base_type)
1104 } else {
1105 base_type
1106 };
1107
1108 return with_array;
1109 }
1110
1111 if let Some(inner_type) = &field_info.inner_type {
1112 if is_builtin_resolver_type(inner_type) {
1113 return inner_type.clone();
1114 }
1115 }
1116
1117 if field_info.base_type == BaseType::Any
1118 || (field_info.base_type == BaseType::Array
1119 && field_info.inner_type.as_deref() == Some("Value"))
1120 {
1121 if let Some(event_type) = self.find_event_interface_for_field(&field_info.field_name) {
1122 return if field_info.is_array {
1123 format!("{}[]", event_type)
1124 } else if field_info.is_optional {
1125 format!("{} | null", event_type)
1126 } else {
1127 event_type
1128 };
1129 }
1130 }
1131
1132 self.base_type_to_typescript(&field_info.base_type, field_info.is_array)
1133 }
1134
1135 fn find_event_interface_for_field(&self, field_name: &str) -> Option<String> {
1137 let handlers = self.handlers_json.as_ref()?.as_array()?;
1139
1140 for handler in handlers {
1142 if let Some(mappings) = handler.get("mappings").and_then(|m| m.as_array()) {
1143 for mapping in mappings {
1144 if let Some(target_path) = mapping.get("target_path").and_then(|t| t.as_str()) {
1145 let target_parts: Vec<&str> = target_path.split('.').collect();
1147 if let Some(target_field) = target_parts.last() {
1148 if *target_field == field_name {
1149 if let Some(source) = mapping.get("source") {
1151 if self.extract_event_data(source).is_some() {
1152 return Some(format!(
1154 "{}Event",
1155 to_pascal_case(field_name)
1156 ));
1157 }
1158 }
1159 }
1160 }
1161 }
1162 }
1163 }
1164 }
1165 None
1166 }
1167
1168 fn resolved_type_to_interface_name(&self, resolved: &ResolvedStructType) -> String {
1170 to_pascal_case(&resolved.type_name)
1171 }
1172
1173 fn generate_nested_interfaces(&self) -> Vec<String> {
1175 let mut interfaces = Vec::new();
1176 let mut generated_types = self.already_emitted_types.clone();
1177
1178 for section in &self.spec.sections {
1180 for field_info in §ion.fields {
1181 if let Some(resolved) = &field_info.resolved_type {
1182 let type_name = resolved.type_name.clone();
1183
1184 if generated_types.insert(type_name) {
1186 let interface = self.generate_interface_for_resolved_type(resolved);
1187 interfaces.push(interface);
1188 }
1189 }
1190 }
1191 }
1192
1193 interfaces.extend(self.generate_event_interfaces(&mut generated_types));
1195
1196 if let Some(idl_value) = &self.idl {
1198 if let Some(types_array) = idl_value.get("types").and_then(|v| v.as_array()) {
1199 for type_def in types_array {
1200 if let (Some(type_name), Some(type_obj)) = (
1201 type_def.get("name").and_then(|v| v.as_str()),
1202 type_def.get("type").and_then(|v| v.as_object()),
1203 ) {
1204 if type_obj.get("kind").and_then(|v| v.as_str()) == Some("enum") {
1205 if generated_types.insert(type_name.to_string()) {
1207 if let Some(variants) =
1208 type_obj.get("variants").and_then(|v| v.as_array())
1209 {
1210 let variant_names: Vec<String> = variants
1211 .iter()
1212 .filter_map(|v| {
1213 v.get("name")
1214 .and_then(|n| n.as_str())
1215 .map(|s| s.to_string())
1216 })
1217 .collect();
1218
1219 if !variant_names.is_empty() {
1220 let interface_name = to_pascal_case(type_name);
1221 let variant_strings: Vec<String> = variant_names
1222 .iter()
1223 .map(|v| format!("\"{}\"", to_pascal_case(v)))
1224 .collect();
1225
1226 let enum_type = format!(
1227 "export type {} = {};",
1228 interface_name,
1229 variant_strings.join(" | ")
1230 );
1231 interfaces.push(enum_type);
1232 }
1233 }
1234 }
1235 }
1236 }
1237 }
1238 }
1239 }
1240
1241 interfaces
1242 }
1243
1244 fn generate_event_interfaces(&self, generated_types: &mut HashSet<String>) -> Vec<String> {
1246 let mut interfaces = Vec::new();
1247
1248 let handlers = match &self.handlers_json {
1250 Some(h) => h.as_array(),
1251 None => return interfaces,
1252 };
1253
1254 let handlers_array = match handlers {
1255 Some(arr) => arr,
1256 None => return interfaces,
1257 };
1258
1259 for handler in handlers_array {
1261 if let Some(mappings) = handler.get("mappings").and_then(|m| m.as_array()) {
1263 for mapping in mappings {
1264 if let Some(target_path) = mapping.get("target_path").and_then(|t| t.as_str()) {
1265 if target_path.contains(".events.") || target_path.starts_with("events.") {
1267 if let Some(source) = mapping.get("source") {
1269 if let Some(event_data) = self.extract_event_data(source) {
1270 if let Some(handler_source) = handler.get("source") {
1272 if let Some(instruction_name) =
1273 self.extract_instruction_name(handler_source)
1274 {
1275 let event_field_name =
1277 target_path.split('.').next_back().unwrap_or("");
1278 let interface_name = format!(
1279 "{}Event",
1280 to_pascal_case(event_field_name)
1281 );
1282
1283 if generated_types.insert(interface_name.clone()) {
1285 if let Some(interface) = self
1286 .generate_event_interface_from_idl(
1287 &interface_name,
1288 &instruction_name,
1289 &event_data,
1290 )
1291 {
1292 interfaces.push(interface);
1293 }
1294 }
1295 }
1296 }
1297 }
1298 }
1299 }
1300 }
1301 }
1302 }
1303 }
1304
1305 interfaces
1306 }
1307
1308 fn extract_event_data(
1310 &self,
1311 source: &serde_json::Value,
1312 ) -> Option<Vec<(String, Option<String>)>> {
1313 if let Some(as_event) = source.get("AsEvent") {
1314 if let Some(fields) = as_event.get("fields").and_then(|f| f.as_array()) {
1315 let mut event_fields = Vec::new();
1316 for field in fields {
1317 if let Some(from_source) = field.get("FromSource") {
1318 if let Some(path) = from_source
1319 .get("path")
1320 .and_then(|p| p.get("segments"))
1321 .and_then(|s| s.as_array())
1322 {
1323 if let Some(field_name) = path.last().and_then(|v| v.as_str()) {
1325 let transform = from_source
1326 .get("transform")
1327 .and_then(|t| t.as_str())
1328 .map(|s| s.to_string());
1329 event_fields.push((field_name.to_string(), transform));
1330 }
1331 }
1332 }
1333 }
1334 return Some(event_fields);
1335 }
1336 }
1337 None
1338 }
1339
1340 fn extract_instruction_name(&self, source: &serde_json::Value) -> Option<String> {
1342 if let Some(source_obj) = source.get("Source") {
1343 if let Some(type_name) = source_obj.get("type_name").and_then(|t| t.as_str()) {
1344 let instruction_part =
1345 crate::event_type_helpers::strip_event_type_suffix(type_name);
1346 return Some(instruction_part.to_string());
1347 }
1348 }
1349 None
1350 }
1351
1352 fn find_instruction_in_idl<'a>(
1356 &self,
1357 instructions: &'a [serde_json::Value],
1358 rust_name: &str,
1359 ) -> Option<&'a serde_json::Value> {
1360 let normalized_search = normalize_for_comparison(rust_name);
1361
1362 for instruction in instructions {
1363 if let Some(idl_name) = instruction.get("name").and_then(|n| n.as_str()) {
1364 if normalize_for_comparison(idl_name) == normalized_search {
1365 return Some(instruction);
1366 }
1367 }
1368 }
1369 None
1370 }
1371
1372 fn generate_event_interface_from_idl(
1374 &self,
1375 interface_name: &str,
1376 rust_instruction_name: &str,
1377 captured_fields: &[(String, Option<String>)],
1378 ) -> Option<String> {
1379 if captured_fields.is_empty() {
1380 return Some(format!("export interface {} {{}}", interface_name));
1381 }
1382
1383 let idl_value = self.idl.as_ref()?;
1384 let instructions = idl_value.get("instructions")?.as_array()?;
1385
1386 let instruction = self.find_instruction_in_idl(instructions, rust_instruction_name)?;
1387 let args = instruction.get("args")?.as_array()?;
1388
1389 let mut fields = Vec::new();
1390 for (field_name, transform) in captured_fields {
1391 for arg in args {
1392 if let Some(arg_name) = arg.get("name").and_then(|n| n.as_str()) {
1393 if arg_name == field_name {
1394 if let Some(arg_type) = arg.get("type") {
1395 let ts_type =
1396 self.idl_type_to_typescript(arg_type, transform.as_deref());
1397 fields.push(format!(" {}: {};", field_name, ts_type));
1398 }
1399 break;
1400 }
1401 }
1402 }
1403 }
1404
1405 if !fields.is_empty() {
1406 return Some(format!(
1407 "export interface {} {{\n{}\n}}",
1408 interface_name,
1409 fields.join("\n")
1410 ));
1411 }
1412
1413 None
1414 }
1415
1416 fn idl_type_to_typescript(
1418 &self,
1419 idl_type: &serde_json::Value,
1420 transform: Option<&str>,
1421 ) -> String {
1422 #![allow(clippy::only_used_in_recursion)]
1423 if transform == Some("HexEncode") {
1425 return "string".to_string();
1426 }
1427
1428 if let Some(type_str) = idl_type.as_str() {
1430 return match type_str {
1431 "u8" | "u16" | "u32" | "u64" | "u128" | "i8" | "i16" | "i32" | "i64" | "i128" => {
1432 "number".to_string()
1433 }
1434 "f32" | "f64" => "number".to_string(),
1435 "bool" => "boolean".to_string(),
1436 "string" => "string".to_string(),
1437 "pubkey" | "publicKey" => "string".to_string(),
1438 "bytes" => "string".to_string(),
1439 _ => "any".to_string(),
1440 };
1441 }
1442
1443 if let Some(type_obj) = idl_type.as_object() {
1445 if let Some(option_type) = type_obj.get("option") {
1446 let inner = self.idl_type_to_typescript(option_type, None);
1447 return format!("{} | null", inner);
1448 }
1449 if let Some(vec_type) = type_obj.get("vec") {
1450 let inner = self.idl_type_to_typescript(vec_type, None);
1451 return format!("{}[]", inner);
1452 }
1453 }
1454
1455 "any".to_string()
1456 }
1457
1458 fn generate_interface_for_resolved_type(&self, resolved: &ResolvedStructType) -> String {
1460 let interface_name = to_pascal_case(&resolved.type_name);
1461
1462 if resolved.is_enum {
1464 let variants: Vec<String> = resolved
1465 .enum_variants
1466 .iter()
1467 .map(|v| format!("\"{}\"", to_pascal_case(v)))
1468 .collect();
1469
1470 return format!("export type {} = {};", interface_name, variants.join(" | "));
1471 }
1472
1473 let fields: Vec<String> = resolved
1476 .fields
1477 .iter()
1478 .map(|field| {
1479 let base_ts_type = self.resolved_field_to_typescript(field);
1480 let ts_type = if field.is_optional {
1481 format!("{} | null", base_ts_type)
1482 } else {
1483 base_ts_type
1484 };
1485 format!(" {}?: {};", field.field_name, ts_type)
1486 })
1487 .collect();
1488
1489 format!(
1490 "export interface {} {{\n{}\n}}",
1491 interface_name,
1492 fields.join("\n")
1493 )
1494 }
1495
1496 fn resolved_field_to_typescript(&self, field: &ResolvedField) -> String {
1498 let base_ts = self.base_type_to_typescript(&field.base_type, false);
1499
1500 if field.is_array {
1501 format!("{}[]", base_ts)
1502 } else {
1503 base_ts
1504 }
1505 }
1506
1507 fn has_event_types(&self) -> bool {
1509 for section in &self.spec.sections {
1510 for field_info in §ion.fields {
1511 if let Some(resolved) = &field_info.resolved_type {
1512 if resolved.is_event || (resolved.is_instruction && field_info.is_array) {
1513 return true;
1514 }
1515 }
1516 }
1517 }
1518 false
1519 }
1520
1521 fn generate_event_wrapper_interface(&self) -> String {
1523 r#"/**
1524 * Wrapper for event data that includes context metadata.
1525 * Events are automatically wrapped in this structure at runtime.
1526 */
1527export interface EventWrapper<T> {
1528 /** Unix timestamp when the event was processed */
1529 timestamp: number;
1530 /** The event-specific data */
1531 data: T;
1532 /** Optional blockchain slot number */
1533 slot?: number;
1534 /** Optional transaction signature */
1535 signature?: string;
1536}"#
1537 .to_string()
1538 }
1539
1540 fn infer_type_from_field_name(&self, field_name: &str) -> String {
1541 let lower_name = field_name.to_lowercase();
1542
1543 if lower_name.contains("events.") {
1545 return "any".to_string();
1547 }
1548
1549 if lower_name.contains("id")
1551 || lower_name.contains("count")
1552 || lower_name.contains("number")
1553 || lower_name.contains("timestamp")
1554 || lower_name.contains("time")
1555 || lower_name.contains("at")
1556 || lower_name.contains("volume")
1557 || lower_name.contains("amount")
1558 || lower_name.contains("ev")
1559 || lower_name.contains("fee")
1560 || lower_name.contains("payout")
1561 || lower_name.contains("distributed")
1562 || lower_name.contains("claimable")
1563 || lower_name.contains("total")
1564 || lower_name.contains("rate")
1565 || lower_name.contains("ratio")
1566 || lower_name.contains("current")
1567 || lower_name.contains("state")
1568 {
1569 "number".to_string()
1570 } else if lower_name.contains("status")
1571 || lower_name.contains("hash")
1572 || lower_name.contains("address")
1573 || lower_name.contains("key")
1574 {
1575 "string".to_string()
1576 } else {
1577 "any".to_string()
1578 }
1579 }
1580
1581 fn is_field_optional(&self, mapping: &TypedFieldMapping<S>) -> bool {
1582 match &mapping.source {
1584 MappingSource::Constant(_) => false,
1586 MappingSource::AsEvent { .. } => true,
1588 MappingSource::FromSource { .. } => true,
1590 _ => true,
1592 }
1593 }
1594
1595 fn base_type_to_typescript(&self, base_type: &BaseType, is_array: bool) -> String {
1597 let base_ts_type = match base_type {
1598 BaseType::Integer => "number",
1599 BaseType::Float => "number",
1600 BaseType::String => "string",
1601 BaseType::Boolean => "boolean",
1602 BaseType::Timestamp => "number", BaseType::Binary => "string", BaseType::Pubkey => "string", BaseType::Array => "any[]", BaseType::Object => "Record<string, any>", BaseType::Any => "any",
1608 };
1609
1610 if is_array && !matches!(base_type, BaseType::Array) {
1611 format!("{}[]", base_ts_type)
1612 } else {
1613 base_ts_type.to_string()
1614 }
1615 }
1616
1617 fn base_type_to_zod(&self, base_type: &BaseType) -> String {
1619 match base_type {
1620 BaseType::Integer | BaseType::Float | BaseType::Timestamp => "z.number()".to_string(),
1621 BaseType::String | BaseType::Pubkey | BaseType::Binary => "z.string()".to_string(),
1622 BaseType::Boolean => "z.boolean()".to_string(),
1623 BaseType::Array => "z.array(z.any())".to_string(),
1624 BaseType::Object => "z.record(z.any())".to_string(),
1625 BaseType::Any => "z.any()".to_string(),
1626 }
1627 }
1628}
1629
1630#[derive(Debug, Clone)]
1632struct TypeScriptField {
1633 name: String,
1634 ts_type: String,
1635 optional: bool,
1636 #[allow(dead_code)]
1637 description: Option<String>,
1638}
1639
1640#[derive(Debug, Clone)]
1641struct SchemaOutput {
1642 definitions: String,
1643 names: Vec<String>,
1644}
1645
1646fn value_to_typescript_type(value: &serde_json::Value) -> String {
1648 match value {
1649 serde_json::Value::Number(_) => "number".to_string(),
1650 serde_json::Value::String(_) => "string".to_string(),
1651 serde_json::Value::Bool(_) => "boolean".to_string(),
1652 serde_json::Value::Array(_) => "any[]".to_string(),
1653 serde_json::Value::Object(_) => "Record<string, any>".to_string(),
1654 serde_json::Value::Null => "null".to_string(),
1655 }
1656}
1657
1658fn extract_builtin_resolver_type_names(spec: &SerializableStreamSpec) -> HashSet<String> {
1659 let mut names = HashSet::new();
1660 let registry = crate::resolvers::builtin_resolver_registry();
1661 for resolver in registry.definitions() {
1662 let output_type = resolver.output_type();
1663 for section in &spec.sections {
1664 for field in §ion.fields {
1665 if field.inner_type.as_deref() == Some(output_type) {
1666 names.insert(output_type.to_string());
1667 }
1668 }
1669 }
1670 }
1671 names
1672}
1673
1674fn extract_idl_enum_type_names(idl: &serde_json::Value) -> HashSet<String> {
1675 let mut names = HashSet::new();
1676 if let Some(types_array) = idl.get("types").and_then(|v| v.as_array()) {
1677 for type_def in types_array {
1678 if let (Some(type_name), Some(type_obj)) = (
1679 type_def.get("name").and_then(|v| v.as_str()),
1680 type_def.get("type").and_then(|v| v.as_object()),
1681 ) {
1682 if type_obj.get("kind").and_then(|v| v.as_str()) == Some("enum") {
1683 names.insert(type_name.to_string());
1684 }
1685 }
1686 }
1687 }
1688 names
1689}
1690
1691fn extract_emitted_enum_type_names(interfaces: &str, idl: Option<&IdlSnapshot>) -> HashSet<String> {
1694 let mut names = HashSet::new();
1695
1696 let idl_enum_names: HashSet<String> = idl
1698 .and_then(|idl| serde_json::to_value(idl).ok())
1699 .map(|v| extract_idl_enum_type_names(&v))
1700 .unwrap_or_default();
1701
1702 for line in interfaces.lines() {
1705 if let Some(start) = line.find("export const ") {
1706 let end = line
1707 .find("Schema = z.enum")
1708 .or_else(|| line.find("Schema = z.string()"));
1709 if let Some(end) = end {
1710 let schema_name = line[start + 13..end].trim();
1711 if idl_enum_names.contains(schema_name) {
1713 names.insert(schema_name.to_string());
1714 }
1715 }
1716 }
1717 }
1718
1719 names
1720}
1721
1722fn to_pascal_case(s: &str) -> String {
1724 s.split(['_', '-', '.'])
1725 .map(|word| {
1726 let mut chars = word.chars();
1727 match chars.next() {
1728 None => String::new(),
1729 Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
1730 }
1731 })
1732 .collect()
1733}
1734
1735fn normalize_for_comparison(s: &str) -> String {
1738 s.chars()
1739 .filter(|c| *c != '_')
1740 .flat_map(|c| c.to_lowercase())
1741 .collect()
1742}
1743
1744fn is_root_section(name: &str) -> bool {
1745 name.eq_ignore_ascii_case("root")
1746}
1747
1748fn is_builtin_resolver_type(type_name: &str) -> bool {
1749 crate::resolvers::is_resolver_output_type(type_name)
1750}
1751
1752fn to_kebab_case(s: &str) -> String {
1754 let mut result = String::new();
1755
1756 for ch in s.chars() {
1757 if ch.is_uppercase() && !result.is_empty() {
1758 result.push('-');
1759 }
1760 result.push(ch.to_lowercase().next().unwrap());
1761 }
1762
1763 result
1764}
1765
1766pub fn generate_typescript_from_spec_fn<F, S>(
1769 spec_fn: F,
1770 entity_name: String,
1771 config: Option<TypeScriptConfig>,
1772) -> Result<TypeScriptOutput, String>
1773where
1774 F: Fn() -> TypedStreamSpec<S>,
1775{
1776 let spec = spec_fn();
1777 let compiler =
1778 TypeScriptCompiler::new(spec, entity_name).with_config(config.unwrap_or_default());
1779
1780 Ok(compiler.compile())
1781}
1782
1783pub fn write_typescript_to_file(
1785 output: &TypeScriptOutput,
1786 path: &std::path::Path,
1787) -> Result<(), std::io::Error> {
1788 std::fs::write(path, output.full_file())
1789}
1790
1791pub fn compile_serializable_spec(
1794 spec: SerializableStreamSpec,
1795 entity_name: String,
1796 config: Option<TypeScriptConfig>,
1797) -> Result<TypeScriptOutput, String> {
1798 compile_serializable_spec_with_emitted(spec, entity_name, config, HashSet::new())
1799}
1800
1801fn compile_serializable_spec_with_emitted(
1802 spec: SerializableStreamSpec,
1803 entity_name: String,
1804 config: Option<TypeScriptConfig>,
1805 already_emitted_types: HashSet<String>,
1806) -> Result<TypeScriptOutput, String> {
1807 let idl = spec
1808 .idl
1809 .as_ref()
1810 .and_then(|idl_snapshot| serde_json::to_value(idl_snapshot).ok());
1811
1812 let handlers = serde_json::to_value(&spec.handlers).ok();
1813 let views = spec.views.clone();
1814
1815 let typed_spec: TypedStreamSpec<()> = TypedStreamSpec::from_serializable(spec);
1816
1817 let compiler = TypeScriptCompiler::new(typed_spec, entity_name)
1818 .with_idl(idl)
1819 .with_handlers_json(handlers)
1820 .with_views(views)
1821 .with_config(config.unwrap_or_default())
1822 .with_already_emitted_types(already_emitted_types);
1823
1824 Ok(compiler.compile())
1825}
1826
1827#[derive(Debug, Clone)]
1828pub struct TypeScriptStackConfig {
1829 pub package_name: String,
1830 pub generate_helpers: bool,
1831 pub export_const_name: String,
1832 pub url: Option<String>,
1833}
1834
1835impl Default for TypeScriptStackConfig {
1836 fn default() -> Self {
1837 Self {
1838 package_name: "hyperstack-react".to_string(),
1839 generate_helpers: true,
1840 export_const_name: "STACK".to_string(),
1841 url: None,
1842 }
1843 }
1844}
1845
1846#[derive(Debug, Clone)]
1847pub struct TypeScriptStackOutput {
1848 pub interfaces: String,
1849 pub stack_definition: String,
1850 pub imports: String,
1851}
1852
1853impl TypeScriptStackOutput {
1854 pub fn full_file(&self) -> String {
1855 let mut parts = Vec::new();
1856 if !self.imports.is_empty() {
1857 parts.push(self.imports.as_str());
1858 }
1859 if !self.interfaces.is_empty() {
1860 parts.push(self.interfaces.as_str());
1861 }
1862 if !self.stack_definition.is_empty() {
1863 parts.push(self.stack_definition.as_str());
1864 }
1865 parts.join("\n\n")
1866 }
1867}
1868
1869pub fn compile_stack_spec(
1876 stack_spec: SerializableStackSpec,
1877 config: Option<TypeScriptStackConfig>,
1878) -> Result<TypeScriptStackOutput, String> {
1879 let config = config.unwrap_or_default();
1880 let stack_name = &stack_spec.stack_name;
1881 let stack_kebab = to_kebab_case(stack_name);
1882
1883 let mut all_interfaces = Vec::new();
1885 let mut entity_names = Vec::new();
1886 let mut schema_names: Vec<String> = Vec::new();
1887 let mut emitted_types: HashSet<String> = HashSet::new();
1888
1889 for entity_spec in &stack_spec.entities {
1890 let mut spec = entity_spec.clone();
1891 if spec.idl.is_none() {
1893 spec.idl = stack_spec.idls.first().cloned();
1894 }
1895 let entity_name = spec.state_name.clone();
1896 entity_names.push(entity_name.clone());
1897
1898 let per_entity_config = TypeScriptConfig {
1899 package_name: config.package_name.clone(),
1900 generate_helpers: false,
1901 interface_prefix: String::new(),
1902 export_const_name: config.export_const_name.clone(),
1903 url: config.url.clone(),
1904 };
1905
1906 let builtin_type_names = extract_builtin_resolver_type_names(&spec);
1908 let idl_for_check = spec.idl.clone();
1910
1911 let output = compile_serializable_spec_with_emitted(
1912 spec,
1913 entity_name,
1914 Some(per_entity_config),
1915 emitted_types.clone(),
1916 )?;
1917
1918 let emitted_enum_names =
1921 extract_emitted_enum_type_names(&output.interfaces, idl_for_check.as_ref());
1922 emitted_types.extend(emitted_enum_names);
1923 emitted_types.extend(builtin_type_names);
1924
1925 if !output.interfaces.is_empty() {
1927 all_interfaces.push(output.interfaces);
1928 }
1929
1930 schema_names.extend(output.schema_names);
1931 }
1932
1933 let interfaces = all_interfaces.join("\n\n");
1934
1935 let stack_definition = generate_stack_definition_multi(
1937 stack_name,
1938 &stack_kebab,
1939 &stack_spec.entities,
1940 &entity_names,
1941 &stack_spec.pdas,
1942 &stack_spec.program_ids,
1943 &schema_names,
1944 &config,
1945 );
1946
1947 let imports = if stack_spec.pdas.values().any(|p| !p.is_empty()) {
1948 "import { z } from 'zod';\nimport { pda, literal, account, arg, bytes } from 'hyperstack-typescript';".to_string()
1949 } else {
1950 "import { z } from 'zod';".to_string()
1951 };
1952
1953 Ok(TypeScriptStackOutput {
1954 imports,
1955 interfaces,
1956 stack_definition,
1957 })
1958}
1959
1960pub fn write_stack_typescript_to_file(
1962 output: &TypeScriptStackOutput,
1963 path: &std::path::Path,
1964) -> Result<(), std::io::Error> {
1965 std::fs::write(path, output.full_file())
1966}
1967
1968#[allow(clippy::too_many_arguments)]
1992fn generate_stack_definition_multi(
1993 stack_name: &str,
1994 stack_kebab: &str,
1995 entities: &[SerializableStreamSpec],
1996 entity_names: &[String],
1997 pdas: &BTreeMap<String, BTreeMap<String, PdaDefinition>>,
1998 program_ids: &[String],
1999 schema_names: &[String],
2000 config: &TypeScriptStackConfig,
2001) -> String {
2002 let export_name = format!(
2003 "{}_{}",
2004 to_screaming_snake_case(stack_name),
2005 config.export_const_name
2006 );
2007
2008 let view_helpers = generate_view_helpers_static();
2009
2010 let url_line = match &config.url {
2011 Some(url) => format!(" url: '{}',", url),
2012 None => " // url: 'wss://your-stack-url.stack.usehyperstack.com', // TODO: Set after first deployment".to_string(),
2013 };
2014
2015 let mut entity_view_blocks = Vec::new();
2017 for (i, entity_spec) in entities.iter().enumerate() {
2018 let entity_name = &entity_names[i];
2019 let entity_pascal = to_pascal_case(entity_name);
2020
2021 let mut view_entries = Vec::new();
2022
2023 view_entries.push(format!(
2024 " state: stateView<{entity}>('{entity_name}/state'),",
2025 entity = entity_pascal,
2026 entity_name = entity_name
2027 ));
2028
2029 view_entries.push(format!(
2030 " list: listView<{entity}>('{entity_name}/list'),",
2031 entity = entity_pascal,
2032 entity_name = entity_name
2033 ));
2034
2035 for view in &entity_spec.views {
2036 if !view.id.ends_with("/state")
2037 && !view.id.ends_with("/list")
2038 && view.id.starts_with(entity_name)
2039 {
2040 let view_name = view.id.split('/').nth(1).unwrap_or("unknown");
2041 view_entries.push(format!(
2042 " {}: listView<{entity}>('{}'),",
2043 view_name,
2044 view.id,
2045 entity = entity_pascal
2046 ));
2047 }
2048 }
2049
2050 entity_view_blocks.push(format!(
2051 " {}: {{\n{}\n }},",
2052 entity_name,
2053 view_entries.join("\n")
2054 ));
2055 }
2056
2057 let views_body = entity_view_blocks.join("\n");
2058
2059 let pdas_block = generate_pdas_block(pdas, program_ids);
2060
2061 let mut unique_schemas: BTreeSet<String> = BTreeSet::new();
2062 for name in schema_names {
2063 unique_schemas.insert(name.clone());
2064 }
2065 let schemas_block = if unique_schemas.is_empty() {
2066 String::new()
2067 } else {
2068 let schema_entries: Vec<String> = unique_schemas
2069 .iter()
2070 .map(|name| format!(" {}: {},", name.trim_end_matches("Schema"), name))
2071 .collect();
2072 format!("\n schemas: {{\n{}\n }},", schema_entries.join("\n"))
2073 };
2074
2075 let entity_types: Vec<String> = entity_names.iter().map(|n| to_pascal_case(n)).collect();
2076
2077 format!(
2078 r#"{view_helpers}
2079
2080// ============================================================================
2081// Stack Definition
2082// ============================================================================
2083
2084/** Stack definition for {stack_name} with {entity_count} entities */
2085export const {export_name} = {{
2086 name: '{stack_kebab}',
2087{url_line}
2088 views: {{
2089{views_body}
2090 }},{schemas_section}{pdas_section}
2091}} as const;
2092
2093/** Type alias for the stack */
2094export type {stack_name}Stack = typeof {export_name};
2095
2096/** Entity types in this stack */
2097export type {stack_name}Entity = {entity_union};
2098
2099/** Default export for convenience */
2100export default {export_name};"#,
2101 view_helpers = view_helpers,
2102 stack_name = stack_name,
2103 entity_count = entities.len(),
2104 export_name = export_name,
2105 stack_kebab = stack_kebab,
2106 url_line = url_line,
2107 views_body = views_body,
2108 schemas_section = schemas_block,
2109 pdas_section = pdas_block,
2110 entity_union = entity_types.join(" | "),
2111 )
2112}
2113
2114fn generate_pdas_block(
2115 pdas: &BTreeMap<String, BTreeMap<String, PdaDefinition>>,
2116 program_ids: &[String],
2117) -> String {
2118 if pdas.is_empty() {
2119 return String::new();
2120 }
2121
2122 let mut program_blocks = Vec::new();
2123
2124 for (program_name, program_pdas) in pdas {
2125 if program_pdas.is_empty() {
2126 continue;
2127 }
2128
2129 let program_id = program_ids.first().cloned().unwrap_or_default();
2130
2131 let mut pda_entries = Vec::new();
2132 for (pda_name, pda_def) in program_pdas {
2133 let seeds_str = pda_def
2134 .seeds
2135 .iter()
2136 .map(|seed| match seed {
2137 PdaSeedDef::Literal { value } => format!("literal('{}')", value),
2138 PdaSeedDef::AccountRef { account_name } => {
2139 format!("account('{}')", account_name)
2140 }
2141 PdaSeedDef::ArgRef { arg_name, arg_type } => {
2142 if let Some(t) = arg_type {
2143 format!("arg('{}', '{}')", arg_name, t)
2144 } else {
2145 format!("arg('{}')", arg_name)
2146 }
2147 }
2148 PdaSeedDef::Bytes { value } => {
2149 let bytes_arr: Vec<String> = value.iter().map(|b| b.to_string()).collect();
2150 format!("bytes(new Uint8Array([{}]))", bytes_arr.join(", "))
2151 }
2152 })
2153 .collect::<Vec<_>>()
2154 .join(", ");
2155
2156 let pid = pda_def.program_id.as_ref().unwrap_or(&program_id);
2157 pda_entries.push(format!(
2158 " {}: pda('{}', {}),",
2159 pda_name, pid, seeds_str
2160 ));
2161 }
2162
2163 program_blocks.push(format!(
2164 " {}: {{\n{}\n }},",
2165 program_name,
2166 pda_entries.join("\n")
2167 ));
2168 }
2169
2170 if program_blocks.is_empty() {
2171 return String::new();
2172 }
2173
2174 format!("\n pdas: {{\n{}\n }},", program_blocks.join("\n"))
2175}
2176
2177fn generate_view_helpers_static() -> String {
2178 r#"// ============================================================================
2179// View Definition Types (framework-agnostic)
2180// ============================================================================
2181
2182/** View definition with embedded entity type */
2183export interface ViewDef<T, TMode extends 'state' | 'list'> {
2184 readonly mode: TMode;
2185 readonly view: string;
2186 /** Phantom field for type inference - not present at runtime */
2187 readonly _entity?: T;
2188}
2189
2190/** Helper to create typed state view definitions (keyed lookups) */
2191function stateView<T>(view: string): ViewDef<T, 'state'> {
2192 return { mode: 'state', view } as const;
2193}
2194
2195/** Helper to create typed list view definitions (collections) */
2196function listView<T>(view: string): ViewDef<T, 'list'> {
2197 return { mode: 'list', view } as const;
2198}"#
2199 .to_string()
2200}
2201
2202fn to_screaming_snake_case(s: &str) -> String {
2204 let mut result = String::new();
2205 for (i, ch) in s.chars().enumerate() {
2206 if ch.is_uppercase() && i > 0 {
2207 result.push('_');
2208 }
2209 result.push(ch.to_uppercase().next().unwrap());
2210 }
2211 result
2212}
2213
2214#[cfg(test)]
2215mod tests {
2216 use super::*;
2217
2218 #[test]
2219 fn test_case_conversions() {
2220 assert_eq!(to_pascal_case("settlement_game"), "SettlementGame");
2221 assert_eq!(to_kebab_case("SettlementGame"), "settlement-game");
2222 }
2223
2224 #[test]
2225 fn test_normalize_for_comparison() {
2226 assert_eq!(normalize_for_comparison("claim_sol"), "claimsol");
2227 assert_eq!(normalize_for_comparison("claimSol"), "claimsol");
2228 assert_eq!(normalize_for_comparison("ClaimSol"), "claimsol");
2229 assert_eq!(
2230 normalize_for_comparison("admin_set_creator"),
2231 "adminsetcreator"
2232 );
2233 assert_eq!(
2234 normalize_for_comparison("AdminSetCreator"),
2235 "adminsetcreator"
2236 );
2237 }
2238
2239 #[test]
2240 fn test_value_to_typescript_type() {
2241 assert_eq!(value_to_typescript_type(&serde_json::json!(42)), "number");
2242 assert_eq!(
2243 value_to_typescript_type(&serde_json::json!("hello")),
2244 "string"
2245 );
2246 assert_eq!(
2247 value_to_typescript_type(&serde_json::json!(true)),
2248 "boolean"
2249 );
2250 assert_eq!(value_to_typescript_type(&serde_json::json!([])), "any[]");
2251 }
2252
2253 #[test]
2254 fn test_derived_view_codegen() {
2255 let spec = SerializableStreamSpec {
2256 state_name: "OreRound".to_string(),
2257 program_id: None,
2258 idl: None,
2259 identity: IdentitySpec {
2260 primary_keys: vec!["id".to_string()],
2261 lookup_indexes: vec![],
2262 },
2263 handlers: vec![],
2264 sections: vec![],
2265 field_mappings: BTreeMap::new(),
2266 resolver_hooks: vec![],
2267 resolver_specs: vec![],
2268 instruction_hooks: vec![],
2269 computed_fields: vec![],
2270 computed_field_specs: vec![],
2271 content_hash: None,
2272 views: vec![
2273 ViewDef {
2274 id: "OreRound/latest".to_string(),
2275 source: ViewSource::Entity {
2276 name: "OreRound".to_string(),
2277 },
2278 pipeline: vec![ViewTransform::Last],
2279 output: ViewOutput::Single,
2280 },
2281 ViewDef {
2282 id: "OreRound/top10".to_string(),
2283 source: ViewSource::Entity {
2284 name: "OreRound".to_string(),
2285 },
2286 pipeline: vec![ViewTransform::Take { count: 10 }],
2287 output: ViewOutput::Collection,
2288 },
2289 ],
2290 };
2291
2292 let output =
2293 compile_serializable_spec(spec, "OreRound".to_string(), None).expect("should compile");
2294
2295 let stack_def = &output.stack_definition;
2296
2297 assert!(
2298 stack_def.contains("listView<OreRound>('OreRound/latest')"),
2299 "Expected 'latest' derived view using listView, got:\n{}",
2300 stack_def
2301 );
2302 assert!(
2303 stack_def.contains("listView<OreRound>('OreRound/top10')"),
2304 "Expected 'top10' derived view using listView, got:\n{}",
2305 stack_def
2306 );
2307 assert!(
2308 stack_def.contains("latest:"),
2309 "Expected 'latest' key, got:\n{}",
2310 stack_def
2311 );
2312 assert!(
2313 stack_def.contains("top10:"),
2314 "Expected 'top10' key, got:\n{}",
2315 stack_def
2316 );
2317 assert!(
2318 stack_def.contains("function listView<T>(view: string): ViewDef<T, 'list'>"),
2319 "Expected listView helper function, got:\n{}",
2320 stack_def
2321 );
2322 }
2323}