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