1use crate::ast::*;
2use arete_idl::utils::to_snake_case as idl_to_snake_case;
3use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
4
5#[derive(Debug, Clone)]
7pub struct TypeScriptOutput {
8 pub interfaces: String,
9 pub stack_definition: String,
10 pub imports: String,
11 pub schema_names: Vec<String>,
12}
13
14impl TypeScriptOutput {
15 pub fn full_file(&self) -> String {
16 format!(
17 "{}\n\n{}\n\n{}",
18 self.imports, self.interfaces, self.stack_definition
19 )
20 }
21}
22
23#[derive(Debug, Clone)]
25pub struct TypeScriptConfig {
26 pub package_name: String,
27 pub generate_helpers: bool,
28 pub interface_prefix: String,
29 pub export_const_name: String,
30 pub url: Option<String>,
32}
33
34impl Default for TypeScriptConfig {
35 fn default() -> Self {
36 Self {
37 package_name: "@usearete/react".to_string(),
38 generate_helpers: true,
39 interface_prefix: "".to_string(),
40 export_const_name: "STACK".to_string(),
41 url: None,
42 }
43 }
44}
45
46pub trait TypeScriptGenerator {
48 fn generate_typescript(&self, config: &TypeScriptConfig) -> String;
49}
50
51pub trait TypeScriptInterfaceGenerator {
53 fn generate_interface(&self, name: &str, config: &TypeScriptConfig) -> String;
54}
55
56pub trait TypeScriptTypeMapper {
58 fn to_typescript_type(&self) -> String;
59}
60
61pub struct TypeScriptCompiler<S> {
63 spec: TypedStreamSpec<S>,
64 entity_name: String,
65 config: TypeScriptConfig,
66 idl: Option<serde_json::Value>, handlers_json: Option<serde_json::Value>, views: Vec<ViewDef>, already_emitted_types: HashSet<String>,
70}
71
72impl<S> TypeScriptCompiler<S> {
73 pub fn new(spec: TypedStreamSpec<S>, entity_name: String) -> Self {
74 Self {
75 spec,
76 entity_name,
77 config: TypeScriptConfig::default(),
78 idl: None,
79 handlers_json: None,
80 views: Vec::new(),
81 already_emitted_types: HashSet::new(),
82 }
83 }
84
85 pub fn with_config(mut self, config: TypeScriptConfig) -> Self {
86 self.config = config;
87 self
88 }
89
90 pub fn with_idl(mut self, idl: Option<serde_json::Value>) -> Self {
91 self.idl = idl;
92 self
93 }
94
95 pub fn with_handlers_json(mut self, handlers: Option<serde_json::Value>) -> Self {
96 self.handlers_json = handlers;
97 self
98 }
99
100 pub fn with_views(mut self, views: Vec<ViewDef>) -> Self {
101 self.views = views;
102 self
103 }
104
105 pub fn with_already_emitted_types(mut self, types: HashSet<String>) -> Self {
106 self.already_emitted_types = types;
107 self
108 }
109
110 pub fn compile(&self) -> TypeScriptOutput {
111 let imports = self.generate_imports();
112 let interfaces = self.generate_interfaces();
113 let schema_output = self.generate_schemas();
114 let combined_interfaces = if schema_output.definitions.is_empty() {
115 interfaces
116 } else if interfaces.is_empty() {
117 schema_output.definitions.clone()
118 } else {
119 format!("{}\n\n{}", interfaces, schema_output.definitions)
120 };
121 let stack_definition = self.generate_stack_definition();
122
123 TypeScriptOutput {
124 imports,
125 interfaces: combined_interfaces,
126 stack_definition,
127 schema_names: schema_output.names,
128 }
129 }
130
131 fn generate_imports(&self) -> String {
132 "import { z } from 'zod';".to_string()
133 }
134
135 fn generate_view_helpers(&self) -> String {
136 r#"// ============================================================================
137// View Definition Types (framework-agnostic)
138// ============================================================================
139
140/** View definition with embedded entity type */
141export interface ViewDef<T, TMode extends 'state' | 'list'> {
142 readonly mode: TMode;
143 readonly view: string;
144 /** Phantom field for type inference - not present at runtime */
145 readonly _entity?: T;
146}
147
148/** Helper to create typed state view definitions (keyed lookups) */
149function stateView<T>(view: string): ViewDef<T, 'state'> {
150 return { mode: 'state', view } as const;
151}
152
153/** Helper to create typed list view definitions (collections) */
154function listView<T>(view: string): ViewDef<T, 'list'> {
155 return { mode: 'list', view } as const;
156}"#
157 .to_string()
158 }
159
160 fn generate_interfaces(&self) -> String {
161 let mut interfaces = Vec::new();
162 let mut processed_types = HashSet::new();
163 let all_sections = self.collect_interface_sections();
164
165 for (section_name, fields) in all_sections {
168 if !is_root_section(§ion_name) && processed_types.insert(section_name.clone()) {
169 let deduplicated_fields = self.deduplicate_fields(fields);
170 let interface =
171 self.generate_interface_from_fields(§ion_name, &deduplicated_fields);
172 interfaces.push(interface);
173 }
174 }
175
176 let main_interface = self.generate_main_entity_interface();
178 interfaces.push(main_interface);
179
180 let nested_interfaces = self.generate_nested_interfaces();
181 interfaces.extend(nested_interfaces);
182
183 let builtin_interfaces = self.generate_builtin_resolver_interfaces();
184 interfaces.extend(builtin_interfaces);
185
186 if self.has_event_types() {
187 interfaces.push(self.generate_event_wrapper_interface());
188 }
189
190 interfaces.join("\n\n")
191 }
192
193 fn collect_interface_sections(&self) -> BTreeMap<String, Vec<TypeScriptField>> {
194 let mut all_sections: BTreeMap<String, Vec<TypeScriptField>> = BTreeMap::new();
195
196 for handler in &self.spec.handlers {
198 let interface_sections = self.extract_interface_sections_from_handler(handler);
199
200 for (section_name, mut fields) in interface_sections {
201 all_sections
202 .entry(section_name)
203 .or_default()
204 .append(&mut fields);
205 }
206 }
207
208 self.add_unmapped_fields(&mut all_sections);
211
212 all_sections
213 }
214
215 fn deduplicate_fields(&self, mut fields: Vec<TypeScriptField>) -> Vec<TypeScriptField> {
216 let mut seen = HashSet::new();
217 let mut unique_fields = Vec::new();
218
219 fields.sort_by(|a, b| a.name.cmp(&b.name));
221
222 for field in fields {
223 if seen.insert(field.name.clone()) {
224 unique_fields.push(field);
225 }
226 }
227
228 unique_fields
229 }
230
231 fn extract_interface_sections_from_handler(
232 &self,
233 handler: &TypedHandlerSpec<S>,
234 ) -> BTreeMap<String, Vec<TypeScriptField>> {
235 let mut sections: BTreeMap<String, Vec<TypeScriptField>> = BTreeMap::new();
236
237 for mapping in &handler.mappings {
238 if !mapping.emit {
239 continue;
240 }
241 let parts: Vec<&str> = mapping.target_path.split('.').collect();
242
243 if parts.len() > 1 {
244 let section_name = parts[0];
245 let field_name = parts[1];
246
247 let ts_field = TypeScriptField::patch(
248 field_name.to_string(),
249 self.mapping_to_typescript_type(mapping),
250 self.is_field_nullable(mapping),
251 );
252
253 sections
254 .entry(section_name.to_string())
255 .or_default()
256 .push(ts_field);
257 } else {
258 let ts_field = TypeScriptField::patch(
259 mapping.target_path.clone(),
260 self.mapping_to_typescript_type(mapping),
261 self.is_field_nullable(mapping),
262 );
263
264 sections
265 .entry("Root".to_string())
266 .or_default()
267 .push(ts_field);
268 }
269 }
270
271 sections
272 }
273
274 fn add_unmapped_fields(&self, sections: &mut BTreeMap<String, Vec<TypeScriptField>>) {
275 if !self.spec.sections.is_empty() {
277 for section in &self.spec.sections {
279 let section_fields = sections.entry(section.name.clone()).or_default();
280
281 for field_info in §ion.fields {
282 if !field_info.emit {
283 continue;
284 }
285 let already_exists = section_fields
287 .iter()
288 .any(|f| f.name == field_info.field_name);
289
290 if !already_exists {
291 let field_path = format!("{}.{}", section.name, field_info.field_name);
293 let effective_field_info =
294 if let Some(mapping) = self.spec.field_mappings.get(&field_path) {
295 if mapping
297 .inner_type
298 .as_ref()
299 .is_some_and(|t| is_builtin_resolver_type(t))
300 {
301 mapping
302 } else {
303 field_info
304 }
305 } else {
306 field_info
307 };
308 let (raw_name, canonical_name) = localize_section_field_names(
309 section.name.as_str(),
310 effective_field_info,
311 );
312
313 section_fields.push(TypeScriptField::from_names(
314 raw_name,
315 canonical_name,
316 self.field_type_info_to_typescript(effective_field_info),
317 effective_field_info.is_optional,
318 FieldPresence::Patch,
319 ));
320 }
321 }
322 }
323 } else {
324 for (field_path, field_type_info) in &self.spec.field_mappings {
326 if !field_type_info.emit {
327 continue;
328 }
329 let parts: Vec<&str> = field_path.split('.').collect();
330 if parts.len() > 1 {
331 let section_name = parts[0];
332 let field_name = parts[1];
333
334 let section_fields = sections.entry(section_name.to_string()).or_default();
335
336 let already_exists = section_fields.iter().any(|f| f.name == field_name);
337
338 if !already_exists {
339 section_fields.push(TypeScriptField::from_names(
340 field_name.to_string(),
341 field_type_info.canonical_field_name(),
342 self.base_type_to_typescript(
343 &field_type_info.base_type,
344 field_type_info.effective_integer_kind(),
345 field_type_info.is_array,
346 ),
347 field_type_info.is_optional,
348 FieldPresence::Patch,
349 ));
350 }
351 }
352 }
353 }
354 }
355
356 fn generate_interface_from_fields(&self, name: &str, fields: &[TypeScriptField]) -> String {
357 let interface_name = self.section_interface_name(name);
358 render_interface_from_ts_fields(&interface_name, fields, true)
359 }
360
361 fn section_interface_name(&self, name: &str) -> String {
362 if name == "Root" {
363 format!(
364 "{}{}",
365 self.config.interface_prefix,
366 to_pascal_case(&self.entity_name)
367 )
368 } else {
369 let base_name = if self.entity_name.contains("Game") {
372 "Game"
373 } else {
374 &self.entity_name
375 };
376 format!(
377 "{}{}{}",
378 self.config.interface_prefix,
379 base_name,
380 to_pascal_case(name)
381 )
382 }
383 }
384
385 fn generate_main_entity_interface(&self) -> String {
386 let entity_name = to_pascal_case(&self.entity_name);
387
388 let main_fields = self.collect_main_entity_fields();
389 if main_fields.is_empty() {
390 return format!(
391 "export interface {} {{\n // Generated interface - extend as needed\n}}",
392 entity_name
393 );
394 }
395
396 render_interface_from_ts_fields(&entity_name, &main_fields, true)
397 }
398
399 fn generate_schemas(&self) -> SchemaOutput {
400 let patch_schema_types = self.patch_schema_type_names();
401 let mut definitions = Vec::new();
402 let mut names = Vec::new();
403 let mut seen = HashSet::new();
404
405 let mut push_schema = |schema_name: String, definition: String, export_name: bool| {
406 if seen.insert(schema_name.clone()) {
407 if export_name {
408 names.push(schema_name);
409 }
410 definitions.push(definition);
411 }
412 };
413
414 for (schema_name, definition) in self.generate_builtin_resolver_schemas() {
415 push_schema(schema_name, definition, true);
416 }
417
418 if self.has_event_types() {
419 push_schema(
420 "EventWrapperSchema".to_string(),
421 self.generate_event_wrapper_schema(),
422 true,
423 );
424 }
425
426 for (schema_name, definition) in self.generate_resolved_type_schemas(&patch_schema_types) {
427 push_schema(schema_name, definition, true);
428 }
429
430 for (schema_name, definition) in
431 self.generate_resolved_type_patch_schemas(&patch_schema_types)
432 {
433 push_schema(schema_name, definition, false);
434 }
435
436 for (schema_name, definition) in self.generate_event_schemas() {
437 push_schema(schema_name, definition, true);
438 }
439
440 for (schema_name, definition) in self.generate_idl_enum_schemas() {
441 push_schema(schema_name, definition, true);
442 }
443
444 let all_sections = self.collect_interface_sections();
445
446 for (section_name, fields) in &all_sections {
447 if is_root_section(section_name) {
448 continue;
449 }
450 let deduplicated_fields = self.deduplicate_fields(fields.clone());
451 let interface_name = self.section_interface_name(section_name);
452 let schema_definition = self.generate_schema_for_fields(
453 &interface_name,
454 &deduplicated_fields,
455 true,
456 SchemaMode::Canonical,
457 &patch_schema_types,
458 );
459 push_schema(format!("{}Schema", interface_name), schema_definition, true);
460
461 let patch_schema_definition = self.generate_schema_for_fields(
462 &interface_name,
463 &deduplicated_fields,
464 false,
465 SchemaMode::Patch,
466 &patch_schema_types,
467 );
468 push_schema(
469 format!("{}PatchSchema", interface_name),
470 patch_schema_definition,
471 false,
472 );
473 }
474
475 let entity_name = to_pascal_case(&self.entity_name);
476 let main_fields = self.collect_main_entity_fields();
477 let entity_schema = self.generate_schema_for_fields(
478 &entity_name,
479 &main_fields,
480 true,
481 SchemaMode::Canonical,
482 &patch_schema_types,
483 );
484 push_schema(format!("{}Schema", entity_name), entity_schema, true);
485
486 let patch_schema = self.generate_schema_for_fields(
487 &entity_name,
488 &main_fields,
489 false,
490 SchemaMode::Patch,
491 &patch_schema_types,
492 );
493 push_schema(format!("{}PatchSchema", entity_name), patch_schema, false);
494
495 let completed_schema =
496 self.generate_completed_entity_schema(&entity_name, &patch_schema_types);
497 push_schema(
498 format!("{}CompletedSchema", entity_name),
499 completed_schema,
500 true,
501 );
502
503 SchemaOutput {
504 definitions: definitions.join("\n\n"),
505 names,
506 }
507 }
508
509 fn generate_event_wrapper_schema(&self) -> String {
510 r#"export const EventWrapperSchema = <T extends z.ZodTypeAny>(data: T) => z.object({
511 timestamp: z.number(),
512 data,
513 slot: z.number().optional(),
514 signature: z.string().optional(),
515});"#
516 .to_string()
517 }
518
519 fn generate_builtin_resolver_schemas(&self) -> Vec<(String, String)> {
520 let mut schemas = Vec::new();
521 let registry = crate::resolvers::builtin_resolver_registry();
522
523 for resolver in registry.definitions() {
524 let output_type = resolver.output_type();
525 let should_emit = self.uses_builtin_type(output_type)
526 && !self.already_emitted_types.contains(output_type);
527
528 let extra_types_used = if let Some(ts_schema) = resolver.typescript_schema() {
530 ts_schema.definition.lines().any(|line| {
532 let line = line.trim();
533 if let Some(rest) = line.strip_prefix("export const ") {
535 let parts: Vec<&str> = rest.split_whitespace().collect();
536 if parts.len() >= 2 && parts[1] == "=" {
537 let schema_name = parts[0];
539 if let Some(type_name) = schema_name.strip_suffix("Schema") {
540 return self.uses_builtin_type(type_name)
541 && !self.already_emitted_types.contains(type_name);
542 }
543 }
544 }
545 false
546 })
547 } else {
548 false
549 };
550
551 if (should_emit || extra_types_used)
552 && !self.already_emitted_types.contains(output_type)
553 {
554 if let Some(schema) = resolver.typescript_schema() {
555 schemas.push((schema.name.to_string(), schema.definition.to_string()));
556 }
557 }
558 }
559
560 schemas
561 }
562
563 fn uses_builtin_type(&self, type_name: &str) -> bool {
564 for section in &self.spec.sections {
566 for field in §ion.fields {
567 if field.inner_type.as_deref() == Some(type_name) {
568 return true;
569 }
570 }
571 }
572 for field_info in self.spec.field_mappings.values() {
574 if field_info.inner_type.as_deref() == Some(type_name) {
575 return true;
576 }
577 }
578 false
579 }
580
581 fn generate_builtin_resolver_interfaces(&self) -> Vec<String> {
582 let mut interfaces = Vec::new();
583 let registry = crate::resolvers::builtin_resolver_registry();
584
585 for resolver in registry.definitions() {
586 let output_type = resolver.output_type();
587 let should_emit = self.uses_builtin_type(output_type)
588 && !self.already_emitted_types.contains(output_type);
589
590 let extra_types_used = if let Some(ts_interface) = resolver.typescript_interface() {
592 ts_interface.lines().any(|line| {
594 let line = line.trim();
595 if let Some(rest) = line.strip_prefix("export type ") {
597 if let Some(type_name) = rest.split_whitespace().next() {
598 return self.uses_builtin_type(type_name)
599 && !self.already_emitted_types.contains(type_name);
600 }
601 } else if let Some(rest) = line.strip_prefix("export interface ") {
602 if let Some(type_name) = rest.split_whitespace().next() {
603 return self.uses_builtin_type(type_name)
604 && !self.already_emitted_types.contains(type_name);
605 }
606 }
607 false
608 })
609 } else {
610 false
611 };
612
613 if should_emit || extra_types_used {
614 if let Some(interface) = resolver.typescript_interface() {
615 interfaces.push(interface.to_string());
616 }
617 }
618 }
619
620 interfaces
621 }
622
623 fn collect_main_entity_fields(&self) -> Vec<TypeScriptField> {
624 let mut sections = BTreeMap::new();
625
626 for handler in &self.spec.handlers {
627 for mapping in &handler.mappings {
628 if !mapping.emit {
629 continue;
630 }
631 let parts: Vec<&str> = mapping.target_path.split('.').collect();
632 if parts.len() > 1 {
633 sections.insert(parts[0], true);
634 }
635 }
636 }
637
638 if !self.spec.sections.is_empty() {
639 for section in &self.spec.sections {
640 if section.fields.iter().any(|field| field.emit) {
641 sections.insert(§ion.name, true);
642 }
643 }
644 } else {
645 for mapping in &self.spec.handlers {
646 for field_mapping in &mapping.mappings {
647 if !field_mapping.emit {
648 continue;
649 }
650 let parts: Vec<&str> = field_mapping.target_path.split('.').collect();
651 if parts.len() > 1 {
652 sections.insert(parts[0], true);
653 }
654 }
655 }
656 }
657
658 let mut fields = Vec::new();
659
660 for section in sections.keys() {
661 if !is_root_section(section) {
662 let base_name = if self.entity_name.contains("Game") {
663 "Game"
664 } else {
665 &self.entity_name
666 };
667 let section_interface_name = format!("{}{}", base_name, to_pascal_case(section));
668 fields.push(TypeScriptField::patch(
669 section.to_string(),
670 section_interface_name,
671 false,
672 ));
673 }
674 }
675
676 for section in &self.spec.sections {
677 if is_root_section(§ion.name) {
678 for field in §ion.fields {
679 if !field.emit {
680 continue;
681 }
682 fields.push(TypeScriptField::from_names(
683 field.raw_field_name().to_string(),
684 field.canonical_field_name(),
685 self.field_type_info_to_typescript(field),
686 field.is_optional,
687 FieldPresence::Patch,
688 ));
689 }
690 }
691 }
692
693 fields
694 }
695
696 fn generate_schema_for_fields(
697 &self,
698 name: &str,
699 fields: &[TypeScriptField],
700 required: bool,
701 mode: SchemaMode,
702 patch_schema_types: &HashSet<String>,
703 ) -> String {
704 if fields.is_empty() {
705 return format!(
706 "export const {} = z.object({{}});",
707 schema_constant_name(name, mode)
708 );
709 }
710
711 let mut field_definitions = Vec::new();
712 let mut transform_fields = Vec::new();
713
714 for field in fields {
715 let base_schema = field.zod_schema.clone().unwrap_or_else(|| {
716 self.typescript_type_to_zod_for_schema(&field.ts_type, mode, patch_schema_types)
717 });
718 let with_nullable = if field.nullable {
719 format!("{}.nullable()", base_schema)
720 } else {
721 base_schema
722 };
723 let schema = if required || matches!(field.presence, FieldPresence::Required) {
724 with_nullable
725 } else {
726 format!("{}.optional()", with_nullable)
727 };
728
729 field_definitions.push(format!(" {}: {},", field.raw_name, schema));
730 if mode == SchemaMode::Patch {
731 transform_fields.push(format!(
732 " ...(value.{raw_name} !== undefined ? {{ {field_name}: value.{raw_name} }} : {{}}),",
733 raw_name = field.raw_name,
734 field_name = field.name,
735 ));
736 } else {
737 transform_fields.push(format!(" {}: value.{},", field.name, field.raw_name));
738 }
739 }
740
741 format!(
742 "export const {} = z.object({{\n{}\n}}).transform((value) => ({{\n{}\n}}));",
743 schema_constant_name(name, mode),
744 field_definitions.join("\n"),
745 transform_fields.join("\n")
746 )
747 }
748
749 fn generate_completed_entity_schema(
750 &self,
751 entity_name: &str,
752 patch_schema_types: &HashSet<String>,
753 ) -> String {
754 let main_fields = self.collect_main_entity_fields();
755 self.generate_schema_for_fields(
756 &format!("{}Completed", entity_name),
757 &main_fields,
758 true,
759 SchemaMode::Canonical,
760 patch_schema_types,
761 )
762 }
763
764 fn generate_resolved_type_schemas(
765 &self,
766 patch_schema_types: &HashSet<String>,
767 ) -> Vec<(String, String)> {
768 let mut schemas = Vec::new();
769 let mut generated_types = HashSet::new();
770 let resolved_name_map = self.build_resolved_type_name_map();
771
772 for section in &self.spec.sections {
773 for field_info in §ion.fields {
774 if let Some(resolved) = &field_info.resolved_type {
775 let type_name =
776 self.resolved_type_to_interface_name_with_map(resolved, &resolved_name_map);
777
778 if !generated_types.insert(type_name.clone()) {
779 continue;
780 }
781
782 if resolved.is_enum {
783 let variants: Vec<String> = resolved
784 .enum_variants
785 .iter()
786 .map(|v| format!("\"{}\"", to_pascal_case(v)))
787 .collect();
788 let schema = if variants.is_empty() {
789 format!("export const {}Schema = z.string();", type_name)
790 } else {
791 format!(
792 "export const {}Schema = z.enum([{}]);",
793 type_name,
794 variants.join(", ")
795 )
796 };
797 schemas.push((format!("{}Schema", type_name), schema));
798 continue;
799 }
800
801 let schema = self.generate_schema_for_fields(
802 &type_name,
803 &self.resolved_fields_to_typescript_fields(&resolved.fields),
804 true,
805 SchemaMode::Canonical,
806 patch_schema_types,
807 );
808 schemas.push((format!("{}Schema", type_name), schema));
809 }
810 }
811 }
812
813 schemas
814 }
815
816 fn generate_resolved_type_patch_schemas(
817 &self,
818 patch_schema_types: &HashSet<String>,
819 ) -> Vec<(String, String)> {
820 let mut schemas = Vec::new();
821 let mut generated_types = HashSet::new();
822 let resolved_name_map = self.build_resolved_type_name_map();
823
824 for section in &self.spec.sections {
825 for field_info in §ion.fields {
826 if let Some(resolved) = &field_info.resolved_type {
827 let type_name =
828 self.resolved_type_to_interface_name_with_map(resolved, &resolved_name_map);
829
830 if !generated_types.insert(type_name.clone()) || resolved.is_enum {
831 continue;
832 }
833
834 let schema = self.generate_schema_for_fields(
835 &type_name,
836 &self.resolved_fields_to_typescript_fields(&resolved.fields),
837 false,
838 SchemaMode::Patch,
839 patch_schema_types,
840 );
841 schemas.push((format!("{}PatchSchema", type_name), schema));
842 }
843 }
844 }
845
846 schemas
847 }
848
849 fn generate_event_schemas(&self) -> Vec<(String, String)> {
850 let mut schemas = Vec::new();
851 let mut generated_types = HashSet::new();
852
853 let handlers = match &self.handlers_json {
854 Some(h) => h.as_array(),
855 None => return schemas,
856 };
857
858 let handlers_array = match handlers {
859 Some(arr) => arr,
860 None => return schemas,
861 };
862
863 for handler in handlers_array {
864 if let Some(mappings) = handler.get("mappings").and_then(|m| m.as_array()) {
865 for mapping in mappings {
866 if let Some(target_path) = mapping.get("target_path").and_then(|t| t.as_str()) {
867 if target_path.contains(".events.") || target_path.starts_with("events.") {
868 if let Some(source) = mapping.get("source") {
869 if let Some(event_data) = self.extract_event_data(source) {
870 if let Some(handler_source) = handler.get("source") {
871 if let Some(instruction_name) =
872 self.extract_instruction_name(handler_source)
873 {
874 let event_field_name =
875 target_path.split('.').next_back().unwrap_or("");
876 let interface_name = format!(
877 "{}Event",
878 to_pascal_case(event_field_name)
879 );
880
881 if generated_types.insert(interface_name.clone()) {
882 if let Some(schema) = self
883 .generate_event_schema_from_idl(
884 &interface_name,
885 &instruction_name,
886 &event_data,
887 )
888 {
889 schemas.push((
890 format!("{}Schema", interface_name),
891 schema,
892 ));
893 }
894 }
895 }
896 }
897 }
898 }
899 }
900 }
901 }
902 }
903 }
904
905 schemas
906 }
907
908 fn generate_event_schema_from_idl(
909 &self,
910 interface_name: &str,
911 rust_instruction_name: &str,
912 captured_fields: &[(String, Option<String>)],
913 ) -> Option<String> {
914 if captured_fields.is_empty() {
915 return Some(format!(
916 "export const {}Schema = z.object({{}});",
917 interface_name
918 ));
919 }
920
921 let idl_value = self.idl.as_ref()?;
922 let instructions = idl_value.get("instructions")?.as_array()?;
923
924 let instruction = self.find_instruction_in_idl(instructions, rust_instruction_name)?;
925 let args = instruction.get("args")?.as_array()?;
926
927 let mut fields = Vec::new();
928 for (field_name, transform) in captured_fields {
929 for arg in args {
930 if let Some(arg_name) = arg.get("name").and_then(|n| n.as_str()) {
931 if arg_name == field_name {
932 if let Some(arg_type) = arg.get("type") {
933 let ts_type =
934 self.idl_type_to_typescript(arg_type, transform.as_deref());
935 fields.push(TypeScriptField::patch(
936 field_name.to_string(),
937 ts_type,
938 false,
939 ));
940 }
941 break;
942 }
943 }
944 }
945 }
946
947 Some(render_schema_from_ts_fields(interface_name, &fields, true))
948 }
949
950 fn generate_idl_enum_schemas(&self) -> Vec<(String, String)> {
951 let mut schemas = Vec::new();
952 let mut generated_types = self.already_emitted_types.clone();
953
954 let idl_value = match &self.idl {
955 Some(idl) => idl,
956 None => return schemas,
957 };
958
959 let types_array = match idl_value.get("types").and_then(|v| v.as_array()) {
960 Some(types) => types,
961 None => return schemas,
962 };
963
964 for type_def in types_array {
965 if let (Some(type_name), Some(type_obj)) = (
966 type_def.get("name").and_then(|v| v.as_str()),
967 type_def.get("type").and_then(|v| v.as_object()),
968 ) {
969 if type_obj.get("kind").and_then(|v| v.as_str()) == Some("enum") {
970 let interface_name = to_pascal_case(type_name);
971 if !generated_types.insert(interface_name.clone()) {
972 continue;
973 }
974 if let Some(variants) = type_obj.get("variants").and_then(|v| v.as_array()) {
975 let variant_names: Vec<String> = variants
976 .iter()
977 .filter_map(|v| v.get("name").and_then(|n| n.as_str()))
978 .map(|s| format!("\"{}\"", to_pascal_case(s)))
979 .collect();
980
981 let schema = if variant_names.is_empty() {
982 format!("export const {}Schema = z.string();", interface_name)
983 } else {
984 format!(
985 "export const {}Schema = z.enum([{}]);",
986 interface_name,
987 variant_names.join(", ")
988 )
989 };
990 schemas.push((format!("{}Schema", interface_name), schema));
991 }
992 }
993 }
994 }
995
996 schemas
997 }
998
999 fn typescript_type_to_zod_for_schema(
1000 &self,
1001 ts_type: &str,
1002 mode: SchemaMode,
1003 patch_schema_types: &HashSet<String>,
1004 ) -> String {
1005 typescript_type_to_zod_for_schema_static(ts_type, mode, patch_schema_types)
1006 }
1007
1008 fn patch_schema_type_names(&self) -> HashSet<String> {
1009 let mut names = HashSet::new();
1010 let resolved_name_map = self.build_resolved_type_name_map();
1011
1012 for section in &self.spec.sections {
1013 if !is_root_section(§ion.name) && section.fields.iter().any(|field| field.emit) {
1014 names.insert(self.section_interface_name(§ion.name));
1015 }
1016
1017 for field in §ion.fields {
1018 let Some(resolved) = &field.resolved_type else {
1019 continue;
1020 };
1021
1022 if resolved.is_enum {
1023 continue;
1024 }
1025
1026 names.insert(
1027 self.resolved_type_to_interface_name_with_map(resolved, &resolved_name_map),
1028 );
1029 }
1030 }
1031
1032 names.insert("TokenMetadata".to_string());
1033 names
1034 }
1035
1036 fn generate_stack_definition(&self) -> String {
1037 let stack_name = to_kebab_case(&self.entity_name);
1038 let entity_pascal = to_pascal_case(&self.entity_name);
1039 let export_name = format!(
1040 "{}_{}",
1041 self.entity_name.to_uppercase(),
1042 self.config.export_const_name
1043 );
1044
1045 let view_helpers = self.generate_view_helpers();
1046 let derived_views = self.generate_derived_view_entries();
1047 let schema_names = self.generate_schemas().names;
1048 let mut unique_schemas: BTreeSet<String> = BTreeSet::new();
1049 for name in schema_names {
1050 unique_schemas.insert(name);
1051 }
1052 let schemas_block = if unique_schemas.is_empty() {
1053 String::new()
1054 } else {
1055 let schema_entries: Vec<String> = unique_schemas
1056 .iter()
1057 .filter(|name| name.ends_with("Schema") && !name.ends_with("PatchSchema"))
1058 .map(|name| format!(" {}: {},", name.trim_end_matches("Schema"), name))
1059 .collect();
1060 if schema_entries.is_empty() {
1061 String::new()
1062 } else {
1063 format!("\n schemas: {{\n{}\n }},", schema_entries.join("\n"))
1064 }
1065 };
1066
1067 let patch_schemas_block = format!(
1068 "\n patchSchemas: {{\n {entity}: {entity}PatchSchema,\n }},",
1069 entity = entity_pascal
1070 );
1071
1072 let url_line = match &self.config.url {
1074 Some(url) => format!(" url: '{}',", url),
1075 None => " url: '', // TODO: Set after first deployment or pass useArete(..., { url })"
1076 .to_string(),
1077 };
1078
1079 format!(
1080 r#"{}
1081
1082// ============================================================================
1083// Stack Definition
1084// ============================================================================
1085
1086/** Stack definition for {} */
1087export const {} = {{
1088 name: '{}',
1089{}
1090 views: {{
1091 {}: {{
1092 state: stateView<{}>('{}/state'),
1093 list: listView<{}>('{}/list'),{}
1094 }},
1095 }},{}{}
1096}} as const;
1097
1098/** Type alias for the stack */
1099export type {}Stack = typeof {};
1100
1101/** Default export for convenience */
1102export default {};"#,
1103 view_helpers,
1104 entity_pascal,
1105 export_name,
1106 stack_name,
1107 url_line,
1108 self.entity_name,
1109 entity_pascal,
1110 self.entity_name,
1111 entity_pascal,
1112 self.entity_name,
1113 derived_views,
1114 schemas_block,
1115 patch_schemas_block,
1116 entity_pascal,
1117 export_name,
1118 export_name
1119 )
1120 }
1121
1122 fn generate_derived_view_entries(&self) -> String {
1123 let derived_views: Vec<&ViewDef> = self
1124 .views
1125 .iter()
1126 .filter(|v| {
1127 !v.id.ends_with("/state")
1128 && !v.id.ends_with("/list")
1129 && v.id.starts_with(&self.entity_name)
1130 })
1131 .collect();
1132
1133 if derived_views.is_empty() {
1134 return String::new();
1135 }
1136
1137 let entity_pascal = to_pascal_case(&self.entity_name);
1138 let mut entries = Vec::new();
1139
1140 for view in derived_views {
1141 let view_name = view.id.split('/').nth(1).unwrap_or("unknown");
1142
1143 entries.push(format!(
1144 "\n {}: listView<{}>('{}'),",
1145 view_name, entity_pascal, view.id
1146 ));
1147 }
1148
1149 entries.join("")
1150 }
1151
1152 fn mapping_to_typescript_type(&self, mapping: &TypedFieldMapping<S>) -> String {
1153 if let Some(field_info) = self.spec.field_mappings.get(&mapping.target_path) {
1155 let ts_type = self.field_type_info_to_typescript(field_info);
1156
1157 if matches!(mapping.population, PopulationStrategy::Append) {
1159 return if ts_type.ends_with("[]") {
1160 ts_type
1161 } else {
1162 format!("{}[]", ts_type)
1163 };
1164 }
1165
1166 return ts_type;
1167 }
1168
1169 match &mapping.population {
1171 PopulationStrategy::Append => {
1172 match &mapping.source {
1174 MappingSource::AsEvent { .. } => "any[]".to_string(),
1175 _ => "any[]".to_string(),
1176 }
1177 }
1178 _ => {
1179 let base_type = match &mapping.source {
1181 MappingSource::FromSource { .. } => {
1182 self.infer_type_from_field_name(&mapping.target_path)
1183 }
1184 MappingSource::Constant(value) => value_to_typescript_type(value),
1185 MappingSource::AsEvent { .. } => "any".to_string(),
1186 _ => "any".to_string(),
1187 };
1188
1189 if let Some(transform) = &mapping.transform {
1191 match transform {
1192 Transformation::HexEncode | Transformation::HexDecode => {
1193 "string".to_string()
1194 }
1195 Transformation::Base58Encode | Transformation::Base58Decode => {
1196 "string".to_string()
1197 }
1198 Transformation::ToString => "string".to_string(),
1199 Transformation::ToNumber => "number".to_string(),
1200 }
1201 } else {
1202 base_type
1203 }
1204 }
1205 }
1206 }
1207
1208 fn field_type_info_to_typescript(&self, field_info: &FieldTypeInfo) -> String {
1209 if let Some(resolved) = &field_info.resolved_type {
1210 let interface_name = self.resolved_type_to_interface_name(resolved);
1211
1212 let base_type = if resolved.is_event || (resolved.is_instruction && field_info.is_array)
1213 {
1214 format!("EventWrapper<{}>", interface_name)
1215 } else {
1216 interface_name
1217 };
1218
1219 let with_array = if field_info.is_array {
1220 format!("{}[]", base_type)
1221 } else {
1222 base_type
1223 };
1224
1225 return with_array;
1226 }
1227
1228 if let Some(inner_type) = &field_info.inner_type {
1229 if is_builtin_resolver_type(inner_type) {
1230 return inner_type.clone();
1231 }
1232 }
1233
1234 if let Some(ts_type) = typescript_integer_type(
1235 field_info.effective_integer_kind(),
1236 field_info
1237 .inner_type
1238 .as_deref()
1239 .or(Some(field_info.rust_type_name.as_str())),
1240 ) {
1241 return if field_info.is_array {
1242 format!("{}[]", ts_type)
1243 } else {
1244 ts_type.to_string()
1245 };
1246 }
1247
1248 if field_info.base_type == BaseType::Any
1249 || (field_info.base_type == BaseType::Array
1250 && field_info.inner_type.as_deref() == Some("Value"))
1251 {
1252 if let Some(event_type) = self.find_event_interface_for_field(&field_info.field_name) {
1253 return if field_info.is_array {
1254 format!("{}[]", event_type)
1255 } else {
1256 event_type
1257 };
1258 }
1259 }
1260
1261 self.base_type_to_typescript(
1262 &field_info.base_type,
1263 field_info.effective_integer_kind(),
1264 field_info.is_array,
1265 )
1266 }
1267
1268 fn find_event_interface_for_field(&self, field_name: &str) -> Option<String> {
1270 let handlers = self.handlers_json.as_ref()?.as_array()?;
1272
1273 for handler in handlers {
1275 if let Some(mappings) = handler.get("mappings").and_then(|m| m.as_array()) {
1276 for mapping in mappings {
1277 if let Some(target_path) = mapping.get("target_path").and_then(|t| t.as_str()) {
1278 let target_parts: Vec<&str> = target_path.split('.').collect();
1280 if let Some(target_field) = target_parts.last() {
1281 if *target_field == field_name {
1282 if let Some(source) = mapping.get("source") {
1284 if self.extract_event_data(source).is_some() {
1285 return Some(format!(
1287 "{}Event",
1288 to_pascal_case(field_name)
1289 ));
1290 }
1291 }
1292 }
1293 }
1294 }
1295 }
1296 }
1297 }
1298 None
1299 }
1300
1301 fn resolved_type_to_interface_name(&self, resolved: &ResolvedStructType) -> String {
1303 self.build_resolved_type_name_map()
1304 .get(&resolved.type_name)
1305 .cloned()
1306 .unwrap_or_else(|| to_pascal_case(&resolved.type_name))
1307 }
1308
1309 fn generate_nested_interfaces(&self) -> Vec<String> {
1311 let mut interfaces = Vec::new();
1312 let mut generated_types = self.already_emitted_types.clone();
1313 let resolved_name_map = self.build_resolved_type_name_map();
1314
1315 for section in &self.spec.sections {
1317 for field_info in §ion.fields {
1318 if let Some(resolved) = &field_info.resolved_type {
1319 let type_name =
1320 self.resolved_type_to_interface_name_with_map(resolved, &resolved_name_map);
1321
1322 if generated_types.insert(type_name) {
1324 let interface = self.generate_interface_for_resolved_type(resolved);
1325 interfaces.push(interface);
1326 }
1327 }
1328 }
1329 }
1330
1331 interfaces.extend(self.generate_event_interfaces(&mut generated_types));
1333
1334 if let Some(idl_value) = &self.idl {
1336 if let Some(types_array) = idl_value.get("types").and_then(|v| v.as_array()) {
1337 for type_def in types_array {
1338 if let (Some(type_name), Some(type_obj)) = (
1339 type_def.get("name").and_then(|v| v.as_str()),
1340 type_def.get("type").and_then(|v| v.as_object()),
1341 ) {
1342 if type_obj.get("kind").and_then(|v| v.as_str()) == Some("enum") {
1343 let interface_name = to_pascal_case(type_name);
1345 if generated_types.insert(interface_name.clone()) {
1346 if let Some(variants) =
1347 type_obj.get("variants").and_then(|v| v.as_array())
1348 {
1349 let variant_names: Vec<String> = variants
1350 .iter()
1351 .filter_map(|v| {
1352 v.get("name")
1353 .and_then(|n| n.as_str())
1354 .map(|s| s.to_string())
1355 })
1356 .collect();
1357
1358 if !variant_names.is_empty() {
1359 let variant_strings: Vec<String> = variant_names
1360 .iter()
1361 .map(|v| format!("\"{}\"", to_pascal_case(v)))
1362 .collect();
1363
1364 let enum_type = format!(
1365 "export type {} = {};",
1366 interface_name,
1367 variant_strings.join(" | ")
1368 );
1369 interfaces.push(enum_type);
1370 }
1371 }
1372 }
1373 }
1374 }
1375 }
1376 }
1377 }
1378
1379 interfaces
1380 }
1381
1382 fn generate_event_interfaces(&self, generated_types: &mut HashSet<String>) -> Vec<String> {
1384 let mut interfaces = Vec::new();
1385
1386 let handlers = match &self.handlers_json {
1388 Some(h) => h.as_array(),
1389 None => return interfaces,
1390 };
1391
1392 let handlers_array = match handlers {
1393 Some(arr) => arr,
1394 None => return interfaces,
1395 };
1396
1397 for handler in handlers_array {
1399 if let Some(mappings) = handler.get("mappings").and_then(|m| m.as_array()) {
1401 for mapping in mappings {
1402 if let Some(target_path) = mapping.get("target_path").and_then(|t| t.as_str()) {
1403 if target_path.contains(".events.") || target_path.starts_with("events.") {
1405 if let Some(source) = mapping.get("source") {
1407 if let Some(event_data) = self.extract_event_data(source) {
1408 if let Some(handler_source) = handler.get("source") {
1410 if let Some(instruction_name) =
1411 self.extract_instruction_name(handler_source)
1412 {
1413 let event_field_name =
1415 target_path.split('.').next_back().unwrap_or("");
1416 let interface_name = format!(
1417 "{}Event",
1418 to_pascal_case(event_field_name)
1419 );
1420
1421 if generated_types.insert(interface_name.clone()) {
1423 if let Some(interface) = self
1424 .generate_event_interface_from_idl(
1425 &interface_name,
1426 &instruction_name,
1427 &event_data,
1428 )
1429 {
1430 interfaces.push(interface);
1431 }
1432 }
1433 }
1434 }
1435 }
1436 }
1437 }
1438 }
1439 }
1440 }
1441 }
1442
1443 interfaces
1444 }
1445
1446 fn extract_event_data(
1448 &self,
1449 source: &serde_json::Value,
1450 ) -> Option<Vec<(String, Option<String>)>> {
1451 if let Some(as_event) = source.get("AsEvent") {
1452 if let Some(fields) = as_event.get("fields").and_then(|f| f.as_array()) {
1453 let mut event_fields = Vec::new();
1454 for field in fields {
1455 if let Some(from_source) = field.get("FromSource") {
1456 if let Some(path) = from_source
1457 .get("path")
1458 .and_then(|p| p.get("segments"))
1459 .and_then(|s| s.as_array())
1460 {
1461 if let Some(field_name) = path.last().and_then(|v| v.as_str()) {
1463 let transform = from_source
1464 .get("transform")
1465 .and_then(|t| t.as_str())
1466 .map(|s| s.to_string());
1467 event_fields.push((field_name.to_string(), transform));
1468 }
1469 }
1470 }
1471 }
1472 return Some(event_fields);
1473 }
1474 }
1475 None
1476 }
1477
1478 fn extract_instruction_name(&self, source: &serde_json::Value) -> Option<String> {
1480 if let Some(source_obj) = source.get("Source") {
1481 if let Some(type_name) = source_obj.get("type_name").and_then(|t| t.as_str()) {
1482 let instruction_part =
1483 crate::event_type_helpers::strip_event_type_suffix(type_name);
1484 return Some(instruction_part.to_string());
1485 }
1486 }
1487 None
1488 }
1489
1490 fn find_instruction_in_idl<'a>(
1494 &self,
1495 instructions: &'a [serde_json::Value],
1496 rust_name: &str,
1497 ) -> Option<&'a serde_json::Value> {
1498 let normalized_search = normalize_for_comparison(rust_name);
1499
1500 for instruction in instructions {
1501 if let Some(idl_name) = instruction.get("name").and_then(|n| n.as_str()) {
1502 if normalize_for_comparison(idl_name) == normalized_search {
1503 return Some(instruction);
1504 }
1505 }
1506 }
1507 None
1508 }
1509
1510 fn generate_event_interface_from_idl(
1512 &self,
1513 interface_name: &str,
1514 rust_instruction_name: &str,
1515 captured_fields: &[(String, Option<String>)],
1516 ) -> Option<String> {
1517 if captured_fields.is_empty() {
1518 return Some(format!("export interface {} {{}}", interface_name));
1519 }
1520
1521 let idl_value = self.idl.as_ref()?;
1522 let instructions = idl_value.get("instructions")?.as_array()?;
1523
1524 let instruction = self.find_instruction_in_idl(instructions, rust_instruction_name)?;
1525 let args = instruction.get("args")?.as_array()?;
1526
1527 let mut fields = Vec::new();
1528 for (field_name, transform) in captured_fields {
1529 for arg in args {
1530 if let Some(arg_name) = arg.get("name").and_then(|n| n.as_str()) {
1531 if arg_name == field_name {
1532 if let Some(arg_type) = arg.get("type") {
1533 let ts_type =
1534 self.idl_type_to_typescript(arg_type, transform.as_deref());
1535 fields.push(TypeScriptField::patch(
1536 field_name.to_string(),
1537 ts_type,
1538 false,
1539 ));
1540 }
1541 break;
1542 }
1543 }
1544 }
1545 }
1546
1547 if !fields.is_empty() {
1548 return Some(render_interface_from_ts_fields(
1549 interface_name,
1550 &fields,
1551 true,
1552 ));
1553 }
1554
1555 None
1556 }
1557
1558 fn idl_type_to_typescript(
1560 &self,
1561 idl_type: &serde_json::Value,
1562 transform: Option<&str>,
1563 ) -> String {
1564 #![allow(clippy::only_used_in_recursion)]
1565 if transform == Some("HexEncode") {
1567 return "string".to_string();
1568 }
1569
1570 if let Some(type_str) = idl_type.as_str() {
1572 return match type_str {
1573 "u64" | "u128" | "i64" | "i128" => "bigint".to_string(),
1574 "u8" | "u16" | "u32" | "i8" | "i16" | "i32" => "number".to_string(),
1575 "f32" | "f64" => "number".to_string(),
1576 "bool" => "boolean".to_string(),
1577 "string" => "string".to_string(),
1578 "pubkey" | "publicKey" => "string".to_string(),
1579 "bytes" => "string".to_string(),
1580 _ => "any".to_string(),
1581 };
1582 }
1583
1584 if let Some(type_obj) = idl_type.as_object() {
1586 if let Some(option_type) = type_obj.get("option") {
1587 let inner = self.idl_type_to_typescript(option_type, None);
1588 return format!("{} | null", inner);
1589 }
1590 if let Some(vec_type) = type_obj.get("vec") {
1591 let inner = self.idl_type_to_typescript(vec_type, None);
1592 return format!("{}[]", inner);
1593 }
1594 }
1595
1596 "any".to_string()
1597 }
1598
1599 fn generate_interface_for_resolved_type(&self, resolved: &ResolvedStructType) -> String {
1601 let interface_name = self.resolved_type_to_interface_name(resolved);
1602
1603 if resolved.is_enum {
1605 let variants: Vec<String> = resolved
1606 .enum_variants
1607 .iter()
1608 .map(|v| format!("\"{}\"", to_pascal_case(v)))
1609 .collect();
1610
1611 return format!("export type {} = {};", interface_name, variants.join(" | "));
1612 }
1613
1614 render_interface_from_ts_fields(
1615 &interface_name,
1616 &self.resolved_fields_to_typescript_fields(&resolved.fields),
1617 true,
1618 )
1619 }
1620
1621 fn resolved_field_to_typescript(&self, field: &ResolvedField) -> String {
1623 if let Some(ts_type) =
1624 typescript_integer_type(field.effective_integer_kind(), Some(&field.field_type))
1625 {
1626 return if field.is_array {
1627 format!("{}[]", ts_type)
1628 } else {
1629 ts_type.to_string()
1630 };
1631 }
1632 let base_ts =
1633 self.base_type_to_typescript(&field.base_type, field.effective_integer_kind(), false);
1634
1635 if field.is_array {
1636 format!("{}[]", base_ts)
1637 } else {
1638 base_ts
1639 }
1640 }
1641
1642 fn resolved_fields_to_typescript_fields(
1643 &self,
1644 fields: &[ResolvedField],
1645 ) -> Vec<TypeScriptField> {
1646 fields
1647 .iter()
1648 .map(|field| {
1649 TypeScriptField::from_names(
1650 field.raw_field_name().to_string(),
1651 field.canonical_field_name(),
1652 self.resolved_field_to_typescript(field),
1653 field.is_optional,
1654 FieldPresence::Patch,
1655 )
1656 })
1657 .collect()
1658 }
1659
1660 fn has_event_types(&self) -> bool {
1662 for section in &self.spec.sections {
1663 for field_info in §ion.fields {
1664 if let Some(resolved) = &field_info.resolved_type {
1665 if resolved.is_event || (resolved.is_instruction && field_info.is_array) {
1666 return true;
1667 }
1668 }
1669 }
1670 }
1671 false
1672 }
1673
1674 fn build_resolved_type_name_map(&self) -> HashMap<String, String> {
1675 let mut reserved_names = self.already_emitted_types.clone();
1676 reserved_names.insert(to_pascal_case(&self.entity_name));
1677
1678 for section in &self.spec.sections {
1679 if !is_root_section(§ion.name) && section.fields.iter().any(|field| field.emit) {
1680 reserved_names.insert(self.section_interface_name(§ion.name));
1681 }
1682 }
1683
1684 let mut resolved_name_map = HashMap::new();
1685
1686 for section in &self.spec.sections {
1687 for field_info in §ion.fields {
1688 if !field_info.emit {
1689 continue;
1690 }
1691
1692 let Some(resolved) = &field_info.resolved_type else {
1693 continue;
1694 };
1695
1696 if resolved_name_map.contains_key(&resolved.type_name) {
1697 continue;
1698 }
1699
1700 let emitted_name = unique_resolved_type_name_ts(resolved, &mut reserved_names);
1701 resolved_name_map.insert(resolved.type_name.clone(), emitted_name);
1702 }
1703 }
1704
1705 resolved_name_map
1706 }
1707
1708 fn resolved_type_to_interface_name_with_map(
1709 &self,
1710 resolved: &ResolvedStructType,
1711 resolved_name_map: &HashMap<String, String>,
1712 ) -> String {
1713 resolved_name_map
1714 .get(&resolved.type_name)
1715 .cloned()
1716 .unwrap_or_else(|| to_pascal_case(&resolved.type_name))
1717 }
1718
1719 fn generate_event_wrapper_interface(&self) -> String {
1721 r#"/**
1722 * Wrapper for event data that includes context metadata.
1723 * Events are automatically wrapped in this structure at runtime.
1724 */
1725export interface EventWrapper<T> {
1726 /** Unix timestamp when the event was processed */
1727 timestamp: number;
1728 /** The event-specific data */
1729 data: T;
1730 /** Optional blockchain slot number */
1731 slot?: number;
1732 /** Optional transaction signature */
1733 signature?: string;
1734}"#
1735 .to_string()
1736 }
1737
1738 fn infer_type_from_field_name(&self, field_name: &str) -> String {
1739 let lower_name = field_name.to_lowercase();
1740
1741 if lower_name.contains("events.") {
1743 return "any".to_string();
1745 }
1746
1747 if lower_name.contains("id")
1749 || lower_name.contains("count")
1750 || lower_name.contains("number")
1751 || lower_name.contains("timestamp")
1752 || lower_name.contains("time")
1753 || lower_name.contains("at")
1754 || lower_name.contains("volume")
1755 || lower_name.contains("amount")
1756 || lower_name.contains("ev")
1757 || lower_name.contains("fee")
1758 || lower_name.contains("payout")
1759 || lower_name.contains("distributed")
1760 || lower_name.contains("claimable")
1761 || lower_name.contains("total")
1762 || lower_name.contains("rate")
1763 || lower_name.contains("ratio")
1764 || lower_name.contains("current")
1765 || lower_name.contains("state")
1766 {
1767 "number".to_string()
1768 } else if lower_name.contains("status")
1769 || lower_name.contains("hash")
1770 || lower_name.contains("address")
1771 || lower_name.contains("key")
1772 {
1773 "string".to_string()
1774 } else {
1775 "any".to_string()
1776 }
1777 }
1778
1779 fn is_field_nullable(&self, mapping: &TypedFieldMapping<S>) -> bool {
1780 match &mapping.source {
1783 MappingSource::Constant(_) => false,
1785 MappingSource::AsEvent { .. } => true,
1787 MappingSource::FromSource { .. } => true,
1789 _ => true,
1791 }
1792 }
1793
1794 fn base_type_to_typescript(
1796 &self,
1797 base_type: &BaseType,
1798 integer_kind: Option<IntegerKind>,
1799 is_array: bool,
1800 ) -> String {
1801 let base_ts_type = match base_type {
1802 BaseType::Integer => integer_kind
1803 .map(integer_kind_to_typescript)
1804 .unwrap_or("number"),
1805 BaseType::Float => "number",
1806 BaseType::String => "string",
1807 BaseType::Boolean => "boolean",
1808 BaseType::Timestamp => integer_kind
1809 .map(integer_kind_to_typescript)
1810 .unwrap_or("number"),
1811 BaseType::Binary => "string", BaseType::Pubkey => "string", BaseType::Array => "any[]", BaseType::Object => "Record<string, any>", BaseType::Any => "any",
1816 };
1817
1818 if is_array && !matches!(base_type, BaseType::Array) {
1819 format!("{}[]", base_ts_type)
1820 } else {
1821 base_ts_type.to_string()
1822 }
1823 }
1824}
1825
1826#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1827enum FieldPresence {
1828 Patch,
1829 Required,
1830}
1831
1832#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1833enum SchemaMode {
1834 Canonical,
1835 Patch,
1836}
1837
1838fn schema_constant_name(name: &str, mode: SchemaMode) -> String {
1839 match mode {
1840 SchemaMode::Canonical => format!("{}Schema", name),
1841 SchemaMode::Patch => format!("{}PatchSchema", name),
1842 }
1843}
1844
1845fn localize_section_field_names(
1846 section_name: &str,
1847 field_info: &FieldTypeInfo,
1848) -> (String, String) {
1849 let raw_name = field_info.raw_field_name();
1850 if is_root_section(section_name) {
1851 return (raw_name.to_string(), field_info.canonical_field_name());
1852 }
1853
1854 let prefix = format!("{}.", section_name);
1855 if let Some(local_raw_name) = raw_name.strip_prefix(&prefix) {
1856 return (local_raw_name.to_string(), to_camel_case(local_raw_name));
1857 }
1858
1859 (raw_name.to_string(), field_info.canonical_field_name())
1860}
1861
1862#[derive(Debug, Clone)]
1864struct TypeScriptField {
1865 name: String,
1866 raw_name: String,
1867 ts_type: String,
1868 nullable: bool,
1869 presence: FieldPresence,
1870 zod_schema: Option<String>,
1871 #[allow(dead_code)]
1872 description: Option<String>,
1873}
1874
1875impl TypeScriptField {
1876 fn patch(raw_name: String, ts_type: String, nullable: bool) -> Self {
1877 Self::from_names(
1878 raw_name.clone(),
1879 to_camel_case(&raw_name),
1880 ts_type,
1881 nullable,
1882 FieldPresence::Patch,
1883 )
1884 }
1885
1886 fn required_with_schema(
1887 raw_name: String,
1888 canonical_name: String,
1889 ts_type: String,
1890 nullable: bool,
1891 zod_schema: String,
1892 ) -> Self {
1893 let mut field = Self::from_names(
1894 raw_name,
1895 canonical_name,
1896 ts_type,
1897 nullable,
1898 FieldPresence::Required,
1899 );
1900 field.zod_schema = Some(zod_schema);
1901 field
1902 }
1903
1904 fn from_names(
1905 raw_name: String,
1906 canonical_name: String,
1907 ts_type: String,
1908 nullable: bool,
1909 presence: FieldPresence,
1910 ) -> Self {
1911 Self {
1912 name: canonical_name,
1913 raw_name,
1914 ts_type,
1915 nullable,
1916 presence,
1917 zod_schema: None,
1918 description: None,
1919 }
1920 }
1921
1922 fn rendered_ts_type(&self) -> String {
1923 if self.nullable {
1924 format!("{} | null", self.ts_type)
1925 } else {
1926 self.ts_type.clone()
1927 }
1928 }
1929}
1930
1931#[derive(Debug, Clone)]
1932struct SchemaOutput {
1933 definitions: String,
1934 names: Vec<String>,
1935}
1936
1937#[derive(Debug, Default)]
1938struct IdlAccountArtifacts {
1939 code: String,
1940 schema_names: Vec<String>,
1941 type_names: HashSet<String>,
1942 account_type_names: BTreeMap<(String, String), String>,
1943}
1944
1945fn value_to_typescript_type(value: &serde_json::Value) -> String {
1947 match value {
1948 serde_json::Value::Number(_) => "number".to_string(),
1949 serde_json::Value::String(_) => "string".to_string(),
1950 serde_json::Value::Bool(_) => "boolean".to_string(),
1951 serde_json::Value::Array(_) => "any[]".to_string(),
1952 serde_json::Value::Object(_) => "Record<string, any>".to_string(),
1953 serde_json::Value::Null => "null".to_string(),
1954 }
1955}
1956
1957fn extract_builtin_resolver_type_names(spec: &SerializableStreamSpec) -> HashSet<String> {
1958 let mut names = HashSet::new();
1959 let registry = crate::resolvers::builtin_resolver_registry();
1960 for resolver in registry.definitions() {
1961 let output_type = resolver.output_type();
1962 for section in &spec.sections {
1963 for field in §ion.fields {
1964 if field.inner_type.as_deref() == Some(output_type) {
1965 names.insert(output_type.to_string());
1966 }
1967 }
1968 }
1969 }
1970 names
1971}
1972
1973fn generate_idl_account_artifacts(
1974 idls: &[IdlSnapshot],
1975 reserved_type_names: &HashSet<String>,
1976) -> IdlAccountArtifacts {
1977 let mut used_type_names = reserved_type_names.clone();
1978 let mut emitted_type_names = HashSet::new();
1979 let mut seen_schema_names = HashSet::new();
1980 let mut interface_blocks = Vec::new();
1981 let mut schema_blocks = Vec::new();
1982 let mut schema_names = Vec::new();
1983 let mut account_type_names = BTreeMap::new();
1984
1985 for idl in idls {
1986 let program_key = to_camel_case(&idl.name);
1987 let program_prefix = to_pascal_case(&idl.name);
1988 let type_defs: BTreeMap<String, &IdlTypeDefSnapshot> = idl
1989 .types
1990 .iter()
1991 .map(|type_def| (type_def.name.clone(), type_def))
1992 .collect();
1993 let account_names: HashSet<String> = idl
1994 .accounts
1995 .iter()
1996 .map(|account| account.name.clone())
1997 .collect();
1998 let mut local_name_map = BTreeMap::new();
1999
2000 for account in &idl.accounts {
2001 let unique_name =
2002 unique_idl_type_name(&account.name, &program_prefix, &mut used_type_names);
2003 emitted_type_names.insert(unique_name.clone());
2004 local_name_map.insert(account.name.clone(), unique_name.clone());
2005 account_type_names.insert((program_key.clone(), account.name.clone()), unique_name);
2006 }
2007
2008 let mut required_defined_types = BTreeSet::new();
2009 for account in &idl.accounts {
2010 for field in resolve_idl_account_fields(account, &type_defs) {
2011 collect_required_defined_types(
2012 &field.type_,
2013 &type_defs,
2014 &account_names,
2015 &mut required_defined_types,
2016 );
2017 }
2018 }
2019
2020 for type_name in &required_defined_types {
2021 if local_name_map.contains_key(type_name) {
2022 continue;
2023 }
2024 let unique_name =
2025 unique_idl_type_name(type_name, &program_prefix, &mut used_type_names);
2026 emitted_type_names.insert(unique_name.clone());
2027 local_name_map.insert(type_name.clone(), unique_name);
2028 }
2029
2030 for type_name in &required_defined_types {
2031 if account_names.contains(type_name) {
2032 continue;
2033 }
2034 if let Some(type_def) = type_defs.get(type_name) {
2035 if let Some((interface_def, schema_name, schema_def)) =
2036 generate_type_defs_from_idl_type(type_def, &local_name_map)
2037 {
2038 interface_blocks.push(interface_def);
2039 if seen_schema_names.insert(schema_name.clone()) {
2040 schema_names.push(schema_name.clone());
2041 schema_blocks.push(schema_def);
2042 }
2043 }
2044 }
2045 }
2046
2047 for account in &idl.accounts {
2048 let Some(type_name) = local_name_map.get(&account.name) else {
2049 continue;
2050 };
2051 let account_fields = resolve_idl_account_fields(account, &type_defs);
2052 interface_blocks.push(generate_interface_from_idl_fields(
2053 type_name,
2054 account_fields,
2055 &local_name_map,
2056 ));
2057 let schema_name = format!("{}Schema", type_name);
2058 if seen_schema_names.insert(schema_name.clone()) {
2059 schema_names.push(schema_name.clone());
2060 schema_blocks.push(generate_schema_from_idl_fields(
2061 type_name,
2062 account_fields,
2063 &local_name_map,
2064 ));
2065 }
2066 }
2067 }
2068
2069 let code = if interface_blocks.is_empty() && schema_blocks.is_empty() {
2070 String::new()
2071 } else if schema_blocks.is_empty() {
2072 interface_blocks.join("\n\n")
2073 } else if interface_blocks.is_empty() {
2074 schema_blocks.join("\n\n")
2075 } else {
2076 format!(
2077 "{}\n\n{}",
2078 interface_blocks.join("\n\n"),
2079 schema_blocks.join("\n\n")
2080 )
2081 };
2082
2083 IdlAccountArtifacts {
2084 code,
2085 schema_names,
2086 type_names: emitted_type_names,
2087 account_type_names,
2088 }
2089}
2090
2091fn resolve_idl_account_fields<'a>(
2092 account: &'a IdlAccountSnapshot,
2093 type_defs: &'a BTreeMap<String, &'a IdlTypeDefSnapshot>,
2094) -> &'a [IdlFieldSnapshot] {
2095 if !account.fields.is_empty() {
2096 return &account.fields;
2097 }
2098
2099 let Some(type_def) = type_defs.get(&account.name) else {
2100 return &account.fields;
2101 };
2102
2103 match &type_def.type_def {
2104 IdlTypeDefKindSnapshot::Struct { fields, .. } => fields,
2105 _ => &account.fields,
2106 }
2107}
2108
2109fn unique_idl_type_name(
2110 raw_name: &str,
2111 program_prefix: &str,
2112 used_type_names: &mut HashSet<String>,
2113) -> String {
2114 let base_name = to_pascal_case(raw_name);
2115 if used_type_names.insert(base_name.clone()) {
2116 return base_name;
2117 }
2118
2119 let prefixed = format!("{}{}", program_prefix, base_name);
2120 if used_type_names.insert(prefixed.clone()) {
2121 return prefixed;
2122 }
2123
2124 let mut index = 2;
2125 loop {
2126 let candidate = format!("{}{}", prefixed, index);
2127 if used_type_names.insert(candidate.clone()) {
2128 return candidate;
2129 }
2130 index += 1;
2131 }
2132}
2133
2134fn collect_required_defined_types(
2135 idl_type: &IdlTypeSnapshot,
2136 type_defs: &BTreeMap<String, &IdlTypeDefSnapshot>,
2137 account_names: &HashSet<String>,
2138 output: &mut BTreeSet<String>,
2139) {
2140 match idl_type {
2141 IdlTypeSnapshot::Simple(_) => {}
2142 IdlTypeSnapshot::Array(array_type) => {
2143 for element in &array_type.array {
2144 if let IdlArrayElementSnapshot::Type(inner) = element {
2145 collect_required_defined_types(inner, type_defs, account_names, output);
2146 }
2147 }
2148 }
2149 IdlTypeSnapshot::Option(option_type) => {
2150 collect_required_defined_types(&option_type.option, type_defs, account_names, output);
2151 }
2152 IdlTypeSnapshot::Vec(vec_type) => {
2153 collect_required_defined_types(&vec_type.vec, type_defs, account_names, output);
2154 }
2155 IdlTypeSnapshot::HashMap(hash_map_type) => {
2156 collect_required_defined_types(
2157 &hash_map_type.hash_map.0,
2158 type_defs,
2159 account_names,
2160 output,
2161 );
2162 collect_required_defined_types(
2163 &hash_map_type.hash_map.1,
2164 type_defs,
2165 account_names,
2166 output,
2167 );
2168 }
2169 IdlTypeSnapshot::Defined(defined_type) => {
2170 let type_name = match &defined_type.defined {
2171 IdlDefinedInnerSnapshot::Named { name } => name,
2172 IdlDefinedInnerSnapshot::Simple(name) => name,
2173 };
2174
2175 if !output.insert(type_name.clone()) {
2176 return;
2177 }
2178
2179 if account_names.contains(type_name) {
2180 return;
2181 }
2182
2183 let Some(type_def) = type_defs.get(type_name) else {
2184 return;
2185 };
2186
2187 match &type_def.type_def {
2188 IdlTypeDefKindSnapshot::Struct { fields, .. } => {
2189 for field in fields {
2190 collect_required_defined_types(
2191 &field.type_,
2192 type_defs,
2193 account_names,
2194 output,
2195 );
2196 }
2197 }
2198 IdlTypeDefKindSnapshot::TupleStruct { fields, .. } => {
2199 for field in fields {
2200 collect_required_defined_types(field, type_defs, account_names, output);
2201 }
2202 }
2203 IdlTypeDefKindSnapshot::Enum { variants, .. } => {
2204 for variant in variants {
2205 for field in &variant.fields {
2206 match field {
2207 IdlEnumVariantFieldSnapshot::Named(named) => {
2208 collect_required_defined_types(
2209 &named.type_,
2210 type_defs,
2211 account_names,
2212 output,
2213 );
2214 }
2215 IdlEnumVariantFieldSnapshot::Tuple(tuple) => {
2216 collect_required_defined_types(
2217 tuple,
2218 type_defs,
2219 account_names,
2220 output,
2221 );
2222 }
2223 }
2224 }
2225 }
2226 }
2227 }
2228 }
2229 }
2230}
2231
2232fn generate_type_defs_from_idl_type(
2233 type_def: &IdlTypeDefSnapshot,
2234 local_name_map: &BTreeMap<String, String>,
2235) -> Option<(String, String, String)> {
2236 let type_name = local_name_map
2237 .get(&type_def.name)
2238 .cloned()
2239 .unwrap_or_else(|| to_pascal_case(&type_def.name));
2240 let schema_name = format!("{}Schema", type_name);
2241
2242 match &type_def.type_def {
2243 IdlTypeDefKindSnapshot::Struct { fields, .. } => Some((
2244 generate_interface_from_idl_fields(&type_name, fields, local_name_map),
2245 schema_name,
2246 generate_schema_from_idl_fields(&type_name, fields, local_name_map),
2247 )),
2248 IdlTypeDefKindSnapshot::TupleStruct { fields, .. } => {
2249 let interface = format!(
2250 "export type {} = [{}];",
2251 type_name,
2252 fields
2253 .iter()
2254 .map(|field| idl_snapshot_type_to_typescript(field, local_name_map))
2255 .collect::<Vec<_>>()
2256 .join(", ")
2257 );
2258 let schema = format!(
2259 "export const {} = z.tuple([{}]);",
2260 schema_name,
2261 fields
2262 .iter()
2263 .map(|field| idl_snapshot_type_to_zod(field, local_name_map))
2264 .collect::<Vec<_>>()
2265 .join(", ")
2266 );
2267 Some((interface, schema_name, schema))
2268 }
2269 IdlTypeDefKindSnapshot::Enum { variants, .. } => {
2270 let variant_names = variants
2271 .iter()
2272 .map(|variant| format!("\"{}\"", variant.name))
2273 .collect::<Vec<_>>();
2274 let interface = if variant_names.is_empty() {
2275 format!("export type {} = string;", type_name)
2276 } else {
2277 format!("export type {} = {};", type_name, variant_names.join(" | "))
2278 };
2279 let schema = if variant_names.is_empty() {
2280 format!("export const {} = z.string();", schema_name)
2281 } else {
2282 format!(
2283 "export const {} = z.enum([{}]);",
2284 schema_name,
2285 variant_names.join(", ")
2286 )
2287 };
2288 Some((interface, schema_name, schema))
2289 }
2290 }
2291}
2292
2293fn generate_interface_from_idl_fields(
2294 name: &str,
2295 fields: &[IdlFieldSnapshot],
2296 local_name_map: &BTreeMap<String, String>,
2297) -> String {
2298 render_interface_from_ts_fields(name, &normalize_idl_fields(fields, local_name_map), true)
2299}
2300
2301fn generate_schema_from_idl_fields(
2302 name: &str,
2303 fields: &[IdlFieldSnapshot],
2304 local_name_map: &BTreeMap<String, String>,
2305) -> String {
2306 render_schema_from_ts_fields(name, &normalize_idl_fields(fields, local_name_map), true)
2307}
2308
2309fn normalize_idl_fields(
2310 fields: &[IdlFieldSnapshot],
2311 local_name_map: &BTreeMap<String, String>,
2312) -> Vec<TypeScriptField> {
2313 let mut canonical_names = BTreeMap::new();
2314 let mut normalized = Vec::with_capacity(fields.len());
2315
2316 for field in fields {
2317 let canonical_name = to_camel_case(&field.name);
2318 if let Some(existing_source) =
2319 canonical_names.insert(canonical_name.clone(), field.name.clone())
2320 {
2321 assert_eq!(
2322 existing_source, field.name,
2323 "IDL field normalization collision: '{}' and '{}' both normalize to '{}'",
2324 existing_source, field.name, canonical_name
2325 );
2326 }
2327
2328 let (normalized_type, nullable) = strip_nullable_idl_type(&field.type_);
2329
2330 normalized.push(TypeScriptField::required_with_schema(
2331 idl_field_wire_name(&field.name),
2332 canonical_name,
2333 idl_snapshot_type_to_typescript(normalized_type, local_name_map),
2334 nullable,
2335 idl_snapshot_type_to_zod(normalized_type, local_name_map),
2336 ));
2337 }
2338
2339 normalized
2340}
2341
2342fn idl_field_wire_name(field_name: &str) -> String {
2343 idl_to_snake_case(field_name)
2344}
2345
2346fn idl_snapshot_type_to_typescript(
2347 idl_type: &IdlTypeSnapshot,
2348 local_name_map: &BTreeMap<String, String>,
2349) -> String {
2350 match idl_type {
2351 IdlTypeSnapshot::Simple(type_name) => match type_name.as_str() {
2352 "u64" | "u128" | "i64" | "i128" => "bigint".to_string(),
2353 "u8" | "u16" | "u32" | "i8" | "i16" | "i32" | "f32" | "f64" => "number".to_string(),
2354 "bool" => "boolean".to_string(),
2355 "string" => "string".to_string(),
2356 "pubkey" | "publicKey" => "string".to_string(),
2357 "bytes" => "number[]".to_string(),
2358 _ => "any".to_string(),
2359 },
2360 IdlTypeSnapshot::Array(array_type) => {
2361 let (inner_ts, size) = match array_type.array.as_slice() {
2362 [IdlArrayElementSnapshot::Type(inner), IdlArrayElementSnapshot::Size(size)] => (
2363 Some(idl_snapshot_type_to_typescript(inner, local_name_map)),
2364 Some(*size),
2365 ),
2366 [IdlArrayElementSnapshot::TypeName(inner), IdlArrayElementSnapshot::Size(size)] => {
2367 (
2368 Some(idl_snapshot_type_to_typescript(
2369 &IdlTypeSnapshot::Simple(inner.clone()),
2370 local_name_map,
2371 )),
2372 Some(*size),
2373 )
2374 }
2375 _ => (None, None),
2376 };
2377 let inner_ts = inner_ts.unwrap_or_else(|| "any".to_string());
2378 if let Some(size) = size {
2379 format!(
2380 "{}[]",
2381 if size == 0 {
2382 "never".to_string()
2383 } else {
2384 inner_ts
2385 }
2386 )
2387 } else {
2388 format!("{}[]", inner_ts)
2389 }
2390 }
2391 IdlTypeSnapshot::Option(option_type) => {
2392 format!(
2393 "{} | null",
2394 idl_snapshot_type_to_typescript(&option_type.option, local_name_map)
2395 )
2396 }
2397 IdlTypeSnapshot::Vec(vec_type) => {
2398 format!(
2399 "{}[]",
2400 idl_snapshot_type_to_typescript(&vec_type.vec, local_name_map)
2401 )
2402 }
2403 IdlTypeSnapshot::HashMap(hash_map_type) => {
2404 format!(
2405 "Record<string, {}>",
2406 idl_snapshot_type_to_typescript(&hash_map_type.hash_map.1, local_name_map)
2407 )
2408 }
2409 IdlTypeSnapshot::Defined(defined_type) => {
2410 let type_name = match &defined_type.defined {
2411 IdlDefinedInnerSnapshot::Named { name } => name,
2412 IdlDefinedInnerSnapshot::Simple(name) => name,
2413 };
2414 local_name_map
2415 .get(type_name)
2416 .cloned()
2417 .unwrap_or_else(|| to_pascal_case(type_name))
2418 }
2419 }
2420}
2421
2422fn idl_snapshot_type_to_zod(
2423 idl_type: &IdlTypeSnapshot,
2424 local_name_map: &BTreeMap<String, String>,
2425) -> String {
2426 match idl_type {
2427 IdlTypeSnapshot::Simple(type_name) => match type_name.as_str() {
2428 "u64" | "u128" | "i64" | "i128" => bigint_zod(),
2429 "u8" | "u16" | "u32" | "i8" | "i16" | "i32" | "f32" | "f64" => "z.number()".to_string(),
2430 "bool" => "z.boolean()".to_string(),
2431 "string" => "z.string()".to_string(),
2432 "pubkey" | "publicKey" => "z.string()".to_string(),
2433 "bytes" => "z.array(z.number())".to_string(),
2434 _ => "z.any()".to_string(),
2435 },
2436 IdlTypeSnapshot::Array(array_type) => match array_type.array.as_slice() {
2437 [IdlArrayElementSnapshot::Type(inner), IdlArrayElementSnapshot::Size(size)] => {
2438 format!(
2439 "z.array({}).length({})",
2440 idl_snapshot_type_to_zod(inner, local_name_map),
2441 size
2442 )
2443 }
2444 [IdlArrayElementSnapshot::TypeName(inner), IdlArrayElementSnapshot::Size(size)] => {
2445 format!(
2446 "z.array({}).length({})",
2447 idl_snapshot_type_to_zod(
2448 &IdlTypeSnapshot::Simple(inner.clone()),
2449 local_name_map
2450 ),
2451 size
2452 )
2453 }
2454 _ => "z.array(z.any())".to_string(),
2455 },
2456 IdlTypeSnapshot::Option(option_type) => {
2457 format!(
2458 "{}.nullable()",
2459 idl_snapshot_type_to_zod(&option_type.option, local_name_map)
2460 )
2461 }
2462 IdlTypeSnapshot::Vec(vec_type) => {
2463 format!(
2464 "z.array({})",
2465 idl_snapshot_type_to_zod(&vec_type.vec, local_name_map)
2466 )
2467 }
2468 IdlTypeSnapshot::HashMap(hash_map_type) => {
2469 format!(
2470 "z.record({})",
2471 idl_snapshot_type_to_zod(&hash_map_type.hash_map.1, local_name_map)
2472 )
2473 }
2474 IdlTypeSnapshot::Defined(defined_type) => {
2475 let type_name = match &defined_type.defined {
2476 IdlDefinedInnerSnapshot::Named { name } => name,
2477 IdlDefinedInnerSnapshot::Simple(name) => name,
2478 };
2479 let resolved_name = local_name_map
2480 .get(type_name)
2481 .cloned()
2482 .unwrap_or_else(|| to_pascal_case(type_name));
2483 format!("z.lazy(() => {}Schema)", resolved_name)
2484 }
2485 }
2486}
2487
2488fn typescript_integer_type_from_rust(rust_type: &str) -> Option<&'static str> {
2489 IntegerKind::from_rust_type(rust_type).map(integer_kind_to_typescript)
2490}
2491
2492fn typescript_integer_type(
2493 integer_kind: Option<IntegerKind>,
2494 rust_type: Option<&str>,
2495) -> Option<&'static str> {
2496 integer_kind
2497 .map(integer_kind_to_typescript)
2498 .or_else(|| rust_type.and_then(typescript_integer_type_from_rust))
2499}
2500
2501fn integer_kind_to_typescript(integer_kind: IntegerKind) -> &'static str {
2502 if integer_kind.is_bigint() {
2503 "bigint"
2504 } else {
2505 "number"
2506 }
2507}
2508
2509fn strip_nullable_idl_type(mut idl_type: &IdlTypeSnapshot) -> (&IdlTypeSnapshot, bool) {
2510 let mut nullable = false;
2511 while let IdlTypeSnapshot::Option(option_type) = idl_type {
2512 nullable = true;
2513 idl_type = &option_type.option;
2514 }
2515 (idl_type, nullable)
2516}
2517
2518fn typescript_type_to_zod_static(ts_type: &str) -> String {
2519 let trimmed = ts_type.trim();
2520
2521 if let Some(inner) = trimmed.strip_suffix("[]") {
2522 return format!("z.array({})", typescript_type_to_zod_static(inner));
2523 }
2524
2525 if let Some(inner) = trimmed.strip_prefix("EventWrapper<") {
2526 if let Some(inner) = inner.strip_suffix('>') {
2527 return format!(
2528 "EventWrapperSchema({})",
2529 typescript_type_to_zod_static(inner)
2530 );
2531 }
2532 }
2533
2534 match trimmed {
2535 "string" => "z.string()".to_string(),
2536 "number" => "z.number()".to_string(),
2537 "bigint" => bigint_zod(),
2538 "boolean" => "z.boolean()".to_string(),
2539 "any" => "z.any()".to_string(),
2540 "Record<string, any>" => "z.record(z.any())".to_string(),
2541 _ => format!("{}Schema", trimmed),
2542 }
2543}
2544
2545fn typescript_type_to_zod_for_schema_static(
2546 ts_type: &str,
2547 mode: SchemaMode,
2548 patch_schema_types: &HashSet<String>,
2549) -> String {
2550 let trimmed = ts_type.trim();
2551
2552 if let Some(inner) = trimmed.strip_suffix("[]") {
2553 return format!(
2554 "z.array({})",
2555 typescript_type_to_zod_for_schema_static(inner, mode, patch_schema_types)
2556 );
2557 }
2558
2559 if let Some(inner) = trimmed.strip_prefix("EventWrapper<") {
2560 if let Some(inner) = inner.strip_suffix('>') {
2561 return format!(
2562 "EventWrapperSchema({})",
2563 typescript_type_to_zod_for_schema_static(inner, mode, patch_schema_types)
2564 );
2565 }
2566 }
2567
2568 match trimmed {
2569 "string" => "z.string()".to_string(),
2570 "number" => "z.number()".to_string(),
2571 "bigint" => bigint_zod(),
2572 "boolean" => "z.boolean()".to_string(),
2573 "any" => "z.any()".to_string(),
2574 "Record<string, any>" => "z.record(z.any())".to_string(),
2575 _ => {
2576 if mode == SchemaMode::Patch && patch_schema_types.contains(trimmed) {
2577 format!("{}PatchSchema", trimmed)
2578 } else {
2579 format!("{}Schema", trimmed)
2580 }
2581 }
2582 }
2583}
2584
2585fn render_interface_from_ts_fields(
2586 name: &str,
2587 fields: &[TypeScriptField],
2588 force_required: bool,
2589) -> String {
2590 if fields.is_empty() {
2591 return format!("export interface {} {{\n}}", name);
2592 }
2593
2594 let field_definitions = fields
2595 .iter()
2596 .map(|field| {
2597 let optional = if force_required || matches!(field.presence, FieldPresence::Required) {
2598 ""
2599 } else {
2600 "?"
2601 };
2602 format!(
2603 " {}{}: {};",
2604 field.name,
2605 optional,
2606 field.rendered_ts_type()
2607 )
2608 })
2609 .collect::<Vec<_>>();
2610
2611 format!(
2612 "export interface {} {{\n{}\n}}",
2613 name,
2614 field_definitions.join("\n")
2615 )
2616}
2617
2618fn render_schema_from_ts_fields(
2619 name: &str,
2620 fields: &[TypeScriptField],
2621 force_required: bool,
2622) -> String {
2623 if fields.is_empty() {
2624 return format!("export const {}Schema = z.object({{}});", name);
2625 }
2626
2627 let field_definitions = fields
2628 .iter()
2629 .map(|field| {
2630 let base_schema = field
2631 .zod_schema
2632 .clone()
2633 .unwrap_or_else(|| typescript_type_to_zod_static(&field.ts_type));
2634 let with_nullable = if field.nullable {
2635 format!("{}.nullable()", base_schema)
2636 } else {
2637 base_schema
2638 };
2639 let schema = if force_required || matches!(field.presence, FieldPresence::Required) {
2640 with_nullable
2641 } else {
2642 format!("{}.optional()", with_nullable)
2643 };
2644 format!(" {}: {},", field.raw_name, schema)
2645 })
2646 .collect::<Vec<_>>();
2647
2648 let transform_fields = fields
2649 .iter()
2650 .map(|field| format!(" {}: value.{},", field.name, field.raw_name))
2651 .collect::<Vec<_>>();
2652
2653 format!(
2654 "export const {}Schema = z.object({{\n{}\n}}).transform((value) => ({{\n{}\n}}));",
2655 name,
2656 field_definitions.join("\n"),
2657 transform_fields.join("\n")
2658 )
2659}
2660
2661fn bigint_zod() -> String {
2662 "z.union([z.bigint(), z.string(), z.number().int()]).transform((value) => BigInt(value))"
2663 .to_string()
2664}
2665
2666fn extract_idl_enum_type_names(idl: &serde_json::Value) -> HashSet<String> {
2667 let mut names = HashSet::new();
2668 if let Some(types_array) = idl.get("types").and_then(|v| v.as_array()) {
2669 for type_def in types_array {
2670 if let (Some(type_name), Some(type_obj)) = (
2671 type_def.get("name").and_then(|v| v.as_str()),
2672 type_def.get("type").and_then(|v| v.as_object()),
2673 ) {
2674 if type_obj.get("kind").and_then(|v| v.as_str()) == Some("enum") {
2675 names.insert(to_pascal_case(type_name));
2676 }
2677 }
2678 }
2679 }
2680 names
2681}
2682
2683fn extract_emitted_enum_type_names(interfaces: &str, idl: Option<&IdlSnapshot>) -> HashSet<String> {
2686 let mut names = HashSet::new();
2687
2688 let idl_enum_names: HashSet<String> = idl
2690 .and_then(|idl| serde_json::to_value(idl).ok())
2691 .map(|v| extract_idl_enum_type_names(&v))
2692 .unwrap_or_default();
2693
2694 for line in interfaces.lines() {
2697 if let Some(start) = line.find("export const ") {
2698 let end = line
2699 .find("Schema = z.enum")
2700 .or_else(|| line.find("Schema = z.string()"));
2701 if let Some(end) = end {
2702 let schema_name = line[start + 13..end].trim();
2703 if idl_enum_names.contains(schema_name) {
2705 names.insert(schema_name.to_string());
2706 }
2707 }
2708 }
2709 }
2710
2711 names
2712}
2713
2714fn unique_resolved_type_name_ts(
2715 resolved: &ResolvedStructType,
2716 reserved_names: &mut HashSet<String>,
2717) -> String {
2718 let base_name = to_pascal_case(&resolved.type_name);
2719 if reserved_names.insert(base_name.clone()) {
2720 return base_name;
2721 }
2722
2723 let suffix = if resolved.is_account {
2724 "Account"
2725 } else if resolved.is_event {
2726 "Event"
2727 } else if resolved.is_instruction {
2728 "Instruction"
2729 } else {
2730 "Type"
2731 };
2732
2733 let preferred = format!("{}{}", base_name, suffix);
2734 if reserved_names.insert(preferred.clone()) {
2735 return preferred;
2736 }
2737
2738 let mut index = 2;
2739 loop {
2740 let candidate = format!("{}{}{}", base_name, suffix, index);
2741 if reserved_names.insert(candidate.clone()) {
2742 return candidate;
2743 }
2744 index += 1;
2745 }
2746}
2747
2748pub(crate) fn to_pascal_case(s: &str) -> String {
2750 s.split(['_', '-', '.'])
2751 .map(|word| {
2752 let mut chars = word.chars();
2753 match chars.next() {
2754 None => String::new(),
2755 Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
2756 }
2757 })
2758 .collect()
2759}
2760
2761fn to_camel_case(s: &str) -> String {
2762 let pascal = to_pascal_case(s);
2763 let mut chars = pascal.chars();
2764 match chars.next() {
2765 Some(first) => first.to_lowercase().collect::<String>() + chars.as_str(),
2766 None => pascal,
2767 }
2768}
2769
2770fn normalize_for_comparison(s: &str) -> String {
2773 s.chars()
2774 .filter(|c| *c != '_')
2775 .flat_map(|c| c.to_lowercase())
2776 .collect()
2777}
2778
2779fn is_root_section(name: &str) -> bool {
2780 name.eq_ignore_ascii_case("root")
2781}
2782
2783fn is_builtin_resolver_type(type_name: &str) -> bool {
2784 crate::resolvers::is_resolver_output_type(type_name)
2785}
2786
2787fn to_kebab_case(s: &str) -> String {
2789 let mut result = String::new();
2790
2791 for ch in s.chars() {
2792 if ch.is_uppercase() && !result.is_empty() {
2793 result.push('-');
2794 }
2795 result.push(ch.to_lowercase().next().unwrap());
2796 }
2797
2798 result
2799}
2800
2801pub fn generate_typescript_from_spec_fn<F, S>(
2804 spec_fn: F,
2805 entity_name: String,
2806 config: Option<TypeScriptConfig>,
2807) -> Result<TypeScriptOutput, String>
2808where
2809 F: Fn() -> TypedStreamSpec<S>,
2810{
2811 let spec = spec_fn();
2812 let compiler =
2813 TypeScriptCompiler::new(spec, entity_name).with_config(config.unwrap_or_default());
2814
2815 Ok(compiler.compile())
2816}
2817
2818pub fn write_typescript_to_file(
2820 output: &TypeScriptOutput,
2821 path: &std::path::Path,
2822) -> Result<(), std::io::Error> {
2823 std::fs::write(path, output.full_file())
2824}
2825
2826pub fn compile_serializable_spec(
2829 spec: SerializableStreamSpec,
2830 entity_name: String,
2831 config: Option<TypeScriptConfig>,
2832) -> Result<TypeScriptOutput, String> {
2833 compile_serializable_spec_with_emitted(spec, entity_name, config, HashSet::new())
2834}
2835
2836fn compile_serializable_spec_with_emitted(
2837 spec: SerializableStreamSpec,
2838 entity_name: String,
2839 config: Option<TypeScriptConfig>,
2840 already_emitted_types: HashSet<String>,
2841) -> Result<TypeScriptOutput, String> {
2842 let idl = spec
2843 .idl
2844 .as_ref()
2845 .and_then(|idl_snapshot| serde_json::to_value(idl_snapshot).ok());
2846
2847 let handlers = serde_json::to_value(&spec.handlers).ok();
2848 let views = spec.views.clone();
2849
2850 let typed_spec: TypedStreamSpec<()> = TypedStreamSpec::from_serializable(spec);
2851
2852 let compiler = TypeScriptCompiler::new(typed_spec, entity_name)
2853 .with_idl(idl)
2854 .with_handlers_json(handlers)
2855 .with_views(views)
2856 .with_config(config.unwrap_or_default())
2857 .with_already_emitted_types(already_emitted_types);
2858
2859 Ok(compiler.compile())
2860}
2861
2862#[derive(Debug, Clone)]
2863pub struct TypeScriptStackConfig {
2864 pub package_name: String,
2865 pub generate_helpers: bool,
2866 pub export_const_name: String,
2867 pub url: Option<String>,
2868 pub extension_import: Option<String>,
2869}
2870
2871impl Default for TypeScriptStackConfig {
2872 fn default() -> Self {
2873 Self {
2874 package_name: "@usearete/react".to_string(),
2875 generate_helpers: true,
2876 export_const_name: "STACK".to_string(),
2877 url: None,
2878 extension_import: None,
2879 }
2880 }
2881}
2882
2883#[derive(Debug, Clone)]
2884pub struct TypeScriptStackOutput {
2885 pub interfaces: String,
2886 pub stack_definition: String,
2887 pub imports: String,
2888 pub warnings: Vec<String>,
2891 pub pda_degradations: Vec<crate::typescript_instructions::PdaDegradation>,
2893}
2894
2895impl TypeScriptStackOutput {
2896 pub fn full_file(&self) -> String {
2897 let mut parts = Vec::new();
2898 if !self.imports.is_empty() {
2899 parts.push(self.imports.as_str());
2900 }
2901 if !self.interfaces.is_empty() {
2902 parts.push(self.interfaces.as_str());
2903 }
2904 if !self.stack_definition.is_empty() {
2905 parts.push(self.stack_definition.as_str());
2906 }
2907 parts.join("\n\n")
2908 }
2909}
2910
2911pub fn compile_stack_spec(
2918 stack_spec: SerializableStackSpec,
2919 config: Option<TypeScriptStackConfig>,
2920) -> Result<TypeScriptStackOutput, String> {
2921 let config = config.unwrap_or_default();
2922 let stack_name = &stack_spec.stack_name;
2923 let stack_kebab = to_kebab_case(stack_name);
2924
2925 let mut all_interfaces = Vec::new();
2927 let mut entity_names = Vec::new();
2928 let mut schema_names: Vec<String> = Vec::new();
2929 let mut emitted_types: HashSet<String> = HashSet::new();
2930
2931 for entity_spec in &stack_spec.entities {
2932 let mut spec = entity_spec.clone();
2933 if spec.idl.is_none() {
2935 spec.idl = stack_spec.idls.first().cloned();
2936 }
2937 let entity_name = spec.state_name.clone();
2938 entity_names.push(entity_name.clone());
2939
2940 let per_entity_config = TypeScriptConfig {
2941 package_name: config.package_name.clone(),
2942 generate_helpers: false,
2943 interface_prefix: String::new(),
2944 export_const_name: config.export_const_name.clone(),
2945 url: config.url.clone(),
2946 };
2947
2948 let builtin_type_names = extract_builtin_resolver_type_names(&spec);
2950 let idl_for_check = spec.idl.clone();
2952
2953 let output = compile_serializable_spec_with_emitted(
2954 spec,
2955 entity_name,
2956 Some(per_entity_config),
2957 emitted_types.clone(),
2958 )?;
2959
2960 let emitted_enum_names =
2963 extract_emitted_enum_type_names(&output.interfaces, idl_for_check.as_ref());
2964 emitted_types.extend(emitted_enum_names);
2965 emitted_types.extend(builtin_type_names);
2966
2967 if !output.interfaces.is_empty() {
2969 all_interfaces.push(output.interfaces);
2970 }
2971
2972 schema_names.extend(output.schema_names);
2973 }
2974
2975 let mut interfaces = all_interfaces.join("\n\n");
2976
2977 let mut reserved_type_names: std::collections::HashSet<String> =
2982 std::collections::HashSet::new();
2983 for line in interfaces.lines() {
2984 for prefix in ["export interface ", "export type "] {
2985 if let Some(rest) = line.strip_prefix(prefix) {
2986 let name: String = rest
2987 .chars()
2988 .take_while(|c| c.is_alphanumeric() || *c == '_')
2989 .collect();
2990 if !name.is_empty() {
2991 reserved_type_names.insert(name);
2992 }
2993 }
2994 }
2995 }
2996 let idl_account_artifacts =
2997 generate_idl_account_artifacts(&stack_spec.idls, &reserved_type_names);
2998 for type_name in &idl_account_artifacts.type_names {
2999 reserved_type_names.insert(type_name.clone());
3000 }
3001 if !idl_account_artifacts.code.is_empty() {
3002 if interfaces.is_empty() {
3003 interfaces = idl_account_artifacts.code.clone();
3004 } else {
3005 interfaces = format!("{}\n\n{}", interfaces, idl_account_artifacts.code);
3006 }
3007 }
3008 schema_names.extend(idl_account_artifacts.schema_names.clone());
3009
3010 let instructions_codegen = crate::typescript_instructions::generate_instructions_code(
3011 stack_name,
3012 &stack_spec.instructions,
3013 &stack_spec.idls,
3014 &stack_spec.pdas,
3015 &stack_spec.program_ids,
3016 &reserved_type_names,
3017 );
3018 if !instructions_codegen.code.is_empty() {
3019 if interfaces.is_empty() {
3020 interfaces = instructions_codegen.code.clone();
3021 } else {
3022 interfaces = format!("{}\n\n{}", interfaces, instructions_codegen.code);
3023 }
3024 }
3025
3026 let stack_definition = generate_stack_definition_multi(
3028 stack_name,
3029 &stack_kebab,
3030 &stack_spec.entities,
3031 &entity_names,
3032 &stack_spec.idls,
3033 &stack_spec.pdas,
3034 &stack_spec.program_ids,
3035 &schema_names,
3036 &idl_account_artifacts.account_type_names,
3037 &instructions_codegen.stack_entries,
3038 &config,
3039 );
3040
3041 let imports = assemble_sdk_imports(
3043 stack_spec.pdas.values().any(|p| !p.is_empty()),
3044 !idl_account_artifacts.account_type_names.is_empty(),
3045 &instructions_codegen,
3046 );
3047
3048 Ok(TypeScriptStackOutput {
3049 imports,
3050 interfaces,
3051 stack_definition,
3052 warnings: instructions_codegen.warnings,
3053 pda_degradations: instructions_codegen.pda_degradations,
3054 })
3055}
3056
3057fn assemble_sdk_imports(
3060 has_pdas: bool,
3061 has_account_reads: bool,
3062 instructions_codegen: &crate::typescript_instructions::InstructionsCodegen,
3063) -> String {
3064 let mut sdk_named: Vec<String> = Vec::new();
3065 if has_pdas {
3066 for helper in ["pda", "literal", "account", "arg", "bytes"] {
3067 sdk_named.push(helper.to_string());
3068 }
3069 }
3070 if has_account_reads {
3071 sdk_named.push("programAccountRead".to_string());
3072 }
3073 if instructions_codegen.needs_runtime_import {
3074 sdk_named.push("createInstructionHandler".to_string());
3075 sdk_named.push("type ErrorMetadata".to_string());
3076 }
3077 if !instructions_codegen.stack_entries.is_empty() {
3078 sdk_named.push("buildInstruction".to_string());
3079 sdk_named.push("type BuildOptions".to_string());
3080 }
3081 if instructions_codegen.needs_program_runtime_extensions {
3082 sdk_named.push("PROGRAM_OPERATION_EXTENSIONS".to_string());
3083 sdk_named.push("type ProgramOperationContext".to_string());
3084 sdk_named.push("instructionOperation".to_string());
3085 sdk_named.push("createPreparedInstruction".to_string());
3086 }
3087 if instructions_codegen.needs_amount_input {
3088 sdk_named.push("type AmountInput".to_string());
3089 }
3090 if instructions_codegen.needs_resolve_amount_to_raw {
3091 sdk_named.push("resolveAmountToRaw".to_string());
3092 }
3093 if instructions_codegen.needs_to_raw_amount {
3094 sdk_named.push("toRawAmount".to_string());
3095 }
3096 if sdk_named.is_empty() {
3097 "import { z } from 'zod';".to_string()
3098 } else {
3099 format!(
3100 "import {{ z }} from 'zod';\nimport {{ {} }} from '@usearete/sdk';",
3101 sdk_named.join(", ")
3102 )
3103 }
3104}
3105
3106pub fn compile_program_modules(
3111 stack_spec: SerializableStackSpec,
3112 config: Option<TypeScriptStackConfig>,
3113) -> Result<TypeScriptStackOutput, String> {
3114 let _config = config.unwrap_or_default();
3115 let stack_name = &stack_spec.stack_name;
3116
3117 if stack_spec.idls.is_empty() {
3118 return Err(format!(
3119 "Stack '{}' carries no IDLs; a program-only SDK has nothing to emit",
3120 stack_name
3121 ));
3122 }
3123
3124 let mut reserved_type_names: std::collections::HashSet<String> =
3125 std::collections::HashSet::new();
3126 let idl_account_artifacts =
3127 generate_idl_account_artifacts(&stack_spec.idls, &reserved_type_names);
3128 for type_name in &idl_account_artifacts.type_names {
3129 reserved_type_names.insert(type_name.clone());
3130 }
3131
3132 let mut interfaces = idl_account_artifacts.code.clone();
3133
3134 let instructions_codegen = crate::typescript_instructions::generate_instructions_code(
3135 stack_name,
3136 &stack_spec.instructions,
3137 &stack_spec.idls,
3138 &stack_spec.pdas,
3139 &stack_spec.program_ids,
3140 &reserved_type_names,
3141 );
3142 if !instructions_codegen.code.is_empty() {
3143 if interfaces.is_empty() {
3144 interfaces = instructions_codegen.code.clone();
3145 } else {
3146 interfaces = format!("{}\n\n{}", interfaces, instructions_codegen.code);
3147 }
3148 }
3149
3150 let unique_schemas: BTreeSet<String> =
3151 idl_account_artifacts.schema_names.iter().cloned().collect();
3152
3153 let stack_definition = generate_program_definitions(
3154 stack_name,
3155 &stack_spec.idls,
3156 &stack_spec.pdas,
3157 &stack_spec.program_ids,
3158 &instructions_codegen.stack_entries,
3159 &unique_schemas,
3160 &idl_account_artifacts.account_type_names,
3161 );
3162
3163 let imports = assemble_sdk_imports(
3164 stack_spec.pdas.values().any(|p| !p.is_empty()),
3165 !idl_account_artifacts.account_type_names.is_empty(),
3166 &instructions_codegen,
3167 );
3168
3169 Ok(TypeScriptStackOutput {
3170 imports,
3171 interfaces,
3172 stack_definition,
3173 warnings: instructions_codegen.warnings,
3174 pda_degradations: instructions_codegen.pda_degradations,
3175 })
3176}
3177
3178pub fn write_stack_typescript_to_file(
3180 output: &TypeScriptStackOutput,
3181 path: &std::path::Path,
3182) -> Result<(), std::io::Error> {
3183 std::fs::write(path, output.full_file())
3184}
3185
3186#[allow(clippy::too_many_arguments)]
3210fn generate_stack_definition_multi(
3211 stack_name: &str,
3212 stack_kebab: &str,
3213 entities: &[SerializableStreamSpec],
3214 entity_names: &[String],
3215 idls: &[IdlSnapshot],
3216 pdas: &BTreeMap<String, BTreeMap<String, PdaDefinition>>,
3217 program_ids: &[String],
3218 schema_names: &[String],
3219 account_type_names: &BTreeMap<(String, String), String>,
3220 instruction_entries: &[crate::typescript_instructions::StackInstructionEntry],
3221 config: &TypeScriptStackConfig,
3222) -> String {
3223 let export_name = format!(
3224 "{}_{}",
3225 to_screaming_snake_case(stack_name),
3226 config.export_const_name
3227 );
3228 let core_export_name = format!("{}_CORE", export_name);
3229
3230 let view_helpers = generate_view_helpers_static();
3231
3232 let endpoints_block = match &config.url {
3233 Some(url) => format!(
3234 " endpoints: {{\n ws: '{}',\n http: '{}',\n }},",
3235 url,
3236 derive_http_url(url)
3237 ),
3238 None => " endpoints: {\n ws: '', // TODO: Set after first deployment or pass useArete(..., { url })\n http: '', // TODO: Set after first deployment or pass useArete(..., { httpUrl })\n },"
3239 .to_string(),
3240 };
3241
3242 let mut entity_view_blocks = Vec::new();
3244 for (i, entity_spec) in entities.iter().enumerate() {
3245 let entity_name = &entity_names[i];
3246 let entity_pascal = to_pascal_case(entity_name);
3247
3248 let mut view_entries = Vec::new();
3249
3250 view_entries.push(format!(
3251 " state: stateView<{entity}>('{entity_name}/state'),",
3252 entity = entity_pascal,
3253 entity_name = entity_name
3254 ));
3255
3256 view_entries.push(format!(
3257 " list: listView<{entity}>('{entity_name}/list'),",
3258 entity = entity_pascal,
3259 entity_name = entity_name
3260 ));
3261
3262 for view in &entity_spec.views {
3263 if !view.id.ends_with("/state")
3264 && !view.id.ends_with("/list")
3265 && view.id.starts_with(entity_name)
3266 {
3267 let view_name = view.id.split('/').nth(1).unwrap_or("unknown");
3268 view_entries.push(format!(
3269 " {}: listView<{entity}>('{}'),",
3270 view_name,
3271 view.id,
3272 entity = entity_pascal
3273 ));
3274 }
3275 }
3276
3277 entity_view_blocks.push(format!(
3278 " {}: {{\n{}\n }},",
3279 entity_name,
3280 view_entries.join("\n")
3281 ));
3282 }
3283
3284 let views_body = entity_view_blocks.join("\n");
3285
3286 let mut unique_schemas: BTreeSet<String> = BTreeSet::new();
3287 for name in schema_names {
3288 unique_schemas.insert(name.clone());
3289 }
3290 let schemas_block = if unique_schemas.is_empty() {
3291 String::new()
3292 } else {
3293 let schema_entries: Vec<String> = unique_schemas
3294 .iter()
3295 .filter(|name| name.ends_with("Schema") && !name.ends_with("PatchSchema"))
3296 .map(|name| format!(" {}: {},", name.trim_end_matches("Schema"), name))
3297 .collect();
3298 if schema_entries.is_empty() {
3299 String::new()
3300 } else {
3301 format!("\n schemas: {{\n{}\n }},", schema_entries.join("\n"))
3302 }
3303 };
3304 let patch_schema_entries: Vec<String> = entity_names
3305 .iter()
3306 .map(|entity_name| {
3307 let entity_pascal = to_pascal_case(entity_name);
3308 format!(" {}: {}PatchSchema,", entity_pascal, entity_pascal)
3309 })
3310 .collect();
3311 let patch_schemas_block = if patch_schema_entries.is_empty() {
3312 String::new()
3313 } else {
3314 format!(
3315 "\n patchSchemas: {{\n{}\n }},",
3316 patch_schema_entries.join("\n")
3317 )
3318 };
3319
3320 let programs_block = generate_programs_block(
3321 idls,
3322 pdas,
3323 program_ids,
3324 instruction_entries,
3325 &unique_schemas,
3326 account_type_names,
3327 );
3328 let addresses_block = generate_stack_addresses_block(idls, pdas, program_ids);
3329
3330 let entity_types: Vec<String> = entity_names.iter().map(|n| to_pascal_case(n)).collect();
3331
3332 let stack_export = format!(
3333 r#"export const {core_export_name} = {{
3334 name: '{stack_kebab}',
3335{endpoints_block}
3336 views: {{
3337{views_body}
3338 }},{schemas_section}{patch_schemas_section}{programs_section}{addresses_section}
3339}} as const;"#,
3340 core_export_name = core_export_name,
3341 stack_kebab = stack_kebab,
3342 endpoints_block = endpoints_block,
3343 views_body = views_body,
3344 schemas_section = schemas_block,
3345 patch_schemas_section = patch_schemas_block,
3346 programs_section = programs_block,
3347 addresses_section = addresses_block,
3348 );
3349
3350 format!(
3351 r#"{view_helpers}
3352
3353// ============================================================================
3354// Stack Definition
3355// ============================================================================
3356
3357/** Stack definition for {stack_name} with {entity_count} entities */
3358{stack_export}
3359
3360/** Type alias for the core stack */
3361export type {stack_name}CoreStack = typeof {core_export_name};
3362
3363/** Entity types in this stack */
3364export type {stack_name}Entity = {entity_union};
3365
3366/** Default export for convenience */
3367export default {core_export_name};"#,
3368 view_helpers = view_helpers,
3369 stack_name = stack_name,
3370 entity_count = entities.len(),
3371 core_export_name = core_export_name,
3372 stack_export = stack_export,
3373 entity_union = entity_types.join(" | "),
3374 )
3375}
3376
3377fn generate_single_program_sections(
3382 idl: &IdlSnapshot,
3383 index: usize,
3384 pdas: &BTreeMap<String, BTreeMap<String, PdaDefinition>>,
3385 program_ids: &[String],
3386 instruction_entries: &[crate::typescript_instructions::StackInstructionEntry],
3387 schema_names: &BTreeSet<String>,
3388 account_type_names: &BTreeMap<(String, String), String>,
3389) -> (String, Vec<String>) {
3390 let program_key = to_camel_case(&idl.name);
3391 let multi_program = program_ids.len() > 1
3392 || instruction_entries
3393 .iter()
3394 .any(|entry| entry.program_key.is_some());
3395 let program_id = program_ids
3396 .get(index)
3397 .cloned()
3398 .or_else(|| idl.program_id.clone())
3399 .unwrap_or_default();
3400
3401 let instruction_entries_for_program: Vec<
3402 &crate::typescript_instructions::StackInstructionEntry,
3403 > = instruction_entries
3404 .iter()
3405 .filter(|entry| {
3406 if multi_program {
3407 entry.program_key.as_deref() == Some(program_key.as_str())
3408 } else {
3409 true
3410 }
3411 })
3412 .collect();
3413 let instruction_entry_literals: Vec<String> = instruction_entries_for_program
3414 .iter()
3415 .map(|entry| {
3416 format!(
3417 " {}: {},",
3418 entry.instruction_name, entry.handler_const
3419 )
3420 })
3421 .collect();
3422
3423 let account_entries: Vec<String> = idl
3424 .accounts
3425 .iter()
3426 .filter_map(|account| {
3427 let type_name = account_type_names
3428 .get(&(program_key.clone(), account.name.clone()))?
3429 .clone();
3430 let schema_name = format!("{}Schema", type_name);
3431 if !schema_names.contains(&schema_name) {
3432 return None;
3433 }
3434 Some((account.name.clone(), type_name, schema_name))
3435 })
3436 .map(|account| {
3437 format!(
3438 " {account_name}: programAccountRead<{type_name}>({{ account: '{account_name}', path: '/programs/{program}/accounts/{account_name}', schema: {schema_name} }}),",
3439 account_name = account.0,
3440 type_name = account.1,
3441 schema_name = account.2,
3442 program = program_key,
3443 )
3444 })
3445 .collect();
3446
3447 let program_pdas = pdas
3448 .get(&idl.name)
3449 .or_else(|| pdas.get(&program_key))
3450 .filter(|program_pdas| !program_pdas.is_empty());
3451
3452 let mut sections: Vec<String> = vec![
3453 format!(" name: '{}',", idl.name),
3454 format!(" programId: '{}',", program_id),
3455 ];
3456
3457 if let Some(program_pdas) = program_pdas {
3458 let pda_entries = generate_program_pda_entries(program_pdas, &program_id, " ");
3459 if !pda_entries.is_empty() {
3460 sections.push(format!(
3461 " pdas: {{\n{}\n }},",
3462 pda_entries.join("\n")
3463 ));
3464 sections.push(format!(
3465 " addresses: {{\n{}\n }},",
3466 pda_entries.join("\n")
3467 ));
3468 }
3469 }
3470
3471 if !account_entries.is_empty() {
3472 sections.push(format!(
3473 " accounts: {{\n{}\n }},",
3474 account_entries.join("\n")
3475 ));
3476 }
3477
3478 if !instruction_entry_literals.is_empty() {
3479 sections.push(format!(
3480 " rawInstructions: {{\n{}\n }},",
3481 instruction_entry_literals.join("\n")
3482 ));
3483 if let Some(semantic_block) =
3484 generate_program_semantic_instructions_block(&instruction_entries_for_program, " ")
3485 {
3486 sections.push(semantic_block);
3487 }
3488 }
3489
3490 (program_key, sections)
3491}
3492
3493fn generate_programs_block(
3494 idls: &[IdlSnapshot],
3495 pdas: &BTreeMap<String, BTreeMap<String, PdaDefinition>>,
3496 program_ids: &[String],
3497 instruction_entries: &[crate::typescript_instructions::StackInstructionEntry],
3498 schema_names: &BTreeSet<String>,
3499 account_type_names: &BTreeMap<(String, String), String>,
3500) -> String {
3501 if idls.is_empty() {
3502 return String::new();
3503 }
3504
3505 let mut program_blocks = Vec::new();
3506
3507 for (index, idl) in idls.iter().enumerate() {
3508 let (program_key, sections) = generate_single_program_sections(
3509 idl,
3510 index,
3511 pdas,
3512 program_ids,
3513 instruction_entries,
3514 schema_names,
3515 account_type_names,
3516 );
3517
3518 program_blocks.push(format!(
3519 " {}: {{\n{}\n }},",
3520 program_key,
3521 sections.join("\n")
3522 ));
3523 }
3524
3525 if program_blocks.is_empty() {
3526 return String::new();
3527 }
3528
3529 format!("\n programs: {{\n{}\n }},", program_blocks.join("\n"))
3530}
3531
3532fn dedent_lines(text: &str, spaces: usize) -> String {
3534 text.lines()
3535 .map(|line| {
3536 let strip = line
3537 .char_indices()
3538 .take_while(|(i, c)| *i < spaces && *c == ' ')
3539 .count();
3540 &line[strip..]
3541 })
3542 .collect::<Vec<_>>()
3543 .join("\n")
3544}
3545
3546fn generate_program_definitions(
3556 stack_name: &str,
3557 idls: &[IdlSnapshot],
3558 pdas: &BTreeMap<String, BTreeMap<String, PdaDefinition>>,
3559 program_ids: &[String],
3560 instruction_entries: &[crate::typescript_instructions::StackInstructionEntry],
3561 schema_names: &BTreeSet<String>,
3562 account_type_names: &BTreeMap<(String, String), String>,
3563) -> String {
3564 let mut program_consts = Vec::new();
3565 let mut map_entries = Vec::new();
3566
3567 for (index, idl) in idls.iter().enumerate() {
3568 let (program_key, sections) = generate_single_program_sections(
3569 idl,
3570 index,
3571 pdas,
3572 program_ids,
3573 instruction_entries,
3574 schema_names,
3575 account_type_names,
3576 );
3577 let const_name = to_screaming_snake_case(&idl.name);
3578 let body = dedent_lines(§ions.join("\n"), 4);
3579 program_consts.push(format!(
3580 "/** Standalone program SDK for '{name}' */\nexport const {const_name} = {{\n{body}\n}} as const;",
3581 name = idl.name,
3582 const_name = const_name,
3583 body = body,
3584 ));
3585 map_entries.push(format!(" {}: {},", program_key, const_name));
3586 }
3587
3588 let map_name = format!("{}_PROGRAMS", to_screaming_snake_case(stack_name));
3589 let type_name = format!("{}Programs", to_pascal_case(stack_name));
3590
3591 format!(
3592 r#"// ============================================================================
3593// Program Definitions
3594// ============================================================================
3595
3596{program_consts}
3597
3598 /** All programs from the {stack_name} stack, ready for createSession({{ programs: ... }}) */
3599export const {map_name} = {{
3600{map_entries}
3601}} as const;
3602
3603export type {type_name} = typeof {map_name};
3604
3605export default {map_name};"#,
3606 program_consts = program_consts.join("\n\n"),
3607 stack_name = stack_name,
3608 map_name = map_name,
3609 map_entries = map_entries.join("\n"),
3610 type_name = type_name,
3611 )
3612}
3613
3614fn generate_program_pda_entries(
3615 program_pdas: &BTreeMap<String, PdaDefinition>,
3616 default_program_id: &str,
3617 indent: &str,
3618) -> Vec<String> {
3619 if program_pdas.is_empty() {
3620 return Vec::new();
3621 }
3622
3623 program_pdas
3624 .iter()
3625 .map(|(pda_name, pda_def)| {
3626 let seeds_str = pda_def
3627 .seeds
3628 .iter()
3629 .map(|seed| match seed {
3630 PdaSeedDef::Literal { value } => format!("literal('{}')", value),
3631 PdaSeedDef::AccountRef { account_name } => {
3632 format!("account('{}')", account_name)
3633 }
3634 PdaSeedDef::ArgRef { arg_name, arg_type } => {
3635 if let Some(t) = arg_type {
3636 format!("arg('{}', '{}')", arg_name, t)
3637 } else {
3638 format!("arg('{}')", arg_name)
3639 }
3640 }
3641 PdaSeedDef::Bytes { value } => {
3642 let bytes_arr: Vec<String> = value.iter().map(|b| b.to_string()).collect();
3643 format!("bytes(new Uint8Array([{}]))", bytes_arr.join(", "))
3644 }
3645 })
3646 .collect::<Vec<_>>()
3647 .join(", ");
3648
3649 let pid = pda_def.program_id.as_deref().unwrap_or(default_program_id);
3650 format!("{}{}: pda('{}', {}),", indent, pda_name, pid, seeds_str)
3651 })
3652 .collect()
3653}
3654
3655fn generate_stack_addresses_block(
3656 idls: &[IdlSnapshot],
3657 pdas: &BTreeMap<String, BTreeMap<String, PdaDefinition>>,
3658 program_ids: &[String],
3659) -> String {
3660 if idls.is_empty() {
3661 return String::new();
3662 }
3663
3664 if idls.len() == 1 {
3665 let idl = &idls[0];
3666 let program_id = program_ids
3667 .first()
3668 .cloned()
3669 .or_else(|| idl.program_id.clone())
3670 .unwrap_or_default();
3671 let Some(program_pdas) = pdas
3672 .get(&idl.name)
3673 .or_else(|| pdas.get(&to_camel_case(&idl.name)))
3674 else {
3675 return String::new();
3676 };
3677 let entries = generate_program_pda_entries(program_pdas, &program_id, " ");
3678 if entries.is_empty() {
3679 return String::new();
3680 }
3681 return format!("\n addresses: {{\n{}\n }},", entries.join("\n"));
3682 }
3683
3684 let mut blocks = Vec::new();
3685 for (index, idl) in idls.iter().enumerate() {
3686 let program_id = program_ids
3687 .get(index)
3688 .cloned()
3689 .or_else(|| idl.program_id.clone())
3690 .unwrap_or_default();
3691 let Some(program_pdas) = pdas
3692 .get(&idl.name)
3693 .or_else(|| pdas.get(&to_camel_case(&idl.name)))
3694 else {
3695 continue;
3696 };
3697 let entries = generate_program_pda_entries(program_pdas, &program_id, " ");
3698 if entries.is_empty() {
3699 continue;
3700 }
3701 blocks.push(format!(
3702 " {}: {{\n{}\n }},",
3703 to_camel_case(&idl.name),
3704 entries.join("\n")
3705 ));
3706 }
3707
3708 if blocks.is_empty() {
3709 String::new()
3710 } else {
3711 format!("\n addresses: {{\n{}\n }},", blocks.join("\n"))
3712 }
3713}
3714
3715fn is_valid_ts_identifier(name: &str) -> bool {
3716 name.chars()
3717 .next()
3718 .map(|c| c.is_ascii_alphabetic() || c == '_' || c == '$')
3719 .unwrap_or(false)
3720 && name
3721 .chars()
3722 .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '$')
3723}
3724
3725fn escape_ts_single_quotes(value: &str) -> String {
3726 value
3727 .replace('\\', "\\\\")
3728 .replace('\'', "\\'")
3729 .replace(['\n', '\r'], " ")
3730}
3731
3732fn render_ts_property_name_literal(name: &str) -> String {
3733 if is_valid_ts_identifier(name) {
3734 name.to_string()
3735 } else {
3736 format!("'{}'", escape_ts_single_quotes(name))
3737 }
3738}
3739
3740fn render_program_semantic_instruction_entry(
3741 entry: &crate::typescript_instructions::StackInstructionEntry,
3742 indent: &str,
3743) -> Option<String> {
3744 let semantic_params_type = entry.semantic_params_type.as_ref()?;
3745 entry.runtime_program_key.as_ref()?;
3746
3747 if entry.semantic_amount_args.is_empty() {
3748 return Some(format!(
3749 "{indent}{instruction_name}: instructionOperation(async (params: {semantic_params_type}) => {{\n{indent} const instruction = buildInstruction({handler_const}, params as unknown as Record<string, unknown>);\n{indent} return createPreparedInstruction({{\n{indent} name: '{instruction_name}',\n{indent} instruction,\n{indent} artifacts: {{ instruction }},\n{indent} errors: {handler_const}.errors,\n{indent} }});\n{indent}}}),",
3750 indent = indent,
3751 instruction_name = entry.instruction_name,
3752 semantic_params_type = semantic_params_type,
3753 handler_const = entry.handler_const,
3754 ));
3755 }
3756
3757 let raw_params_setup = if entry.semantic_extra_params.is_empty() {
3758 format!(
3759 "{indent} const {{ build, ...rawParams }} = params;",
3760 indent = indent
3761 )
3762 } else {
3763 format!(
3764 "{indent} const {{ build, {extras}, ...rawParams }} = params;",
3765 indent = indent,
3766 extras = entry.semantic_extra_params.join(", "),
3767 )
3768 };
3769 let resolution_lines: Vec<String> = entry
3770 .semantic_amount_args
3771 .iter()
3772 .map(|amount_arg| {
3773 format!(
3774 "{indent} const {binding_name} = {raw_expression};",
3775 indent = indent,
3776 binding_name = amount_arg.binding_name,
3777 raw_expression = amount_arg.raw_expression,
3778 )
3779 })
3780 .collect();
3781 let raw_assignments: Vec<String> = entry
3782 .semantic_amount_args
3783 .iter()
3784 .map(|amount_arg| {
3785 format!(
3786 "{indent} {}: {},",
3787 render_ts_property_name_literal(&amount_arg.arg_name),
3788 amount_arg.binding_name,
3789 indent = indent,
3790 )
3791 })
3792 .collect();
3793
3794 Some(format!(
3795 "{indent}{instruction_name}: instructionOperation(async (params: {semantic_params_type}) => {{\n{raw_params_setup}\n{resolutions}\n{indent} const instruction = buildInstruction({handler_const}, {{\n{indent} ...rawParams,\n{assignments}\n{indent} }} as unknown as Record<string, unknown>, build);\n{indent} return createPreparedInstruction({{\n{indent} name: '{instruction_name}',\n{indent} instruction,\n{indent} artifacts: {{ instruction }},\n{indent} errors: {handler_const}.errors,\n{indent} }});\n{indent} }}),",
3796 indent = indent,
3797 instruction_name = entry.instruction_name,
3798 semantic_params_type = semantic_params_type,
3799 raw_params_setup = raw_params_setup,
3800 resolutions = resolution_lines.join("\n"),
3801 handler_const = entry.handler_const,
3802 assignments = raw_assignments.join("\n"),
3803 ))
3804}
3805
3806fn generate_program_semantic_instructions_block(
3807 instruction_entries: &[&crate::typescript_instructions::StackInstructionEntry],
3808 indent: &str,
3809) -> Option<String> {
3810 let entry_indent = format!("{} ", indent);
3811 let entries: Vec<String> = instruction_entries
3812 .iter()
3813 .filter_map(|entry| render_program_semantic_instruction_entry(entry, &entry_indent))
3814 .collect();
3815 if entries.is_empty() {
3816 return None;
3817 }
3818
3819 Some(format!(
3820 "{indent}[PROGRAM_OPERATION_EXTENSIONS]: {{\n{indent} createOperations(context: ProgramOperationContext) {{\n{indent} return {{\n{indent} instructions: {{\n{entries}\n{indent} }},\n{indent} }};\n{indent} }},\n{indent}}},",
3821 indent = indent,
3822 entries = entries.join("\n"),
3823 ))
3824}
3825
3826fn derive_http_url(ws_url: &str) -> String {
3827 if ws_url.starts_with("wss://") {
3828 ws_url.replacen("wss://", "https://", 1)
3829 } else if ws_url.starts_with("ws://") {
3830 ws_url.replacen("ws://", "http://", 1)
3831 } else {
3832 ws_url.to_string()
3833 }
3834}
3835
3836fn generate_view_helpers_static() -> String {
3837 r#"// ============================================================================
3838// View Definition Types (framework-agnostic)
3839// ============================================================================
3840
3841/** View definition with embedded entity type */
3842export interface ViewDef<T, TMode extends 'state' | 'list'> {
3843 readonly mode: TMode;
3844 readonly view: string;
3845 /** Phantom field for type inference - not present at runtime */
3846 readonly _entity?: T;
3847}
3848
3849/** Helper to create typed state view definitions (keyed lookups) */
3850function stateView<T>(view: string): ViewDef<T, 'state'> {
3851 return { mode: 'state', view } as const;
3852}
3853
3854/** Helper to create typed list view definitions (collections) */
3855function listView<T>(view: string): ViewDef<T, 'list'> {
3856 return { mode: 'list', view } as const;
3857}"#
3858 .to_string()
3859}
3860
3861pub(crate) fn to_screaming_snake_case(s: &str) -> String {
3863 let mut result = String::new();
3864 for (i, ch) in s.chars().enumerate() {
3865 if ch.is_uppercase() && i > 0 {
3866 result.push('_');
3867 }
3868 result.push(ch.to_uppercase().next().unwrap());
3869 }
3870 result
3871}
3872
3873#[cfg(test)]
3874mod tests {
3875 use super::*;
3876
3877 #[test]
3878 fn test_case_conversions() {
3879 assert_eq!(to_pascal_case("settlement_game"), "SettlementGame");
3880 assert_eq!(to_kebab_case("SettlementGame"), "settlement-game");
3881 }
3882
3883 #[test]
3884 fn test_normalize_for_comparison() {
3885 assert_eq!(normalize_for_comparison("claim_sol"), "claimsol");
3886 assert_eq!(normalize_for_comparison("claimSol"), "claimsol");
3887 assert_eq!(normalize_for_comparison("ClaimSol"), "claimsol");
3888 assert_eq!(
3889 normalize_for_comparison("admin_set_creator"),
3890 "adminsetcreator"
3891 );
3892 assert_eq!(
3893 normalize_for_comparison("AdminSetCreator"),
3894 "adminsetcreator"
3895 );
3896 }
3897
3898 #[test]
3899 fn test_value_to_typescript_type() {
3900 assert_eq!(value_to_typescript_type(&serde_json::json!(42)), "number");
3901 assert_eq!(
3902 value_to_typescript_type(&serde_json::json!("hello")),
3903 "string"
3904 );
3905 assert_eq!(
3906 value_to_typescript_type(&serde_json::json!(true)),
3907 "boolean"
3908 );
3909 assert_eq!(value_to_typescript_type(&serde_json::json!([])), "any[]");
3910 }
3911
3912 #[test]
3913 fn streamed_entity_codegen_normalizes_canonical_names_and_schemas() {
3914 let spec = SerializableStreamSpec {
3915 ast_version: CURRENT_AST_VERSION.to_string(),
3916 state_name: "TokenPosition".to_string(),
3917 program_id: None,
3918 idl: None,
3919 identity: IdentitySpec {
3920 primary_keys: vec!["id.address".to_string()],
3921 lookup_indexes: vec![],
3922 },
3923 handlers: vec![],
3924 sections: vec![
3925 EntitySection {
3926 name: "root".to_string(),
3927 fields: vec![FieldTypeInfo::new(
3928 "total_deposit".to_string(),
3929 "u64".to_string(),
3930 )],
3931 is_nested_struct: false,
3932 parent_field: None,
3933 },
3934 EntitySection {
3935 name: "metrics".to_string(),
3936 fields: vec![FieldTypeInfo::new(
3937 "last_updated_at".to_string(),
3938 "i64".to_string(),
3939 )],
3940 is_nested_struct: false,
3941 parent_field: None,
3942 },
3943 ],
3944 field_mappings: BTreeMap::new(),
3945 resolver_hooks: vec![],
3946 resolver_specs: vec![],
3947 instruction_hooks: vec![],
3948 computed_fields: vec![],
3949 computed_field_specs: vec![],
3950 content_hash: None,
3951 views: vec![],
3952 };
3953
3954 let output = compile_serializable_spec(spec, "TokenPosition".to_string(), None)
3955 .expect("should compile");
3956 let file = output.full_file();
3957
3958 assert!(
3959 file.contains("export interface TokenPosition {"),
3960 "missing main interface:\n{}",
3961 file
3962 );
3963 assert!(
3964 file.contains("totalDeposit: bigint;"),
3965 "missing root canonical field:\n{}",
3966 file
3967 );
3968 assert!(
3969 file.contains("metrics: TokenPositionMetrics;"),
3970 "missing nested canonical section field:\n{}",
3971 file
3972 );
3973 assert!(
3974 file.contains("export interface TokenPositionMetrics {"),
3975 "missing nested interface:\n{}",
3976 file
3977 );
3978 assert!(
3979 file.contains("lastUpdatedAt: bigint;"),
3980 "missing nested canonical field:\n{}",
3981 file
3982 );
3983
3984 let bigint_schema = bigint_zod();
3985 assert!(
3986 file.contains("export const TokenPositionSchema = z.object({"),
3987 "missing main schema:\n{}",
3988 file
3989 );
3990 assert!(
3991 file.contains(&format!("total_deposit: {},", bigint_schema)),
3992 "missing raw root schema field:\n{}",
3993 file
3994 );
3995 assert!(
3996 file.contains("metrics: TokenPositionMetricsSchema,"),
3997 "missing nested schema ref:\n{}",
3998 file
3999 );
4000 assert!(
4001 file.contains("totalDeposit: value.total_deposit,"),
4002 "missing root transform:\n{}",
4003 file
4004 );
4005 assert!(
4006 file.contains("metrics: value.metrics,"),
4007 "missing nested transform:\n{}",
4008 file
4009 );
4010
4011 assert!(
4012 file.contains("export const TokenPositionMetricsSchema = z.object({"),
4013 "missing nested schema:\n{}",
4014 file
4015 );
4016 assert!(
4017 file.contains(&format!("last_updated_at: {},", bigint_schema)),
4018 "missing raw nested schema field:\n{}",
4019 file
4020 );
4021 assert!(
4022 file.contains("lastUpdatedAt: value.last_updated_at,"),
4023 "missing nested transform:\n{}",
4024 file
4025 );
4026
4027 assert!(
4028 file.contains("export const TokenPositionPatchSchema = z.object({"),
4029 "missing patch schema:\n{}",
4030 file
4031 );
4032 assert!(
4033 file.contains(&format!("total_deposit: {}.optional(),", bigint_schema)),
4034 "missing sparse patch field:\n{}",
4035 file
4036 );
4037 assert!(
4038 file.contains("metrics: TokenPositionMetricsPatchSchema.optional(),"),
4039 "missing nested patch schema ref:\n{}",
4040 file
4041 );
4042 assert!(
4043 file.contains("...(value.total_deposit !== undefined ? { totalDeposit: value.total_deposit } : {}),"),
4044 "missing sparse patch transform:\n{}",
4045 file
4046 );
4047 assert!(
4048 file.contains("export const TokenPositionMetricsPatchSchema = z.object({"),
4049 "missing nested patch schema:\n{}",
4050 file
4051 );
4052 assert!(
4053 file.contains("patchSchemas: {\n TokenPosition: TokenPositionPatchSchema,"),
4054 "missing stack patch schema map:\n{}",
4055 file
4056 );
4057 }
4058
4059 #[test]
4060 fn streamed_builtin_token_metadata_codegen_is_canonical_and_sparse() {
4061 let spec = SerializableStreamSpec {
4062 ast_version: CURRENT_AST_VERSION.to_string(),
4063 state_name: "TokenHolder".to_string(),
4064 program_id: None,
4065 idl: None,
4066 identity: IdentitySpec {
4067 primary_keys: vec!["id.address".to_string()],
4068 lookup_indexes: vec![],
4069 },
4070 handlers: vec![],
4071 sections: vec![EntitySection {
4072 name: "root".to_string(),
4073 fields: vec![FieldTypeInfo {
4074 field_name: "base_token_metadata".to_string(),
4075 raw_name: Some("base_token_metadata".to_string()),
4076 canonical_name: Some("baseTokenMetadata".to_string()),
4077 rust_type_name: "Option<TokenMetadata>".to_string(),
4078 base_type: BaseType::Object,
4079 integer_kind: None,
4080 is_optional: true,
4081 is_array: false,
4082 inner_type: Some("TokenMetadata".to_string()),
4083 source_path: None,
4084 resolved_type: None,
4085 emit: true,
4086 }],
4087 is_nested_struct: false,
4088 parent_field: None,
4089 }],
4090 field_mappings: BTreeMap::new(),
4091 resolver_hooks: vec![],
4092 resolver_specs: vec![],
4093 instruction_hooks: vec![],
4094 computed_fields: vec![],
4095 computed_field_specs: vec![],
4096 content_hash: None,
4097 views: vec![],
4098 };
4099
4100 let output = compile_serializable_spec(spec, "TokenHolder".to_string(), None)
4101 .expect("should compile");
4102 let file = output.full_file();
4103
4104 assert!(
4105 file.contains("export interface TokenMetadata {"),
4106 "missing builtin interface:\n{}",
4107 file
4108 );
4109 assert!(
4110 file.contains("logoUri?: string | null;"),
4111 "missing canonical builtin field:\n{}",
4112 file
4113 );
4114 assert!(
4115 file.contains("export const TokenMetadataSchema = z.object({"),
4116 "missing builtin schema:\n{}",
4117 file
4118 );
4119 assert!(
4120 file.contains("logo_uri: z.string().nullable().optional(),"),
4121 "missing raw builtin input field:\n{}",
4122 file
4123 );
4124 assert!(
4125 file.contains("...(value.logo_uri !== undefined ? { logoUri: value.logo_uri } : {}),"),
4126 "missing canonical builtin transform:\n{}",
4127 file
4128 );
4129 assert!(
4130 file.contains("export const TokenMetadataPatchSchema = z.object({"),
4131 "missing builtin patch schema:\n{}",
4132 file
4133 );
4134 assert!(
4135 file.contains("base_token_metadata: TokenMetadataPatchSchema.nullable().optional(),"),
4136 "missing patch schema usage for builtin field:\n{}",
4137 file
4138 );
4139 }
4140
4141 #[test]
4142 fn streamed_section_codegen_localizes_prefixed_raw_field_names() {
4143 let spec = SerializableStreamSpec {
4144 ast_version: CURRENT_AST_VERSION.to_string(),
4145 state_name: "OreRound".to_string(),
4146 program_id: None,
4147 idl: None,
4148 identity: IdentitySpec {
4149 primary_keys: vec!["id.round_id".to_string()],
4150 lookup_indexes: vec![],
4151 },
4152 handlers: vec![],
4153 sections: vec![EntitySection {
4154 name: "results".to_string(),
4155 fields: vec![FieldTypeInfo {
4156 field_name: "results.expires_at_slot_hash".to_string(),
4157 raw_name: Some("results.expires_at_slot_hash".to_string()),
4158 canonical_name: Some("resultsExpiresAtSlotHash".to_string()),
4159 rust_type_name: "Option<String>".to_string(),
4160 base_type: BaseType::String,
4161 integer_kind: None,
4162 is_optional: true,
4163 is_array: false,
4164 inner_type: Some("String".to_string()),
4165 source_path: None,
4166 resolved_type: None,
4167 emit: true,
4168 }],
4169 is_nested_struct: false,
4170 parent_field: None,
4171 }],
4172 field_mappings: BTreeMap::new(),
4173 resolver_hooks: vec![],
4174 resolver_specs: vec![],
4175 instruction_hooks: vec![],
4176 computed_fields: vec![],
4177 computed_field_specs: vec![],
4178 content_hash: None,
4179 views: vec![],
4180 };
4181
4182 let output =
4183 compile_serializable_spec(spec, "OreRound".to_string(), None).expect("should compile");
4184 let file = output.full_file();
4185
4186 assert!(
4187 file.contains("export interface OreRoundResults {"),
4188 "missing section interface:\n{}",
4189 file
4190 );
4191 assert!(
4192 file.contains("expiresAtSlotHash: string | null;"),
4193 "missing localized canonical field:\n{}",
4194 file
4195 );
4196 assert!(
4197 file.contains("expires_at_slot_hash: z.string().nullable(),"),
4198 "missing localized raw schema field:\n{}",
4199 file
4200 );
4201 assert!(
4202 file.contains("expiresAtSlotHash: value.expires_at_slot_hash,"),
4203 "missing localized transform:\n{}",
4204 file
4205 );
4206 assert!(
4207 file.contains("expires_at_slot_hash: z.string().nullable().optional(),"),
4208 "missing localized patch schema field:\n{}",
4209 file
4210 );
4211 assert!(
4212 file.contains("...(value.expires_at_slot_hash !== undefined ? { expiresAtSlotHash: value.expires_at_slot_hash } : {}),"),
4213 "missing localized sparse transform:\n{}",
4214 file
4215 );
4216 }
4217
4218 #[test]
4219 fn test_derived_view_codegen() {
4220 let spec = SerializableStreamSpec {
4221 ast_version: CURRENT_AST_VERSION.to_string(),
4222 state_name: "OreRound".to_string(),
4223 program_id: None,
4224 idl: None,
4225 identity: IdentitySpec {
4226 primary_keys: vec!["id".to_string()],
4227 lookup_indexes: vec![],
4228 },
4229 handlers: vec![],
4230 sections: vec![],
4231 field_mappings: BTreeMap::new(),
4232 resolver_hooks: vec![],
4233 resolver_specs: vec![],
4234 instruction_hooks: vec![],
4235 computed_fields: vec![],
4236 computed_field_specs: vec![],
4237 content_hash: None,
4238 views: vec![
4239 ViewDef {
4240 id: "OreRound/latest".to_string(),
4241 source: ViewSource::Entity {
4242 name: "OreRound".to_string(),
4243 },
4244 pipeline: vec![ViewTransform::Last],
4245 output: ViewOutput::Single,
4246 },
4247 ViewDef {
4248 id: "OreRound/top10".to_string(),
4249 source: ViewSource::Entity {
4250 name: "OreRound".to_string(),
4251 },
4252 pipeline: vec![ViewTransform::Take { count: 10 }],
4253 output: ViewOutput::Collection,
4254 },
4255 ],
4256 };
4257
4258 let output =
4259 compile_serializable_spec(spec, "OreRound".to_string(), None).expect("should compile");
4260
4261 let stack_def = &output.stack_definition;
4262
4263 assert!(
4264 stack_def.contains("listView<OreRound>('OreRound/latest')"),
4265 "Expected 'latest' derived view using listView, got:\n{}",
4266 stack_def
4267 );
4268 assert!(
4269 stack_def.contains("listView<OreRound>('OreRound/top10')"),
4270 "Expected 'top10' derived view using listView, got:\n{}",
4271 stack_def
4272 );
4273 assert!(
4274 stack_def.contains("latest:"),
4275 "Expected 'latest' key, got:\n{}",
4276 stack_def
4277 );
4278 assert!(
4279 stack_def.contains("top10:"),
4280 "Expected 'top10' key, got:\n{}",
4281 stack_def
4282 );
4283 assert!(
4284 stack_def.contains("function listView<T>(view: string): ViewDef<T, 'list'>"),
4285 "Expected listView helper function, got:\n{}",
4286 stack_def
4287 );
4288 }
4289
4290 #[test]
4291 fn test_account_type_collision_uses_account_suffix() {
4292 let plan_field = FieldTypeInfo {
4293 field_name: "plan".to_string(),
4294 raw_name: Some("plan".to_string()),
4295 canonical_name: Some("plan".to_string()),
4296 rust_type_name: "Option<serde_json::Value>".to_string(),
4297 base_type: BaseType::Object,
4298 integer_kind: None,
4299 is_optional: false,
4300 is_array: false,
4301 inner_type: Some("Value".to_string()),
4302 source_path: None,
4303 resolved_type: Some(ResolvedStructType {
4304 type_name: "plan".to_string(),
4305 fields: vec![],
4306 is_instruction: false,
4307 is_account: true,
4308 is_event: false,
4309 is_enum: false,
4310 enum_variants: vec![],
4311 }),
4312 emit: true,
4313 };
4314
4315 let spec = SerializableStreamSpec {
4316 ast_version: CURRENT_AST_VERSION.to_string(),
4317 state_name: "Plan".to_string(),
4318 program_id: None,
4319 idl: None,
4320 identity: IdentitySpec {
4321 primary_keys: vec!["id.address".to_string()],
4322 lookup_indexes: vec![],
4323 },
4324 handlers: vec![],
4325 sections: vec![
4326 EntitySection {
4327 name: "id".to_string(),
4328 fields: vec![FieldTypeInfo::new(
4329 "address".to_string(),
4330 "String".to_string(),
4331 )],
4332 is_nested_struct: false,
4333 parent_field: None,
4334 },
4335 EntitySection {
4336 name: "plan".to_string(),
4337 fields: vec![plan_field],
4338 is_nested_struct: false,
4339 parent_field: None,
4340 },
4341 ],
4342 field_mappings: BTreeMap::new(),
4343 resolver_hooks: vec![],
4344 instruction_hooks: vec![],
4345 resolver_specs: vec![],
4346 computed_fields: vec![],
4347 computed_field_specs: vec![],
4348 content_hash: None,
4349 views: vec![],
4350 };
4351
4352 let output = compile_serializable_spec(spec, "Plan".to_string(), None)
4353 .expect("typescript sdk generation should succeed");
4354
4355 assert!(
4356 output.interfaces.contains("export interface PlanPlan {"),
4357 "expected PlanPlan section interface, got:\n{}",
4358 output.interfaces
4359 );
4360 assert!(
4361 output.interfaces.contains("plan: PlanAccount;"),
4362 "expected PlanAccount field reference, got:\n{}",
4363 output.interfaces
4364 );
4365 assert!(
4366 output.interfaces.contains("export interface PlanAccount {"),
4367 "expected PlanAccount interface, got:\n{}",
4368 output.interfaces
4369 );
4370 }
4371
4372 #[test]
4373 fn test_multi_entity_enum_dedup_uses_pascal_case_name_matching() {
4374 let shared_idl = serde_json::json!({
4375 "name": "subscriptions",
4376 "version": "0.1.0",
4377 "accounts": [],
4378 "instructions": [],
4379 "types": [
4380 {
4381 "name": "planStatus",
4382 "type": {
4383 "kind": "enum",
4384 "variants": [{ "name": "sunset" }, { "name": "active" }]
4385 }
4386 }
4387 ],
4388 "events": [],
4389 "errors": [],
4390 "discriminant_size": 8
4391 });
4392
4393 let idl_snapshot: IdlSnapshot =
4394 serde_json::from_value(shared_idl).expect("idl snapshot should deserialize");
4395
4396 let make_entity = |name: &str| SerializableStreamSpec {
4397 ast_version: CURRENT_AST_VERSION.to_string(),
4398 state_name: name.to_string(),
4399 program_id: None,
4400 idl: None,
4401 identity: IdentitySpec {
4402 primary_keys: vec!["id.address".to_string()],
4403 lookup_indexes: vec![],
4404 },
4405 handlers: vec![],
4406 sections: vec![EntitySection {
4407 name: "id".to_string(),
4408 fields: vec![FieldTypeInfo::new(
4409 "address".to_string(),
4410 "String".to_string(),
4411 )],
4412 is_nested_struct: false,
4413 parent_field: None,
4414 }],
4415 field_mappings: BTreeMap::new(),
4416 resolver_hooks: vec![],
4417 instruction_hooks: vec![],
4418 resolver_specs: vec![],
4419 computed_fields: vec![],
4420 computed_field_specs: vec![],
4421 content_hash: None,
4422 views: vec![],
4423 };
4424
4425 let stack_spec = SerializableStackSpec {
4426 ast_version: CURRENT_AST_VERSION.to_string(),
4427 stack_name: "Subscriptions".to_string(),
4428 program_ids: vec![],
4429 idls: vec![idl_snapshot],
4430 entities: vec![make_entity("Plan"), make_entity("Subscription")],
4431 pdas: BTreeMap::new(),
4432 instructions: vec![],
4433 content_hash: None,
4434 };
4435
4436 let output =
4437 compile_stack_spec(stack_spec, None).expect("stack compilation should succeed");
4438 let file = output.full_file();
4439 let count = output
4440 .interfaces
4441 .matches("export type PlanStatus =")
4442 .count();
4443
4444 assert_eq!(
4445 count, 1,
4446 "expected shared enum type to be emitted once, got:\n{}",
4447 output.interfaces
4448 );
4449 assert!(
4450 file.contains("_STACK_CORE = {"),
4451 "core export missing:\n{}",
4452 file
4453 );
4454 assert!(
4455 !file.contains("extendStack"),
4456 "no extension wiring expected:\n{}",
4457 file
4458 );
4459 }
4460
4461 #[test]
4462 fn golden_ore_stack_json_compiles_program_modules_without_entities() {
4463 let path = concat!(
4464 env!("CARGO_MANIFEST_DIR"),
4465 "/../stacks/ore/.arete/OreStream.stack.json"
4466 );
4467 let json = match std::fs::read_to_string(path) {
4468 Ok(c) => c,
4469 Err(_) => return,
4471 };
4472 let mut spec: SerializableStackSpec =
4473 serde_json::from_str(&json).expect("ore stack json should deserialize");
4474
4475 spec.entities.clear();
4477
4478 let output =
4479 compile_program_modules(spec, None).expect("program-module compilation should succeed");
4480 let file = output.full_file();
4481
4482 assert!(
4484 file.contains("export const ORE = {"),
4485 "ore const missing:\n{}",
4486 file
4487 );
4488 assert!(
4489 file.contains("export const ENTROPY = {"),
4490 "entropy const missing"
4491 );
4492 assert!(
4493 file.contains("export const ORE_STREAM_PROGRAMS = {"),
4494 "combined program map missing"
4495 );
4496 assert!(file.contains(" ore: ORE,"));
4497 assert!(file.contains(" entropy: ENTROPY,"));
4498 assert!(file.contains("export default ORE_STREAM_PROGRAMS;"));
4499 assert!(file.contains("ready for createSession({ programs: ... })"));
4500
4501 assert!(file.contains("createInstructionHandler"));
4503 assert!(file.contains("pdas: {"));
4504 assert!(file.contains("addresses: {"));
4505 assert!(file.contains("instructions: {"));
4506 assert!(file.contains("createPreparedInstruction({"));
4507 assert!(file.contains("buildInstruction("));
4508
4509 assert!(!file.contains("stateView"), "no view helpers expected");
4511 assert!(!file.contains("listView"), "no view helpers expected");
4512 assert!(!file.contains("views:"), "no views block expected");
4513 assert!(!file.contains("endpoints:"), "no endpoints block expected");
4514 assert!(
4515 !file.contains("extendStack"),
4516 "no extension wiring expected"
4517 );
4518 }
4519
4520 #[test]
4521 fn compile_program_modules_emits_amount_aware_semantic_instruction_wrappers() {
4522 let stack_spec = SerializableStackSpec {
4523 ast_version: CURRENT_AST_VERSION.to_string(),
4524 stack_name: "DemoStream".to_string(),
4525 program_ids: vec!["Prog111".to_string()],
4526 idls: vec![IdlSnapshot {
4527 name: "demo".to_string(),
4528 program_id: Some("Prog111".to_string()),
4529 version: "0.1.0".to_string(),
4530 accounts: vec![],
4531 instructions: vec![IdlInstructionSnapshot {
4532 name: "deposit".to_string(),
4533 discriminator: vec![9],
4534 discriminant: None,
4535 docs: vec![],
4536 accounts: vec![],
4537 args: vec![
4538 IdlFieldSnapshot {
4539 name: "amount".to_string(),
4540 type_: IdlTypeSnapshot::Simple("u64".to_string()),
4541 amount_hint: None,
4542 },
4543 IdlFieldSnapshot {
4544 name: "mint".to_string(),
4545 type_: IdlTypeSnapshot::Simple("publicKey".to_string()),
4546 amount_hint: None,
4547 },
4548 ],
4549 }],
4550 types: vec![],
4551 events: vec![],
4552 errors: vec![],
4553 discriminant_size: 1,
4554 }],
4555 entities: vec![],
4556 pdas: BTreeMap::new(),
4557 instructions: vec![InstructionDef {
4558 name: "deposit".to_string(),
4559 discriminator: vec![9],
4560 discriminator_size: 1,
4561 accounts: vec![],
4562 args: vec![
4563 InstructionArgDef {
4564 name: "amount".to_string(),
4565 arg_type: "u64".to_string(),
4566 docs: vec![],
4567 amount_hint: Some(InstructionAmountHint {
4568 decimals_source: AmountDecimalsSource::ArgMint {
4569 arg_name: "mint".to_string(),
4570 },
4571 }),
4572 },
4573 InstructionArgDef {
4574 name: "mint".to_string(),
4575 arg_type: "solana_pubkey::Pubkey".to_string(),
4576 docs: vec![],
4577 amount_hint: None,
4578 },
4579 ],
4580 errors: vec![],
4581 program_id: Some("Prog111".to_string()),
4582 docs: vec![],
4583 }],
4584 content_hash: None,
4585 };
4586
4587 let output = compile_program_modules(stack_spec, None)
4588 .expect("program-module compilation should succeed");
4589 let file = output.full_file();
4590
4591 assert!(
4592 file.contains("type AmountInput"),
4593 "amount import missing:\n{}",
4594 file
4595 );
4596 assert!(
4597 file.contains("PROGRAM_OPERATION_EXTENSIONS"),
4598 "runtime extension import missing"
4599 );
4600 assert!(
4601 file.contains("resolveAmountToRaw"),
4602 "amount resolver import missing"
4603 );
4604 assert!(file.contains("export interface DepositSemanticParams"));
4605 assert!(file.contains("build?: BuildOptions;"));
4606 assert!(file.contains("[PROGRAM_OPERATION_EXTENSIONS]: {"));
4607 assert!(file
4608 .contains("deposit: instructionOperation(async (params: DepositSemanticParams) => {"));
4609 assert!(file.contains("const { build, amountDecimals, ...rawParams } = params;"));
4610 assert!(file.contains("resolveAmountToRaw(context.chain"));
4611 assert!(file.contains("const instruction = buildInstruction(depositInstruction, {"));
4612 assert!(file.contains("createPreparedInstruction({"));
4613 }
4614
4615 #[test]
4616 fn golden_ore_stack_json_compiles_stack_with_root_helper_namespaces() {
4617 let path = concat!(
4618 env!("CARGO_MANIFEST_DIR"),
4619 "/../stacks/ore/.arete/OreStream.stack.json"
4620 );
4621 let json = match std::fs::read_to_string(path) {
4622 Ok(c) => c,
4623 Err(_) => return,
4624 };
4625 let spec: SerializableStackSpec =
4626 serde_json::from_str(&json).expect("ore stack json should deserialize");
4627
4628 let output = compile_stack_spec(spec, None).expect("stack compilation should succeed");
4629 let file = output.full_file();
4630
4631 assert!(
4632 file.contains("endpoints:"),
4633 "stack endpoints missing:\n{}",
4634 file
4635 );
4636 assert!(
4637 file.contains("programs: {"),
4638 "program block missing:\n{}",
4639 file
4640 );
4641 assert!(
4642 file.contains("addresses: {"),
4643 "root addresses missing:\n{}",
4644 file
4645 );
4646 assert!(
4647 file.contains("instructions: {"),
4648 "program instructions missing:\n{}",
4649 file
4650 );
4651 assert!(
4652 file.contains("buildInstruction("),
4653 "instruction builders missing:\n{}",
4654 file
4655 );
4656 }
4657
4658 #[test]
4659 fn account_codegen_normalizes_raw_keys_and_nested_types() {
4660 let idl_snapshot = IdlSnapshot {
4661 name: "presale".to_string(),
4662 program_id: None,
4663 version: "0.1.0".to_string(),
4664 accounts: vec![IdlAccountSnapshot {
4665 name: "Presale".to_string(),
4666 discriminator: vec![1, 2, 3, 4, 5, 6, 7, 8],
4667 docs: vec![],
4668 serialization: None,
4669 fields: vec![
4670 IdlFieldSnapshot {
4671 name: "owner".to_string(),
4672 type_: IdlTypeSnapshot::Simple("pubkey".to_string()),
4673 amount_hint: None,
4674 },
4675 IdlFieldSnapshot {
4676 name: "total_deposit".to_string(),
4677 type_: IdlTypeSnapshot::Simple("u64".to_string()),
4678 amount_hint: None,
4679 },
4680 IdlFieldSnapshot {
4681 name: "optional_authority".to_string(),
4682 type_: IdlTypeSnapshot::Option(IdlOptionTypeSnapshot {
4683 option: Box::new(IdlTypeSnapshot::Simple("pubkey".to_string())),
4684 }),
4685 amount_hint: None,
4686 },
4687 IdlFieldSnapshot {
4688 name: "createKey".to_string(),
4689 type_: IdlTypeSnapshot::Simple("pubkey".to_string()),
4690 amount_hint: None,
4691 },
4692 IdlFieldSnapshot {
4693 name: "member".to_string(),
4694 type_: IdlTypeSnapshot::Defined(IdlDefinedTypeSnapshot {
4695 defined: IdlDefinedInnerSnapshot::Simple("MemberConfig".to_string()),
4696 }),
4697 amount_hint: None,
4698 },
4699 ],
4700 type_def: None,
4701 }],
4702 instructions: vec![],
4703 types: vec![IdlTypeDefSnapshot {
4704 name: "MemberConfig".to_string(),
4705 docs: vec![],
4706 serialization: None,
4707 type_def: IdlTypeDefKindSnapshot::Struct {
4708 kind: "struct".to_string(),
4709 fields: vec![
4710 IdlFieldSnapshot {
4711 name: "last_updated_at".to_string(),
4712 type_: IdlTypeSnapshot::Simple("i128".to_string()),
4713 amount_hint: None,
4714 },
4715 IdlFieldSnapshot {
4716 name: "authority_key".to_string(),
4717 type_: IdlTypeSnapshot::Simple("pubkey".to_string()),
4718 amount_hint: None,
4719 },
4720 ],
4721 },
4722 }],
4723 events: vec![],
4724 errors: vec![],
4725 discriminant_size: 8,
4726 };
4727
4728 let artifacts = generate_idl_account_artifacts(&[idl_snapshot], &HashSet::new());
4729 let account_bigint = bigint_zod();
4730
4731 assert!(artifacts.code.contains("export interface Presale {"));
4732 assert!(
4733 artifacts.code.contains("owner: string;"),
4734 "missing owner field:\n{}",
4735 artifacts.code
4736 );
4737 assert!(
4738 artifacts.code.contains("totalDeposit: bigint;"),
4739 "missing totalDeposit field:\n{}",
4740 artifacts.code
4741 );
4742 assert!(
4743 artifacts.code.contains("optionalAuthority: string | null;"),
4744 "missing optionalAuthority field:\n{}",
4745 artifacts.code
4746 );
4747 assert!(
4748 artifacts.code.contains("createKey: string;"),
4749 "missing createKey field:\n{}",
4750 artifacts.code
4751 );
4752 assert!(
4753 artifacts.code.contains("member: MemberConfig;"),
4754 "missing nested type field:\n{}",
4755 artifacts.code
4756 );
4757 assert!(artifacts.code.contains("export interface MemberConfig {"));
4758 assert!(
4759 artifacts.code.contains("lastUpdatedAt: bigint;"),
4760 "missing nested bigint field:\n{}",
4761 artifacts.code
4762 );
4763 assert!(
4764 artifacts.code.contains("authorityKey: string;"),
4765 "missing nested camelCase field:\n{}",
4766 artifacts.code
4767 );
4768
4769 assert!(artifacts
4770 .code
4771 .contains("export const PresaleSchema = z.object({"));
4772 assert!(
4773 artifacts.code.contains("owner: z.string(),"),
4774 "missing owner schema field:\n{}",
4775 artifacts.code
4776 );
4777 assert!(
4778 artifacts
4779 .code
4780 .contains(&format!("total_deposit: {},", account_bigint)),
4781 "missing total_deposit schema field:\n{}",
4782 artifacts.code
4783 );
4784 assert!(
4785 artifacts
4786 .code
4787 .contains("optional_authority: z.string().nullable(),"),
4788 "missing optional_authority schema field:\n{}",
4789 artifacts.code
4790 );
4791 assert!(
4792 artifacts.code.contains("create_key: z.string(),"),
4793 "missing create_key schema field:\n{}",
4794 artifacts.code
4795 );
4796 assert!(
4797 artifacts
4798 .code
4799 .contains("member: z.lazy(() => MemberConfigSchema),"),
4800 "missing nested schema field:\n{}",
4801 artifacts.code
4802 );
4803 assert!(
4804 artifacts.code.contains("owner: value.owner,"),
4805 "missing owner transform:\n{}",
4806 artifacts.code
4807 );
4808 assert!(
4809 artifacts
4810 .code
4811 .contains("totalDeposit: value.total_deposit,"),
4812 "missing totalDeposit transform:\n{}",
4813 artifacts.code
4814 );
4815 assert!(
4816 artifacts
4817 .code
4818 .contains("optionalAuthority: value.optional_authority,"),
4819 "missing optionalAuthority transform:\n{}",
4820 artifacts.code
4821 );
4822 assert!(
4823 artifacts.code.contains("createKey: value.create_key,"),
4824 "missing createKey transform:\n{}",
4825 artifacts.code
4826 );
4827 assert!(
4828 artifacts.code.contains("member: value.member,"),
4829 "missing nested transform:\n{}",
4830 artifacts.code
4831 );
4832
4833 assert!(artifacts
4834 .code
4835 .contains("export const MemberConfigSchema = z.object({"));
4836 assert!(
4837 artifacts
4838 .code
4839 .contains(&format!("last_updated_at: {},", bigint_zod())),
4840 "missing nested bigint schema field:\n{}",
4841 artifacts.code
4842 );
4843 assert!(
4844 artifacts.code.contains("authority_key: z.string(),"),
4845 "missing nested schema field:\n{}",
4846 artifacts.code
4847 );
4848 assert!(
4849 artifacts
4850 .code
4851 .contains("lastUpdatedAt: value.last_updated_at,"),
4852 "missing nested bigint transform:\n{}",
4853 artifacts.code
4854 );
4855 assert!(
4856 artifacts
4857 .code
4858 .contains("authorityKey: value.authority_key,"),
4859 "missing nested camelCase transform:\n{}",
4860 artifacts.code
4861 );
4862 }
4863
4864 #[test]
4865 fn account_codegen_falls_back_to_same_named_type_def_when_account_fields_are_empty() {
4866 let idl_snapshot = IdlSnapshot {
4867 name: "presale".to_string(),
4868 program_id: None,
4869 version: "0.1.0".to_string(),
4870 accounts: vec![IdlAccountSnapshot {
4871 name: "Presale".to_string(),
4872 discriminator: vec![1, 2, 3, 4, 5, 6, 7, 8],
4873 docs: vec![],
4874 serialization: None,
4875 fields: vec![],
4876 type_def: None,
4877 }],
4878 instructions: vec![],
4879 types: vec![IdlTypeDefSnapshot {
4880 name: "Presale".to_string(),
4881 docs: vec![],
4882 serialization: None,
4883 type_def: IdlTypeDefKindSnapshot::Struct {
4884 kind: "struct".to_string(),
4885 fields: vec![
4886 IdlFieldSnapshot {
4887 name: "owner".to_string(),
4888 type_: IdlTypeSnapshot::Simple("pubkey".to_string()),
4889 amount_hint: None,
4890 },
4891 IdlFieldSnapshot {
4892 name: "total_deposit".to_string(),
4893 type_: IdlTypeSnapshot::Simple("u64".to_string()),
4894 amount_hint: None,
4895 },
4896 ],
4897 },
4898 }],
4899 events: vec![],
4900 errors: vec![],
4901 discriminant_size: 8,
4902 };
4903
4904 let artifacts = generate_idl_account_artifacts(&[idl_snapshot], &HashSet::new());
4905
4906 assert!(artifacts.code.contains("export interface Presale {"));
4907 assert!(
4908 artifacts.code.contains("owner: string;"),
4909 "missing owner field:\n{}",
4910 artifacts.code
4911 );
4912 assert!(
4913 artifacts.code.contains("totalDeposit: bigint;"),
4914 "missing totalDeposit field:\n{}",
4915 artifacts.code
4916 );
4917 assert!(
4918 artifacts
4919 .code
4920 .contains("export const PresaleSchema = z.object({"),
4921 "missing schema:\n{}",
4922 artifacts.code
4923 );
4924 assert!(
4925 artifacts.code.contains("owner: z.string(),"),
4926 "missing owner schema field:\n{}",
4927 artifacts.code
4928 );
4929 assert!(
4930 artifacts
4931 .code
4932 .contains(&format!("total_deposit: {},", bigint_zod())),
4933 "missing total_deposit schema field:\n{}",
4934 artifacts.code
4935 );
4936 assert!(
4937 artifacts.code.contains("owner: value.owner,"),
4938 "missing owner transform:\n{}",
4939 artifacts.code
4940 );
4941 assert!(
4942 artifacts
4943 .code
4944 .contains("totalDeposit: value.total_deposit,"),
4945 "missing totalDeposit transform:\n{}",
4946 artifacts.code
4947 );
4948 }
4949
4950 #[test]
4951 fn compile_program_modules_rejects_specs_without_idls() {
4952 let stack_spec = SerializableStackSpec {
4953 ast_version: CURRENT_AST_VERSION.to_string(),
4954 stack_name: "Empty".to_string(),
4955 program_ids: vec![],
4956 idls: vec![],
4957 entities: vec![],
4958 pdas: BTreeMap::new(),
4959 instructions: vec![],
4960 content_hash: None,
4961 };
4962
4963 let error =
4964 compile_program_modules(stack_spec, None).expect_err("no IDLs should be an error");
4965 assert!(error.contains("no IDLs"), "unexpected error: {}", error);
4966 }
4967}