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
72#[derive(Debug, Clone, PartialEq, Eq)]
73struct StateViewKeyDefinition {
74 field_name: String,
75 typescript_type: String,
76}
77
78impl StateViewKeyDefinition {
79 fn object_type(&self) -> String {
80 format!(
81 "{{ {}: {} }}",
82 render_ts_property_name_literal(&self.field_name),
83 self.typescript_type
84 )
85 }
86
87 fn fields_literal(&self) -> String {
88 format!("['{}']", escape_ts_single_quotes(&self.field_name))
89 }
90}
91
92impl<S> TypeScriptCompiler<S> {
93 pub fn new(spec: TypedStreamSpec<S>, entity_name: String) -> Self {
94 Self {
95 spec,
96 entity_name,
97 config: TypeScriptConfig::default(),
98 idl: None,
99 handlers_json: None,
100 views: Vec::new(),
101 already_emitted_types: HashSet::new(),
102 }
103 }
104
105 pub fn with_config(mut self, config: TypeScriptConfig) -> Self {
106 self.config = config;
107 self
108 }
109
110 pub fn with_idl(mut self, idl: Option<serde_json::Value>) -> Self {
111 self.idl = idl;
112 self
113 }
114
115 pub fn with_handlers_json(mut self, handlers: Option<serde_json::Value>) -> Self {
116 self.handlers_json = handlers;
117 self
118 }
119
120 pub fn with_views(mut self, views: Vec<ViewDef>) -> Self {
121 self.views = views;
122 self
123 }
124
125 pub fn with_already_emitted_types(mut self, types: HashSet<String>) -> Self {
126 self.already_emitted_types = types;
127 self
128 }
129
130 pub fn compile(&self) -> TypeScriptOutput {
131 self.try_compile()
132 .expect("TypeScript SDK generation failed")
133 }
134
135 pub fn try_compile(&self) -> Result<TypeScriptOutput, String> {
136 let state_view_key = state_view_key_definition(
137 &self.entity_name,
138 &self.spec.identity,
139 &self.spec.field_mappings,
140 &self.spec.sections,
141 )?;
142 let imports = self.generate_imports();
143 let interfaces = self.generate_interfaces();
144 let schema_output = self.generate_schemas();
145 let combined_interfaces = if schema_output.definitions.is_empty() {
146 interfaces
147 } else if interfaces.is_empty() {
148 schema_output.definitions.clone()
149 } else {
150 format!("{}\n\n{}", interfaces, schema_output.definitions)
151 };
152 let stack_definition = self.generate_stack_definition(&state_view_key);
153
154 Ok(TypeScriptOutput {
155 imports,
156 interfaces: combined_interfaces,
157 stack_definition,
158 schema_names: schema_output.names,
159 })
160 }
161
162 fn generate_imports(&self) -> String {
163 "import { z } from 'zod';".to_string()
164 }
165
166 fn generate_view_helpers(&self) -> String {
167 generate_view_helpers_static()
168 }
169
170 fn generate_interfaces(&self) -> String {
171 let mut interfaces = Vec::new();
172 let mut processed_types = HashSet::new();
173 let all_sections = self.collect_interface_sections();
174
175 for (section_name, fields) in all_sections {
178 if !is_root_section(§ion_name) && processed_types.insert(section_name.clone()) {
179 let deduplicated_fields = self.deduplicate_fields(fields);
180 let interface =
181 self.generate_interface_from_fields(§ion_name, &deduplicated_fields);
182 interfaces.push(interface);
183 }
184 }
185
186 let main_interface = self.generate_main_entity_interface();
188 interfaces.push(main_interface);
189
190 let nested_interfaces = self.generate_nested_interfaces();
191 interfaces.extend(nested_interfaces);
192
193 let builtin_interfaces = self.generate_builtin_resolver_interfaces();
194 interfaces.extend(builtin_interfaces);
195
196 if self.should_emit_capture_wrapper() {
197 interfaces.push(self.generate_capture_wrapper_interface());
198 }
199
200 if self.has_event_types() {
201 interfaces.push(self.generate_event_wrapper_interface());
202 }
203
204 interfaces.join("\n\n")
205 }
206
207 fn collect_interface_sections(&self) -> BTreeMap<String, Vec<TypeScriptField>> {
208 let mut all_sections: BTreeMap<String, Vec<TypeScriptField>> = BTreeMap::new();
209
210 for handler in &self.spec.handlers {
212 let interface_sections = self.extract_interface_sections_from_handler(handler);
213
214 for (section_name, mut fields) in interface_sections {
215 all_sections
216 .entry(section_name)
217 .or_default()
218 .append(&mut fields);
219 }
220 }
221
222 self.add_unmapped_fields(&mut all_sections);
225
226 all_sections
227 }
228
229 fn deduplicate_fields(&self, mut fields: Vec<TypeScriptField>) -> Vec<TypeScriptField> {
230 let mut seen = HashSet::new();
231 let mut unique_fields = Vec::new();
232
233 fields.sort_by(|a, b| a.name.cmp(&b.name));
235
236 for field in fields {
237 if seen.insert(field.name.clone()) {
238 unique_fields.push(field);
239 }
240 }
241
242 unique_fields
243 }
244
245 fn extract_interface_sections_from_handler(
246 &self,
247 handler: &TypedHandlerSpec<S>,
248 ) -> BTreeMap<String, Vec<TypeScriptField>> {
249 let mut sections: BTreeMap<String, Vec<TypeScriptField>> = BTreeMap::new();
250
251 for mapping in &handler.mappings {
252 if !mapping.emit {
253 continue;
254 }
255 let parts: Vec<&str> = mapping.target_path.split('.').collect();
256
257 if parts.len() > 1 {
258 let section_name = parts[0];
259 let field_name = parts[1];
260
261 let ts_field = TypeScriptField::patch(
262 field_name.to_string(),
263 self.mapping_to_typescript_type(mapping),
264 self.is_field_nullable(mapping),
265 );
266
267 sections
268 .entry(section_name.to_string())
269 .or_default()
270 .push(ts_field);
271 } else {
272 let ts_field = TypeScriptField::patch(
273 mapping.target_path.clone(),
274 self.mapping_to_typescript_type(mapping),
275 self.is_field_nullable(mapping),
276 );
277
278 sections
279 .entry("Root".to_string())
280 .or_default()
281 .push(ts_field);
282 }
283 }
284
285 sections
286 }
287
288 fn add_unmapped_fields(&self, sections: &mut BTreeMap<String, Vec<TypeScriptField>>) {
289 if !self.spec.sections.is_empty() {
291 for section in &self.spec.sections {
293 let section_fields = sections.entry(section.name.clone()).or_default();
294
295 for field_info in §ion.fields {
296 if !field_info.emit {
297 continue;
298 }
299 let already_exists = section_fields
301 .iter()
302 .any(|f| f.name == field_info.field_name);
303
304 if !already_exists {
305 let field_path = format!("{}.{}", section.name, field_info.field_name);
307 let effective_field_info =
308 if let Some(mapping) = self.spec.field_mappings.get(&field_path) {
309 if mapping
311 .inner_type
312 .as_ref()
313 .is_some_and(|t| is_builtin_resolver_type(t))
314 {
315 mapping
316 } else {
317 field_info
318 }
319 } else {
320 field_info
321 };
322 let (raw_name, canonical_name) = localize_section_field_names(
323 section.name.as_str(),
324 effective_field_info,
325 );
326
327 section_fields.push(TypeScriptField::from_names(
328 raw_name,
329 canonical_name,
330 self.field_type_info_to_typescript(effective_field_info),
331 effective_field_info.is_optional,
332 FieldPresence::Patch,
333 ));
334 }
335 }
336 }
337 } else {
338 for (field_path, field_type_info) in &self.spec.field_mappings {
340 if !field_type_info.emit {
341 continue;
342 }
343 let parts: Vec<&str> = field_path.split('.').collect();
344 if parts.len() > 1 {
345 let section_name = parts[0];
346 let field_name = parts[1];
347
348 let section_fields = sections.entry(section_name.to_string()).or_default();
349
350 let already_exists = section_fields.iter().any(|f| f.name == field_name);
351
352 if !already_exists {
353 section_fields.push(TypeScriptField::from_names(
354 field_name.to_string(),
355 field_type_info.canonical_field_name(),
356 self.base_type_to_typescript(
357 &field_type_info.base_type,
358 field_type_info.effective_integer_kind(),
359 field_type_info.is_array,
360 ),
361 field_type_info.is_optional,
362 FieldPresence::Patch,
363 ));
364 }
365 }
366 }
367 }
368 }
369
370 fn generate_interface_from_fields(&self, name: &str, fields: &[TypeScriptField]) -> String {
371 let interface_name = self.section_interface_name(name);
372 render_interface_from_ts_fields(&interface_name, fields, true)
373 }
374
375 fn section_interface_name(&self, name: &str) -> String {
376 if name == "Root" {
377 format!(
378 "{}{}",
379 self.config.interface_prefix,
380 to_pascal_case(&self.entity_name)
381 )
382 } else {
383 let base_name = if self.entity_name.contains("Game") {
386 "Game"
387 } else {
388 &self.entity_name
389 };
390 format!(
391 "{}{}{}",
392 self.config.interface_prefix,
393 base_name,
394 to_pascal_case(name)
395 )
396 }
397 }
398
399 fn generate_main_entity_interface(&self) -> String {
400 let entity_name = to_pascal_case(&self.entity_name);
401
402 let main_fields = self.collect_main_entity_fields();
403 if main_fields.is_empty() {
404 return format!(
405 "export interface {} {{\n // Generated interface - extend as needed\n}}",
406 entity_name
407 );
408 }
409
410 render_interface_from_ts_fields(&entity_name, &main_fields, true)
411 }
412
413 fn generate_schemas(&self) -> SchemaOutput {
414 let patch_schema_types = self.patch_schema_type_names();
415 let mut definitions = Vec::new();
416 let mut names = Vec::new();
417 let mut seen = HashSet::new();
418
419 let mut push_schema = |schema_name: String, definition: String, export_name: bool| {
420 if seen.insert(schema_name.clone()) {
421 if export_name {
422 names.push(schema_name);
423 }
424 definitions.push(definition);
425 }
426 };
427
428 for (schema_name, definition) in self.generate_builtin_resolver_schemas() {
429 push_schema(schema_name, definition, true);
430 }
431
432 if self.has_event_types() {
433 push_schema(
434 "EventWrapperSchema".to_string(),
435 self.generate_event_wrapper_schema(),
436 true,
437 );
438 }
439
440 if self.should_emit_capture_wrapper() {
441 push_schema(
442 "CaptureWrapperSchema".to_string(),
443 self.generate_capture_wrapper_schema(),
444 false,
445 );
446 }
447
448 for (schema_name, definition) in self.generate_resolved_type_schemas(&patch_schema_types) {
449 push_schema(schema_name, definition, true);
450 }
451
452 for (schema_name, definition) in
453 self.generate_resolved_type_patch_schemas(&patch_schema_types)
454 {
455 push_schema(schema_name, definition, false);
456 }
457
458 for (schema_name, definition) in self.generate_event_schemas() {
459 push_schema(schema_name, definition, true);
460 }
461
462 for (schema_name, definition) in self.generate_idl_enum_schemas() {
463 push_schema(schema_name, definition, true);
464 }
465
466 let all_sections = self.collect_interface_sections();
467
468 for (section_name, fields) in &all_sections {
469 if is_root_section(section_name) {
470 continue;
471 }
472 let deduplicated_fields = self.deduplicate_fields(fields.clone());
473 let interface_name = self.section_interface_name(section_name);
474 let schema_definition = self.generate_schema_for_fields(
475 &interface_name,
476 &deduplicated_fields,
477 true,
478 SchemaMode::Canonical,
479 &patch_schema_types,
480 );
481 push_schema(format!("{}Schema", interface_name), schema_definition, true);
482
483 let patch_schema_definition = self.generate_schema_for_fields(
484 &interface_name,
485 &deduplicated_fields,
486 false,
487 SchemaMode::Patch,
488 &patch_schema_types,
489 );
490 push_schema(
491 format!("{}PatchSchema", interface_name),
492 patch_schema_definition,
493 false,
494 );
495 }
496
497 let entity_name = to_pascal_case(&self.entity_name);
498 let main_fields = self.collect_main_entity_fields();
499 let entity_schema = self.generate_schema_for_fields(
500 &entity_name,
501 &main_fields,
502 true,
503 SchemaMode::Canonical,
504 &patch_schema_types,
505 );
506 push_schema(format!("{}Schema", entity_name), entity_schema, true);
507
508 let patch_schema = self.generate_schema_for_fields(
509 &entity_name,
510 &main_fields,
511 false,
512 SchemaMode::Patch,
513 &patch_schema_types,
514 );
515 push_schema(format!("{}PatchSchema", entity_name), patch_schema, false);
516
517 let completed_schema =
518 self.generate_completed_entity_schema(&entity_name, &patch_schema_types);
519 push_schema(
520 format!("{}CompletedSchema", entity_name),
521 completed_schema,
522 true,
523 );
524
525 SchemaOutput {
526 definitions: definitions.join("\n\n"),
527 names,
528 }
529 }
530
531 fn generate_event_wrapper_schema(&self) -> String {
532 r#"export const EventWrapperSchema = <T extends z.ZodTypeAny>(data: T) => z.object({
533 timestamp: z.number(),
534 data,
535 slot: z.number().optional(),
536 signature: z.string().optional(),
537});"#
538 .to_string()
539 }
540
541 fn generate_capture_wrapper_schema(&self) -> String {
542 r#"export const CaptureWrapperSchema = <T extends z.ZodTypeAny>(data: T) => z.object({
543 timestamp: z.number(),
544 account_address: z.string(),
545 data,
546 slot: z.number().optional(),
547 signature: z.string().optional(),
548}).transform((value) => ({
549 timestamp: value.timestamp,
550 accountAddress: value.account_address,
551 data: value.data,
552 ...(value.slot !== undefined ? { slot: value.slot } : {}),
553 ...(value.signature !== undefined ? { signature: value.signature } : {}),
554}));"#
555 .to_string()
556 }
557
558 fn generate_builtin_resolver_schemas(&self) -> Vec<(String, String)> {
559 let mut schemas = Vec::new();
560 let registry = crate::resolvers::builtin_resolver_registry();
561
562 for resolver in registry.definitions() {
563 let output_type = resolver.output_type();
564 let should_emit = self.uses_builtin_type(output_type)
565 && !self.already_emitted_types.contains(output_type);
566
567 let extra_types_used = if let Some(ts_schema) = resolver.typescript_schema() {
569 ts_schema.definition.lines().any(|line| {
571 let line = line.trim();
572 if let Some(rest) = line.strip_prefix("export const ") {
574 let parts: Vec<&str> = rest.split_whitespace().collect();
575 if parts.len() >= 2 && parts[1] == "=" {
576 let schema_name = parts[0];
578 if let Some(type_name) = schema_name.strip_suffix("Schema") {
579 return self.uses_builtin_type(type_name)
580 && !self.already_emitted_types.contains(type_name);
581 }
582 }
583 }
584 false
585 })
586 } else {
587 false
588 };
589
590 if (should_emit || extra_types_used)
591 && !self.already_emitted_types.contains(output_type)
592 {
593 if let Some(schema) = resolver.typescript_schema() {
594 schemas.push((schema.name.to_string(), schema.definition.to_string()));
595 }
596 }
597 }
598
599 schemas
600 }
601
602 fn uses_builtin_type(&self, type_name: &str) -> bool {
603 for section in &self.spec.sections {
605 for field in §ion.fields {
606 if field.inner_type.as_deref() == Some(type_name) {
607 return true;
608 }
609 }
610 }
611 for field_info in self.spec.field_mappings.values() {
613 if field_info.inner_type.as_deref() == Some(type_name) {
614 return true;
615 }
616 }
617 false
618 }
619
620 fn generate_builtin_resolver_interfaces(&self) -> Vec<String> {
621 let mut interfaces = Vec::new();
622 let registry = crate::resolvers::builtin_resolver_registry();
623
624 for resolver in registry.definitions() {
625 let output_type = resolver.output_type();
626 let should_emit = self.uses_builtin_type(output_type)
627 && !self.already_emitted_types.contains(output_type);
628
629 let extra_types_used = if let Some(ts_interface) = resolver.typescript_interface() {
631 ts_interface.lines().any(|line| {
633 let line = line.trim();
634 if let Some(rest) = line.strip_prefix("export type ") {
636 if let Some(type_name) = rest.split_whitespace().next() {
637 return self.uses_builtin_type(type_name)
638 && !self.already_emitted_types.contains(type_name);
639 }
640 } else if let Some(rest) = line.strip_prefix("export interface ") {
641 if let Some(type_name) = rest.split_whitespace().next() {
642 return self.uses_builtin_type(type_name)
643 && !self.already_emitted_types.contains(type_name);
644 }
645 }
646 false
647 })
648 } else {
649 false
650 };
651
652 if should_emit || extra_types_used {
653 if let Some(interface) = resolver.typescript_interface() {
654 interfaces.push(interface.to_string());
655 }
656 }
657 }
658
659 interfaces
660 }
661
662 fn collect_main_entity_fields(&self) -> Vec<TypeScriptField> {
663 let mut sections = BTreeMap::new();
664
665 for handler in &self.spec.handlers {
666 for mapping in &handler.mappings {
667 if !mapping.emit {
668 continue;
669 }
670 let parts: Vec<&str> = mapping.target_path.split('.').collect();
671 if parts.len() > 1 {
672 sections.insert(parts[0], true);
673 }
674 }
675 }
676
677 if !self.spec.sections.is_empty() {
678 for section in &self.spec.sections {
679 if section.fields.iter().any(|field| field.emit) {
680 sections.insert(§ion.name, true);
681 }
682 }
683 } else {
684 for mapping in &self.spec.handlers {
685 for field_mapping in &mapping.mappings {
686 if !field_mapping.emit {
687 continue;
688 }
689 let parts: Vec<&str> = field_mapping.target_path.split('.').collect();
690 if parts.len() > 1 {
691 sections.insert(parts[0], true);
692 }
693 }
694 }
695 }
696
697 let mut fields = Vec::new();
698
699 for section in sections.keys() {
700 if !is_root_section(section) {
701 let base_name = if self.entity_name.contains("Game") {
702 "Game"
703 } else {
704 &self.entity_name
705 };
706 let section_interface_name = format!("{}{}", base_name, to_pascal_case(section));
707 fields.push(TypeScriptField::patch(
708 section.to_string(),
709 section_interface_name,
710 false,
711 ));
712 }
713 }
714
715 for section in &self.spec.sections {
716 if is_root_section(§ion.name) {
717 for field in §ion.fields {
718 if !field.emit {
719 continue;
720 }
721 fields.push(TypeScriptField::from_names(
722 field.raw_field_name().to_string(),
723 field.canonical_field_name(),
724 self.field_type_info_to_typescript(field),
725 field.is_optional,
726 FieldPresence::Patch,
727 ));
728 }
729 }
730 }
731
732 fields
733 }
734
735 fn generate_schema_for_fields(
736 &self,
737 name: &str,
738 fields: &[TypeScriptField],
739 required: bool,
740 mode: SchemaMode,
741 patch_schema_types: &HashSet<String>,
742 ) -> String {
743 if fields.is_empty() {
744 return format!(
745 "export const {} = z.object({{}});",
746 schema_constant_name(name, mode)
747 );
748 }
749
750 let mut field_definitions = Vec::new();
751 let mut transform_fields = Vec::new();
752
753 for field in fields {
754 let base_schema = field.zod_schema.clone().unwrap_or_else(|| {
755 self.typescript_type_to_zod_for_schema(&field.ts_type, mode, patch_schema_types)
756 });
757 let with_nullable = if field.nullable {
758 format!("{}.nullable()", base_schema)
759 } else {
760 base_schema
761 };
762 let nullable_keys_can_be_absent = matches!(mode, SchemaMode::Canonical);
763 let schema = if required || matches!(field.presence, FieldPresence::Required) {
764 if field.nullable && nullable_keys_can_be_absent {
765 format!("{}.optional()", with_nullable)
769 } else {
770 with_nullable
771 }
772 } else {
773 format!("{}.optional()", with_nullable)
774 };
775
776 field_definitions.push(format!(" {}: {},", field.raw_name, schema));
777 if mode == SchemaMode::Patch {
778 transform_fields.push(format!(
779 " ...(value.{raw_name} !== undefined ? {{ {field_name}: value.{raw_name} }} : {{}}),",
780 raw_name = field.raw_name,
781 field_name = field.name,
782 ));
783 } else {
784 transform_fields.push(format!(" {}: value.{},", field.name, field.raw_name));
785 }
786 }
787
788 format!(
789 "export const {} = z.object({{\n{}\n}}).transform((value) => ({{\n{}\n}}));",
790 schema_constant_name(name, mode),
791 field_definitions.join("\n"),
792 transform_fields.join("\n")
793 )
794 }
795
796 fn generate_completed_entity_schema(
797 &self,
798 entity_name: &str,
799 patch_schema_types: &HashSet<String>,
800 ) -> String {
801 let main_fields = self.collect_main_entity_fields();
802 self.generate_schema_for_fields(
803 &format!("{}Completed", entity_name),
804 &main_fields,
805 true,
806 SchemaMode::Canonical,
807 patch_schema_types,
808 )
809 }
810
811 fn generate_resolved_type_schemas(
812 &self,
813 patch_schema_types: &HashSet<String>,
814 ) -> Vec<(String, String)> {
815 let mut schemas = Vec::new();
816 let mut generated_types = HashSet::new();
817 let resolved_name_map = self.build_resolved_type_name_map();
818
819 for section in &self.spec.sections {
820 for field_info in §ion.fields {
821 if let Some(resolved) = &field_info.resolved_type {
822 let type_name =
823 self.resolved_type_to_interface_name_with_map(resolved, &resolved_name_map);
824
825 if !generated_types.insert(type_name.clone()) {
826 continue;
827 }
828
829 if resolved.is_enum {
830 let variants: Vec<String> = resolved
831 .enum_variants
832 .iter()
833 .map(|v| format!("\"{}\"", to_pascal_case(v)))
834 .collect();
835 let schema = if variants.is_empty() {
836 format!("export const {}Schema = z.string();", type_name)
837 } else {
838 format!(
839 "export const {}Schema = z.enum([{}]);",
840 type_name,
841 variants.join(", ")
842 )
843 };
844 schemas.push((format!("{}Schema", type_name), schema));
845 continue;
846 }
847
848 let schema = self.generate_schema_for_fields(
849 &type_name,
850 &self.resolved_fields_to_typescript_fields(&resolved.fields),
851 true,
852 SchemaMode::Canonical,
853 patch_schema_types,
854 );
855 schemas.push((format!("{}Schema", type_name), schema));
856 }
857 }
858 }
859
860 schemas
861 }
862
863 fn generate_resolved_type_patch_schemas(
864 &self,
865 patch_schema_types: &HashSet<String>,
866 ) -> Vec<(String, String)> {
867 let mut schemas = Vec::new();
868 let mut generated_types = HashSet::new();
869 let resolved_name_map = self.build_resolved_type_name_map();
870
871 for section in &self.spec.sections {
872 for field_info in §ion.fields {
873 if let Some(resolved) = &field_info.resolved_type {
874 let type_name =
875 self.resolved_type_to_interface_name_with_map(resolved, &resolved_name_map);
876
877 if !generated_types.insert(type_name.clone()) || resolved.is_enum {
878 continue;
879 }
880
881 let schema = self.generate_schema_for_fields(
882 &type_name,
883 &self.resolved_fields_to_typescript_fields(&resolved.fields),
884 false,
885 SchemaMode::Patch,
886 patch_schema_types,
887 );
888 schemas.push((format!("{}PatchSchema", type_name), schema));
889 }
890 }
891 }
892
893 schemas
894 }
895
896 fn generate_event_schemas(&self) -> Vec<(String, String)> {
897 let mut schemas = Vec::new();
898 let mut generated_types = HashSet::new();
899
900 let handlers = match &self.handlers_json {
901 Some(h) => h.as_array(),
902 None => return schemas,
903 };
904
905 let handlers_array = match handlers {
906 Some(arr) => arr,
907 None => return schemas,
908 };
909
910 for handler in handlers_array {
911 if let Some(mappings) = handler.get("mappings").and_then(|m| m.as_array()) {
912 for mapping in mappings {
913 if let Some(target_path) = mapping.get("target_path").and_then(|t| t.as_str()) {
914 if target_path.contains(".events.") || target_path.starts_with("events.") {
915 if let Some(source) = mapping.get("source") {
916 if let Some(event_data) = self.extract_event_data(source) {
917 if let Some(handler_source) = handler.get("source") {
918 if let Some(instruction_name) =
919 self.extract_instruction_name(handler_source)
920 {
921 let event_field_name =
922 target_path.split('.').next_back().unwrap_or("");
923 let interface_name = format!(
924 "{}Event",
925 to_pascal_case(event_field_name)
926 );
927
928 if generated_types.insert(interface_name.clone()) {
929 if let Some(schema) = self
930 .generate_event_schema_from_idl(
931 &interface_name,
932 &instruction_name,
933 &event_data,
934 )
935 {
936 schemas.push((
937 format!("{}Schema", interface_name),
938 schema,
939 ));
940 }
941 }
942 }
943 }
944 }
945 }
946 }
947 }
948 }
949 }
950 }
951
952 schemas
953 }
954
955 fn generate_event_schema_from_idl(
956 &self,
957 interface_name: &str,
958 rust_instruction_name: &str,
959 captured_fields: &[(String, Option<String>)],
960 ) -> Option<String> {
961 if captured_fields.is_empty() {
962 return Some(format!(
963 "export const {}Schema = z.object({{}});",
964 interface_name
965 ));
966 }
967
968 let idl_value = self.idl.as_ref()?;
969 let instructions = idl_value.get("instructions")?.as_array()?;
970
971 let instruction = self.find_instruction_in_idl(instructions, rust_instruction_name)?;
972 let args = instruction.get("args")?.as_array()?;
973
974 let mut fields = Vec::new();
975 for (field_name, transform) in captured_fields {
976 for arg in args {
977 if let Some(arg_name) = arg.get("name").and_then(|n| n.as_str()) {
978 if arg_name == field_name {
979 if let Some(arg_type) = arg.get("type") {
980 let ts_type =
981 self.idl_type_to_typescript(arg_type, transform.as_deref());
982 fields.push(TypeScriptField::patch(
983 field_name.to_string(),
984 ts_type,
985 false,
986 ));
987 }
988 break;
989 }
990 }
991 }
992 }
993
994 Some(render_schema_from_ts_fields(interface_name, &fields, true))
995 }
996
997 fn generate_idl_enum_schemas(&self) -> Vec<(String, String)> {
998 let mut schemas = Vec::new();
999 let mut generated_types = self.already_emitted_types.clone();
1000
1001 let idl_value = match &self.idl {
1002 Some(idl) => idl,
1003 None => return schemas,
1004 };
1005
1006 let types_array = match idl_value.get("types").and_then(|v| v.as_array()) {
1007 Some(types) => types,
1008 None => return schemas,
1009 };
1010
1011 for type_def in types_array {
1012 if let (Some(type_name), Some(type_obj)) = (
1013 type_def.get("name").and_then(|v| v.as_str()),
1014 type_def.get("type").and_then(|v| v.as_object()),
1015 ) {
1016 if type_obj.get("kind").and_then(|v| v.as_str()) == Some("enum") {
1017 let interface_name = to_pascal_case(type_name);
1018 if !generated_types.insert(interface_name.clone()) {
1019 continue;
1020 }
1021 if let Some(variants) = type_obj.get("variants").and_then(|v| v.as_array()) {
1022 let variant_names: Vec<String> = variants
1023 .iter()
1024 .filter_map(|v| v.get("name").and_then(|n| n.as_str()))
1025 .map(|s| format!("\"{}\"", to_pascal_case(s)))
1026 .collect();
1027
1028 let schema = if variant_names.is_empty() {
1029 format!("export const {}Schema = z.string();", interface_name)
1030 } else {
1031 format!(
1032 "export const {}Schema = z.enum([{}]);",
1033 interface_name,
1034 variant_names.join(", ")
1035 )
1036 };
1037 schemas.push((format!("{}Schema", interface_name), schema));
1038 }
1039 }
1040 }
1041 }
1042
1043 schemas
1044 }
1045
1046 fn typescript_type_to_zod_for_schema(
1047 &self,
1048 ts_type: &str,
1049 mode: SchemaMode,
1050 patch_schema_types: &HashSet<String>,
1051 ) -> String {
1052 typescript_type_to_zod_for_schema_static(ts_type, mode, patch_schema_types)
1053 }
1054
1055 fn patch_schema_type_names(&self) -> HashSet<String> {
1056 let mut names = HashSet::new();
1057 let resolved_name_map = self.build_resolved_type_name_map();
1058
1059 for section in &self.spec.sections {
1060 if !is_root_section(§ion.name) && section.fields.iter().any(|field| field.emit) {
1061 names.insert(self.section_interface_name(§ion.name));
1062 }
1063
1064 for field in §ion.fields {
1065 let Some(resolved) = &field.resolved_type else {
1066 continue;
1067 };
1068
1069 if resolved.is_enum {
1070 continue;
1071 }
1072
1073 names.insert(
1074 self.resolved_type_to_interface_name_with_map(resolved, &resolved_name_map),
1075 );
1076 }
1077 }
1078
1079 names.insert("TokenMetadata".to_string());
1080 names
1081 }
1082
1083 fn generate_stack_definition(&self, state_view_key: &StateViewKeyDefinition) -> String {
1084 let stack_name = to_kebab_case(&self.entity_name);
1085 let entity_pascal = to_pascal_case(&self.entity_name);
1086 let export_name = format!(
1087 "{}_{}",
1088 self.entity_name.to_uppercase(),
1089 self.config.export_const_name
1090 );
1091
1092 let view_helpers = self.generate_view_helpers();
1093 let derived_views = self.generate_derived_view_entries();
1094 let schema_names = self.generate_schemas().names;
1095 let mut unique_schemas: BTreeSet<String> = BTreeSet::new();
1096 for name in schema_names {
1097 unique_schemas.insert(name);
1098 }
1099 let schemas_block = if unique_schemas.is_empty() {
1100 String::new()
1101 } else {
1102 let schema_entries: Vec<String> = unique_schemas
1103 .iter()
1104 .filter(|name| name.ends_with("Schema") && !name.ends_with("PatchSchema"))
1105 .map(|name| format!(" {}: {},", name.trim_end_matches("Schema"), name))
1106 .collect();
1107 if schema_entries.is_empty() {
1108 String::new()
1109 } else {
1110 format!("\n schemas: {{\n{}\n }},", schema_entries.join("\n"))
1111 }
1112 };
1113
1114 let patch_schemas_block = format!(
1115 "\n patchSchemas: {{\n {entity}: {entity}PatchSchema,\n }},",
1116 entity = entity_pascal
1117 );
1118
1119 let url_line = match &self.config.url {
1121 Some(url) => format!(" url: '{}',", url),
1122 None => " url: '', // TODO: Set after first deployment or pass useArete(..., { url })"
1123 .to_string(),
1124 };
1125
1126 format!(
1127 r#"{}
1128
1129// ============================================================================
1130// Stack Definition
1131// ============================================================================
1132
1133/** Stack definition for {} */
1134export const {} = {{
1135 name: '{}',
1136{}
1137 views: {{
1138 {}: {{
1139 state: stateView<{}, {}>('{}/state', {}),
1140 list: listView<{}>('{}/list'),{}
1141 }},
1142 }},{}{}
1143}} as const;
1144
1145/** Type alias for the stack */
1146export type {}Stack = typeof {};
1147
1148/** Default export for convenience */
1149export default {};"#,
1150 view_helpers,
1151 entity_pascal,
1152 export_name,
1153 stack_name,
1154 url_line,
1155 self.entity_name,
1156 entity_pascal,
1157 state_view_key.object_type(),
1158 self.entity_name,
1159 state_view_key.fields_literal(),
1160 entity_pascal,
1161 self.entity_name,
1162 derived_views,
1163 schemas_block,
1164 patch_schemas_block,
1165 entity_pascal,
1166 export_name,
1167 export_name
1168 )
1169 }
1170
1171 fn generate_derived_view_entries(&self) -> String {
1172 let derived_views: Vec<&ViewDef> = self
1173 .views
1174 .iter()
1175 .filter(|v| {
1176 !v.id.ends_with("/state")
1177 && !v.id.ends_with("/list")
1178 && v.id.starts_with(&self.entity_name)
1179 })
1180 .collect();
1181
1182 if derived_views.is_empty() {
1183 return String::new();
1184 }
1185
1186 let entity_pascal = to_pascal_case(&self.entity_name);
1187 let mut entries = Vec::new();
1188
1189 for view in derived_views {
1190 let view_name = view.id.split('/').nth(1).unwrap_or("unknown");
1191
1192 entries.push(format!(
1193 "\n {}: listView<{}>('{}'),",
1194 view_name, entity_pascal, view.id
1195 ));
1196 }
1197
1198 entries.join("")
1199 }
1200
1201 fn mapping_to_typescript_type(&self, mapping: &TypedFieldMapping<S>) -> String {
1202 if let Some(field_info) = self.spec.field_mappings.get(&mapping.target_path) {
1204 let ts_type = self.field_type_info_to_typescript(field_info);
1205
1206 if matches!(mapping.population, PopulationStrategy::Append) {
1208 return if ts_type.ends_with("[]") {
1209 ts_type
1210 } else {
1211 format!("{}[]", ts_type)
1212 };
1213 }
1214
1215 return ts_type;
1216 }
1217
1218 match &mapping.population {
1220 PopulationStrategy::Append => {
1221 match &mapping.source {
1223 MappingSource::AsEvent { .. } => "any[]".to_string(),
1224 _ => "any[]".to_string(),
1225 }
1226 }
1227 _ => {
1228 let base_type = match &mapping.source {
1230 MappingSource::FromSource { .. } => {
1231 self.infer_type_from_field_name(&mapping.target_path)
1232 }
1233 MappingSource::Constant(value) => value_to_typescript_type(value),
1234 MappingSource::AsEvent { .. } => "any".to_string(),
1235 _ => "any".to_string(),
1236 };
1237
1238 if let Some(transform) = &mapping.transform {
1240 match transform {
1241 Transformation::HexEncode | Transformation::HexDecode => {
1242 "string".to_string()
1243 }
1244 Transformation::Base58Encode | Transformation::Base58Decode => {
1245 "string".to_string()
1246 }
1247 Transformation::ToString => "string".to_string(),
1248 Transformation::ToNumber => "number".to_string(),
1249 }
1250 } else {
1251 base_type
1252 }
1253 }
1254 }
1255 }
1256
1257 fn field_type_info_to_typescript(&self, field_info: &FieldTypeInfo) -> String {
1258 if let Some(resolved) = &field_info.resolved_type {
1259 let interface_name = self.resolved_type_to_interface_name(resolved);
1260
1261 let base_type = if resolved.is_event || (resolved.is_instruction && field_info.is_array)
1262 {
1263 format!("EventWrapper<{}>", interface_name)
1264 } else if resolved.is_account && self.is_capture_field(field_info) {
1265 format!("CaptureWrapper<{}>", interface_name)
1266 } else {
1267 interface_name
1268 };
1269
1270 let with_array = if field_info.is_array {
1271 format!("{}[]", base_type)
1272 } else {
1273 base_type
1274 };
1275
1276 return with_array;
1277 }
1278
1279 if let Some(inner_type) = &field_info.inner_type {
1280 if is_builtin_resolver_type(inner_type) {
1281 return inner_type.clone();
1282 }
1283 }
1284
1285 if let Some(ts_type) = typescript_integer_type(
1286 field_info.effective_integer_kind(),
1287 field_info
1288 .inner_type
1289 .as_deref()
1290 .or(Some(field_info.rust_type_name.as_str())),
1291 ) {
1292 return if field_info.is_array {
1293 format!("{}[]", ts_type)
1294 } else {
1295 ts_type.to_string()
1296 };
1297 }
1298
1299 if field_info.base_type == BaseType::Array && field_info.is_array {
1302 if let Some(element) = field_info
1303 .inner_type
1304 .as_deref()
1305 .and_then(typescript_scalar_array_element)
1306 {
1307 return format!("{}[]", element);
1308 }
1309 }
1310
1311 if field_info.base_type == BaseType::Any
1312 || (field_info.base_type == BaseType::Array
1313 && field_info.inner_type.as_deref() == Some("Value"))
1314 {
1315 if let Some(event_type) = self.find_event_interface_for_field(&field_info.field_name) {
1316 return if field_info.is_array {
1317 format!("{}[]", event_type)
1318 } else {
1319 event_type
1320 };
1321 }
1322 }
1323
1324 self.base_type_to_typescript(
1325 &field_info.base_type,
1326 field_info.effective_integer_kind(),
1327 field_info.is_array,
1328 )
1329 }
1330
1331 fn find_event_interface_for_field(&self, field_name: &str) -> Option<String> {
1333 let handlers = self.handlers_json.as_ref()?.as_array()?;
1335
1336 for handler in handlers {
1338 if let Some(mappings) = handler.get("mappings").and_then(|m| m.as_array()) {
1339 for mapping in mappings {
1340 if let Some(target_path) = mapping.get("target_path").and_then(|t| t.as_str()) {
1341 let target_parts: Vec<&str> = target_path.split('.').collect();
1343 if let Some(target_field) = target_parts.last() {
1344 if *target_field == field_name {
1345 if let Some(source) = mapping.get("source") {
1347 if self.extract_event_data(source).is_some() {
1348 return Some(format!(
1350 "{}Event",
1351 to_pascal_case(field_name)
1352 ));
1353 }
1354 }
1355 }
1356 }
1357 }
1358 }
1359 }
1360 }
1361 None
1362 }
1363
1364 fn resolved_type_to_interface_name(&self, resolved: &ResolvedStructType) -> String {
1366 self.build_resolved_type_name_map()
1367 .get(&resolved.type_name)
1368 .cloned()
1369 .unwrap_or_else(|| to_pascal_case(&resolved.type_name))
1370 }
1371
1372 fn generate_nested_interfaces(&self) -> Vec<String> {
1374 let mut interfaces = Vec::new();
1375 let mut generated_types = self.already_emitted_types.clone();
1376 let resolved_name_map = self.build_resolved_type_name_map();
1377
1378 for section in &self.spec.sections {
1380 for field_info in §ion.fields {
1381 if let Some(resolved) = &field_info.resolved_type {
1382 let type_name =
1383 self.resolved_type_to_interface_name_with_map(resolved, &resolved_name_map);
1384
1385 if generated_types.insert(type_name) {
1387 let interface = self.generate_interface_for_resolved_type(resolved);
1388 interfaces.push(interface);
1389 }
1390 }
1391 }
1392 }
1393
1394 interfaces.extend(self.generate_event_interfaces(&mut generated_types));
1396
1397 if let Some(idl_value) = &self.idl {
1399 if let Some(types_array) = idl_value.get("types").and_then(|v| v.as_array()) {
1400 for type_def in types_array {
1401 if let (Some(type_name), Some(type_obj)) = (
1402 type_def.get("name").and_then(|v| v.as_str()),
1403 type_def.get("type").and_then(|v| v.as_object()),
1404 ) {
1405 if type_obj.get("kind").and_then(|v| v.as_str()) == Some("enum") {
1406 let interface_name = to_pascal_case(type_name);
1408 if generated_types.insert(interface_name.clone()) {
1409 if let Some(variants) =
1410 type_obj.get("variants").and_then(|v| v.as_array())
1411 {
1412 let variant_names: Vec<String> = variants
1413 .iter()
1414 .filter_map(|v| {
1415 v.get("name")
1416 .and_then(|n| n.as_str())
1417 .map(|s| s.to_string())
1418 })
1419 .collect();
1420
1421 if !variant_names.is_empty() {
1422 let variant_strings: Vec<String> = variant_names
1423 .iter()
1424 .map(|v| format!("\"{}\"", to_pascal_case(v)))
1425 .collect();
1426
1427 let enum_type = format!(
1428 "export type {} = {};",
1429 interface_name,
1430 variant_strings.join(" | ")
1431 );
1432 interfaces.push(enum_type);
1433 }
1434 }
1435 }
1436 }
1437 }
1438 }
1439 }
1440 }
1441
1442 interfaces
1443 }
1444
1445 fn generate_event_interfaces(&self, generated_types: &mut HashSet<String>) -> Vec<String> {
1447 let mut interfaces = Vec::new();
1448
1449 let handlers = match &self.handlers_json {
1451 Some(h) => h.as_array(),
1452 None => return interfaces,
1453 };
1454
1455 let handlers_array = match handlers {
1456 Some(arr) => arr,
1457 None => return interfaces,
1458 };
1459
1460 for handler in handlers_array {
1462 if let Some(mappings) = handler.get("mappings").and_then(|m| m.as_array()) {
1464 for mapping in mappings {
1465 if let Some(target_path) = mapping.get("target_path").and_then(|t| t.as_str()) {
1466 if target_path.contains(".events.") || target_path.starts_with("events.") {
1468 if let Some(source) = mapping.get("source") {
1470 if let Some(event_data) = self.extract_event_data(source) {
1471 if let Some(handler_source) = handler.get("source") {
1473 if let Some(instruction_name) =
1474 self.extract_instruction_name(handler_source)
1475 {
1476 let event_field_name =
1478 target_path.split('.').next_back().unwrap_or("");
1479 let interface_name = format!(
1480 "{}Event",
1481 to_pascal_case(event_field_name)
1482 );
1483
1484 if generated_types.insert(interface_name.clone()) {
1486 if let Some(interface) = self
1487 .generate_event_interface_from_idl(
1488 &interface_name,
1489 &instruction_name,
1490 &event_data,
1491 )
1492 {
1493 interfaces.push(interface);
1494 }
1495 }
1496 }
1497 }
1498 }
1499 }
1500 }
1501 }
1502 }
1503 }
1504 }
1505
1506 interfaces
1507 }
1508
1509 fn extract_event_data(
1511 &self,
1512 source: &serde_json::Value,
1513 ) -> Option<Vec<(String, Option<String>)>> {
1514 if let Some(as_event) = source.get("AsEvent") {
1515 if let Some(fields) = as_event.get("fields").and_then(|f| f.as_array()) {
1516 let mut event_fields = Vec::new();
1517 for field in fields {
1518 if let Some(from_source) = field.get("FromSource") {
1519 if let Some(path) = from_source
1520 .get("path")
1521 .and_then(|p| p.get("segments"))
1522 .and_then(|s| s.as_array())
1523 {
1524 if let Some(field_name) = path.last().and_then(|v| v.as_str()) {
1526 let transform = from_source
1527 .get("transform")
1528 .and_then(|t| t.as_str())
1529 .map(|s| s.to_string());
1530 event_fields.push((field_name.to_string(), transform));
1531 }
1532 }
1533 }
1534 }
1535 return Some(event_fields);
1536 }
1537 }
1538 None
1539 }
1540
1541 fn extract_instruction_name(&self, source: &serde_json::Value) -> Option<String> {
1543 if let Some(source_obj) = source.get("Source") {
1544 if let Some(type_name) = source_obj.get("type_name").and_then(|t| t.as_str()) {
1545 let instruction_part =
1546 crate::event_type_helpers::strip_event_type_suffix(type_name);
1547 return Some(instruction_part.to_string());
1548 }
1549 }
1550 None
1551 }
1552
1553 fn find_instruction_in_idl<'a>(
1557 &self,
1558 instructions: &'a [serde_json::Value],
1559 rust_name: &str,
1560 ) -> Option<&'a serde_json::Value> {
1561 let normalized_search = normalize_for_comparison(rust_name);
1562
1563 for instruction in instructions {
1564 if let Some(idl_name) = instruction.get("name").and_then(|n| n.as_str()) {
1565 if normalize_for_comparison(idl_name) == normalized_search {
1566 return Some(instruction);
1567 }
1568 }
1569 }
1570 None
1571 }
1572
1573 fn generate_event_interface_from_idl(
1575 &self,
1576 interface_name: &str,
1577 rust_instruction_name: &str,
1578 captured_fields: &[(String, Option<String>)],
1579 ) -> Option<String> {
1580 if captured_fields.is_empty() {
1581 return Some(format!("export interface {} {{}}", interface_name));
1582 }
1583
1584 let idl_value = self.idl.as_ref()?;
1585 let instructions = idl_value.get("instructions")?.as_array()?;
1586
1587 let instruction = self.find_instruction_in_idl(instructions, rust_instruction_name)?;
1588 let args = instruction.get("args")?.as_array()?;
1589
1590 let mut fields = Vec::new();
1591 for (field_name, transform) in captured_fields {
1592 for arg in args {
1593 if let Some(arg_name) = arg.get("name").and_then(|n| n.as_str()) {
1594 if arg_name == field_name {
1595 if let Some(arg_type) = arg.get("type") {
1596 let ts_type =
1597 self.idl_type_to_typescript(arg_type, transform.as_deref());
1598 fields.push(TypeScriptField::patch(
1599 field_name.to_string(),
1600 ts_type,
1601 false,
1602 ));
1603 }
1604 break;
1605 }
1606 }
1607 }
1608 }
1609
1610 if !fields.is_empty() {
1611 return Some(render_interface_from_ts_fields(
1612 interface_name,
1613 &fields,
1614 true,
1615 ));
1616 }
1617
1618 None
1619 }
1620
1621 fn idl_type_to_typescript(
1623 &self,
1624 idl_type: &serde_json::Value,
1625 transform: Option<&str>,
1626 ) -> String {
1627 #![allow(clippy::only_used_in_recursion)]
1628 if transform == Some("HexEncode") {
1630 return "string".to_string();
1631 }
1632
1633 if let Some(type_str) = idl_type.as_str() {
1635 return match type_str {
1636 "u64" | "u128" | "i64" | "i128" => "bigint".to_string(),
1637 "u8" | "u16" | "u32" | "i8" | "i16" | "i32" => "number".to_string(),
1638 "f32" | "f64" => "number".to_string(),
1639 "bool" => "boolean".to_string(),
1640 "string" => "string".to_string(),
1641 "pubkey" | "publicKey" => "string".to_string(),
1642 "bytes" => "string".to_string(),
1643 _ => "any".to_string(),
1644 };
1645 }
1646
1647 if let Some(type_obj) = idl_type.as_object() {
1649 if let Some(option_type) = type_obj.get("option") {
1650 let inner = self.idl_type_to_typescript(option_type, None);
1651 return format!("{} | null", inner);
1652 }
1653 if let Some(vec_type) = type_obj.get("vec") {
1654 let inner = self.idl_type_to_typescript(vec_type, None);
1655 return format!("{}[]", inner);
1656 }
1657 }
1658
1659 "any".to_string()
1660 }
1661
1662 fn generate_interface_for_resolved_type(&self, resolved: &ResolvedStructType) -> String {
1664 let interface_name = self.resolved_type_to_interface_name(resolved);
1665
1666 if resolved.is_enum {
1668 let variants: Vec<String> = resolved
1669 .enum_variants
1670 .iter()
1671 .map(|v| format!("\"{}\"", to_pascal_case(v)))
1672 .collect();
1673
1674 return format!("export type {} = {};", interface_name, variants.join(" | "));
1675 }
1676
1677 render_interface_from_ts_fields(
1678 &interface_name,
1679 &self.resolved_fields_to_typescript_fields(&resolved.fields),
1680 true,
1681 )
1682 }
1683
1684 fn resolved_field_to_typescript(&self, field: &ResolvedField) -> String {
1686 if let Some(ts_type) =
1687 typescript_integer_type(field.effective_integer_kind(), Some(&field.field_type))
1688 {
1689 return if field.is_array {
1690 format!("{}[]", ts_type)
1691 } else {
1692 ts_type.to_string()
1693 };
1694 }
1695 let base_ts =
1696 self.base_type_to_typescript(&field.base_type, field.effective_integer_kind(), false);
1697
1698 if field.is_array {
1699 format!("{}[]", base_ts)
1700 } else {
1701 base_ts
1702 }
1703 }
1704
1705 fn resolved_fields_to_typescript_fields(
1706 &self,
1707 fields: &[ResolvedField],
1708 ) -> Vec<TypeScriptField> {
1709 fields
1710 .iter()
1711 .map(|field| {
1712 TypeScriptField::from_names(
1713 field.raw_field_name().to_string(),
1714 field.canonical_field_name(),
1715 self.resolved_field_to_typescript(field),
1716 field.is_optional,
1717 FieldPresence::Patch,
1718 )
1719 })
1720 .collect()
1721 }
1722
1723 fn has_event_types(&self) -> bool {
1725 for section in &self.spec.sections {
1726 for field_info in §ion.fields {
1727 if let Some(resolved) = &field_info.resolved_type {
1728 if resolved.is_event || (resolved.is_instruction && field_info.is_array) {
1729 return true;
1730 }
1731 }
1732 }
1733 }
1734 false
1735 }
1736
1737 fn has_capture_types(&self) -> bool {
1738 self.spec
1739 .sections
1740 .iter()
1741 .flat_map(|section| §ion.fields)
1742 .any(|field| self.is_capture_field(field))
1743 }
1744
1745 fn should_emit_capture_wrapper(&self) -> bool {
1746 self.has_capture_types() && !self.already_emitted_types.contains("CaptureWrapper")
1747 }
1748
1749 fn is_capture_field(&self, field_info: &FieldTypeInfo) -> bool {
1750 let raw_name = field_info.raw_field_name();
1751 self.spec.handlers.iter().any(|handler| {
1752 handler.mappings.iter().any(|mapping| {
1753 matches!(&mapping.source, MappingSource::AsCapture { .. })
1754 && (mapping.target_path == field_info.field_name
1755 || mapping.target_path == raw_name)
1756 })
1757 })
1758 }
1759
1760 fn build_resolved_type_name_map(&self) -> HashMap<String, String> {
1761 let mut reserved_names = self.already_emitted_types.clone();
1762 reserved_names.insert(to_pascal_case(&self.entity_name));
1763
1764 for section in &self.spec.sections {
1765 if !is_root_section(§ion.name) && section.fields.iter().any(|field| field.emit) {
1766 reserved_names.insert(self.section_interface_name(§ion.name));
1767 }
1768 }
1769
1770 let mut resolved_name_map = HashMap::new();
1771
1772 for section in &self.spec.sections {
1773 for field_info in §ion.fields {
1774 if !field_info.emit {
1775 continue;
1776 }
1777
1778 let Some(resolved) = &field_info.resolved_type else {
1779 continue;
1780 };
1781
1782 if resolved_name_map.contains_key(&resolved.type_name) {
1783 continue;
1784 }
1785
1786 let emitted_name = unique_resolved_type_name_ts(resolved, &mut reserved_names);
1787 resolved_name_map.insert(resolved.type_name.clone(), emitted_name);
1788 }
1789 }
1790
1791 resolved_name_map
1792 }
1793
1794 fn resolved_type_to_interface_name_with_map(
1795 &self,
1796 resolved: &ResolvedStructType,
1797 resolved_name_map: &HashMap<String, String>,
1798 ) -> String {
1799 resolved_name_map
1800 .get(&resolved.type_name)
1801 .cloned()
1802 .unwrap_or_else(|| to_pascal_case(&resolved.type_name))
1803 }
1804
1805 fn generate_event_wrapper_interface(&self) -> String {
1807 r#"/**
1808 * Wrapper for event data that includes context metadata.
1809 * Events are automatically wrapped in this structure at runtime.
1810 */
1811export interface EventWrapper<T> {
1812 /** Unix timestamp when the event was processed */
1813 timestamp: number;
1814 /** The event-specific data */
1815 data: T;
1816 /** Optional blockchain slot number */
1817 slot?: number;
1818 /** Optional transaction signature */
1819 signature?: string;
1820}"#
1821 .to_string()
1822 }
1823
1824 fn generate_capture_wrapper_interface(&self) -> String {
1825 r#"/**
1826 * Wrapper for account data captured with context metadata.
1827 */
1828export interface CaptureWrapper<T> {
1829 /** Unix timestamp when the account was captured */
1830 timestamp: number;
1831 /** Base58 account address */
1832 accountAddress: string;
1833 /** Captured account data */
1834 data: T;
1835 /** Optional blockchain slot number */
1836 slot?: number;
1837 /** Optional transaction signature */
1838 signature?: string;
1839}"#
1840 .to_string()
1841 }
1842
1843 fn infer_type_from_field_name(&self, field_name: &str) -> String {
1844 let lower_name = field_name.to_lowercase();
1845
1846 if lower_name.contains("events.") {
1848 return "any".to_string();
1850 }
1851
1852 if lower_name.contains("id")
1854 || lower_name.contains("count")
1855 || lower_name.contains("number")
1856 || lower_name.contains("timestamp")
1857 || lower_name.contains("time")
1858 || lower_name.contains("at")
1859 || lower_name.contains("volume")
1860 || lower_name.contains("amount")
1861 || lower_name.contains("ev")
1862 || lower_name.contains("fee")
1863 || lower_name.contains("payout")
1864 || lower_name.contains("distributed")
1865 || lower_name.contains("claimable")
1866 || lower_name.contains("total")
1867 || lower_name.contains("rate")
1868 || lower_name.contains("ratio")
1869 || lower_name.contains("current")
1870 || lower_name.contains("state")
1871 {
1872 "number".to_string()
1873 } else if lower_name.contains("status")
1874 || lower_name.contains("hash")
1875 || lower_name.contains("address")
1876 || lower_name.contains("key")
1877 {
1878 "string".to_string()
1879 } else {
1880 "any".to_string()
1881 }
1882 }
1883
1884 fn is_field_nullable(&self, mapping: &TypedFieldMapping<S>) -> bool {
1885 match &mapping.source {
1888 MappingSource::Constant(_) => false,
1890 MappingSource::AsEvent { .. } => true,
1892 MappingSource::FromSource { .. } => true,
1894 _ => true,
1896 }
1897 }
1898
1899 fn base_type_to_typescript(
1901 &self,
1902 base_type: &BaseType,
1903 integer_kind: Option<IntegerKind>,
1904 is_array: bool,
1905 ) -> String {
1906 let base_ts_type = match base_type {
1907 BaseType::Integer => integer_kind
1908 .map(integer_kind_to_typescript)
1909 .unwrap_or("number"),
1910 BaseType::Float => "number",
1911 BaseType::String => "string",
1912 BaseType::Boolean => "boolean",
1913 BaseType::Timestamp => integer_kind
1914 .map(integer_kind_to_typescript)
1915 .unwrap_or("number"),
1916 BaseType::Binary => "string", BaseType::Pubkey => "string", BaseType::Array => "any[]", BaseType::Object => "Record<string, any>", BaseType::Any => "any",
1921 };
1922
1923 if is_array && !matches!(base_type, BaseType::Array) {
1924 format!("{}[]", base_ts_type)
1925 } else {
1926 base_ts_type.to_string()
1927 }
1928 }
1929}
1930
1931#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1932enum FieldPresence {
1933 Patch,
1934 Required,
1935}
1936
1937#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1938enum SchemaMode {
1939 Canonical,
1940 Patch,
1941}
1942
1943fn schema_constant_name(name: &str, mode: SchemaMode) -> String {
1944 match mode {
1945 SchemaMode::Canonical => format!("{}Schema", name),
1946 SchemaMode::Patch => format!("{}PatchSchema", name),
1947 }
1948}
1949
1950fn localize_section_field_names(
1951 section_name: &str,
1952 field_info: &FieldTypeInfo,
1953) -> (String, String) {
1954 let raw_name = field_info.raw_field_name();
1955 if is_root_section(section_name) {
1956 return (raw_name.to_string(), field_info.canonical_field_name());
1957 }
1958
1959 let prefix = format!("{}.", section_name);
1960 if let Some(local_raw_name) = raw_name.strip_prefix(&prefix) {
1961 return (local_raw_name.to_string(), to_camel_case(local_raw_name));
1962 }
1963
1964 (raw_name.to_string(), field_info.canonical_field_name())
1965}
1966
1967#[derive(Debug, Clone)]
1969struct TypeScriptField {
1970 name: String,
1971 raw_name: String,
1972 ts_type: String,
1973 nullable: bool,
1974 presence: FieldPresence,
1975 zod_schema: Option<String>,
1976 #[allow(dead_code)]
1977 description: Option<String>,
1978}
1979
1980impl TypeScriptField {
1981 fn patch(raw_name: String, ts_type: String, nullable: bool) -> Self {
1982 Self::from_names(
1983 raw_name.clone(),
1984 to_camel_case(&raw_name),
1985 ts_type,
1986 nullable,
1987 FieldPresence::Patch,
1988 )
1989 }
1990
1991 fn required_with_schema(
1992 raw_name: String,
1993 canonical_name: String,
1994 ts_type: String,
1995 nullable: bool,
1996 zod_schema: String,
1997 ) -> Self {
1998 let mut field = Self::from_names(
1999 raw_name,
2000 canonical_name,
2001 ts_type,
2002 nullable,
2003 FieldPresence::Required,
2004 );
2005 field.zod_schema = Some(zod_schema);
2006 field
2007 }
2008
2009 fn from_names(
2010 raw_name: String,
2011 canonical_name: String,
2012 ts_type: String,
2013 nullable: bool,
2014 presence: FieldPresence,
2015 ) -> Self {
2016 Self {
2017 name: canonical_name,
2018 raw_name,
2019 ts_type,
2020 nullable,
2021 presence,
2022 zod_schema: None,
2023 description: None,
2024 }
2025 }
2026
2027 fn rendered_ts_type(&self) -> String {
2028 if self.nullable {
2029 format!("{} | null", self.ts_type)
2030 } else {
2031 self.ts_type.clone()
2032 }
2033 }
2034}
2035
2036#[derive(Debug, Clone)]
2037struct SchemaOutput {
2038 definitions: String,
2039 names: Vec<String>,
2040}
2041
2042#[derive(Debug, Default)]
2043struct IdlAccountArtifacts {
2044 code: String,
2045 schema_names: Vec<String>,
2046 type_names: HashSet<String>,
2047 account_type_names: BTreeMap<(String, String), String>,
2048}
2049
2050fn value_to_typescript_type(value: &serde_json::Value) -> String {
2052 match value {
2053 serde_json::Value::Number(_) => "number".to_string(),
2054 serde_json::Value::String(_) => "string".to_string(),
2055 serde_json::Value::Bool(_) => "boolean".to_string(),
2056 serde_json::Value::Array(_) => "any[]".to_string(),
2057 serde_json::Value::Object(_) => "Record<string, any>".to_string(),
2058 serde_json::Value::Null => "null".to_string(),
2059 }
2060}
2061
2062fn extract_builtin_resolver_type_names(spec: &SerializableStreamSpec) -> HashSet<String> {
2063 let mut names = HashSet::new();
2064 let registry = crate::resolvers::builtin_resolver_registry();
2065 for resolver in registry.definitions() {
2066 let output_type = resolver.output_type();
2067 for section in &spec.sections {
2068 for field in §ion.fields {
2069 if field.inner_type.as_deref() == Some(output_type) {
2070 names.insert(output_type.to_string());
2071 }
2072 }
2073 }
2074 }
2075 names
2076}
2077
2078fn generate_idl_account_artifacts(
2079 idls: &[IdlSnapshot],
2080 reserved_type_names: &HashSet<String>,
2081) -> IdlAccountArtifacts {
2082 let mut used_type_names = reserved_type_names.clone();
2083 let mut emitted_type_names = HashSet::new();
2084 let mut seen_schema_names = HashSet::new();
2085 let mut interface_blocks = Vec::new();
2086 let mut schema_blocks = Vec::new();
2087 let mut schema_names = Vec::new();
2088 let mut account_type_names = BTreeMap::new();
2089
2090 for idl in idls {
2091 let program_key = to_camel_case(&idl.name);
2092 let program_prefix = to_pascal_case(&idl.name);
2093 let type_defs: BTreeMap<String, &IdlTypeDefSnapshot> = idl
2094 .types
2095 .iter()
2096 .map(|type_def| (type_def.name.clone(), type_def))
2097 .collect();
2098 let account_names: HashSet<String> = idl
2099 .accounts
2100 .iter()
2101 .map(|account| account.name.clone())
2102 .collect();
2103 let mut local_name_map = BTreeMap::new();
2104
2105 for account in &idl.accounts {
2106 let unique_name =
2107 unique_idl_type_name(&account.name, &program_prefix, &mut used_type_names);
2108 emitted_type_names.insert(unique_name.clone());
2109 local_name_map.insert(account.name.clone(), unique_name.clone());
2110 account_type_names.insert((program_key.clone(), account.name.clone()), unique_name);
2111 }
2112
2113 let mut required_defined_types = BTreeSet::new();
2114 for account in &idl.accounts {
2115 for field in resolve_idl_account_fields(account, &type_defs) {
2116 collect_required_defined_types(
2117 &field.type_,
2118 &type_defs,
2119 &account_names,
2120 &mut required_defined_types,
2121 );
2122 }
2123 }
2124
2125 for type_name in &required_defined_types {
2126 if local_name_map.contains_key(type_name) {
2127 continue;
2128 }
2129 let unique_name =
2130 unique_idl_type_name(type_name, &program_prefix, &mut used_type_names);
2131 emitted_type_names.insert(unique_name.clone());
2132 local_name_map.insert(type_name.clone(), unique_name);
2133 }
2134
2135 for type_name in &required_defined_types {
2136 if account_names.contains(type_name) {
2137 continue;
2138 }
2139 if let Some(type_def) = type_defs.get(type_name) {
2140 if let Some((interface_def, schema_name, schema_def)) =
2141 generate_type_defs_from_idl_type(type_def, &local_name_map)
2142 {
2143 interface_blocks.push(interface_def);
2144 if seen_schema_names.insert(schema_name.clone()) {
2145 schema_names.push(schema_name.clone());
2146 schema_blocks.push(schema_def);
2147 }
2148 }
2149 }
2150 }
2151
2152 for account in &idl.accounts {
2153 let Some(type_name) = local_name_map.get(&account.name) else {
2154 continue;
2155 };
2156 let account_fields = resolve_idl_account_fields(account, &type_defs);
2157 interface_blocks.push(generate_interface_from_idl_fields(
2158 type_name,
2159 account_fields,
2160 &local_name_map,
2161 ));
2162 let schema_name = format!("{}Schema", type_name);
2163 if seen_schema_names.insert(schema_name.clone()) {
2164 schema_names.push(schema_name.clone());
2165 schema_blocks.push(generate_schema_from_idl_fields(
2166 type_name,
2167 account_fields,
2168 &local_name_map,
2169 ));
2170 }
2171 }
2172 }
2173
2174 let code = if interface_blocks.is_empty() && schema_blocks.is_empty() {
2175 String::new()
2176 } else if schema_blocks.is_empty() {
2177 interface_blocks.join("\n\n")
2178 } else if interface_blocks.is_empty() {
2179 schema_blocks.join("\n\n")
2180 } else {
2181 format!(
2182 "{}\n\n{}",
2183 interface_blocks.join("\n\n"),
2184 schema_blocks.join("\n\n")
2185 )
2186 };
2187
2188 IdlAccountArtifacts {
2189 code,
2190 schema_names,
2191 type_names: emitted_type_names,
2192 account_type_names,
2193 }
2194}
2195
2196fn resolve_idl_account_fields<'a>(
2197 account: &'a IdlAccountSnapshot,
2198 type_defs: &'a BTreeMap<String, &'a IdlTypeDefSnapshot>,
2199) -> &'a [IdlFieldSnapshot] {
2200 if !account.fields.is_empty() {
2201 return &account.fields;
2202 }
2203
2204 let Some(type_def) = type_defs.get(&account.name) else {
2205 return &account.fields;
2206 };
2207
2208 match &type_def.type_def {
2209 IdlTypeDefKindSnapshot::Struct { fields, .. } => fields,
2210 _ => &account.fields,
2211 }
2212}
2213
2214fn unique_idl_type_name(
2215 raw_name: &str,
2216 program_prefix: &str,
2217 used_type_names: &mut HashSet<String>,
2218) -> String {
2219 let base_name = to_pascal_case(raw_name);
2220 if used_type_names.insert(base_name.clone()) {
2221 return base_name;
2222 }
2223
2224 let prefixed = format!("{}{}", program_prefix, base_name);
2225 if used_type_names.insert(prefixed.clone()) {
2226 return prefixed;
2227 }
2228
2229 let mut index = 2;
2230 loop {
2231 let candidate = format!("{}{}", prefixed, index);
2232 if used_type_names.insert(candidate.clone()) {
2233 return candidate;
2234 }
2235 index += 1;
2236 }
2237}
2238
2239fn collect_required_defined_types(
2240 idl_type: &IdlTypeSnapshot,
2241 type_defs: &BTreeMap<String, &IdlTypeDefSnapshot>,
2242 account_names: &HashSet<String>,
2243 output: &mut BTreeSet<String>,
2244) {
2245 match idl_type {
2246 IdlTypeSnapshot::Simple(_) => {}
2247 IdlTypeSnapshot::Array(array_type) => {
2248 for element in &array_type.array {
2249 if let IdlArrayElementSnapshot::Type(inner) = element {
2250 collect_required_defined_types(inner, type_defs, account_names, output);
2251 }
2252 }
2253 }
2254 IdlTypeSnapshot::Option(option_type) => {
2255 collect_required_defined_types(&option_type.option, type_defs, account_names, output);
2256 }
2257 IdlTypeSnapshot::Vec(vec_type) => {
2258 collect_required_defined_types(&vec_type.vec, type_defs, account_names, output);
2259 }
2260 IdlTypeSnapshot::HashMap(hash_map_type) => {
2261 collect_required_defined_types(
2262 &hash_map_type.hash_map.0,
2263 type_defs,
2264 account_names,
2265 output,
2266 );
2267 collect_required_defined_types(
2268 &hash_map_type.hash_map.1,
2269 type_defs,
2270 account_names,
2271 output,
2272 );
2273 }
2274 IdlTypeSnapshot::Defined(defined_type) => {
2275 let type_name = match &defined_type.defined {
2276 IdlDefinedInnerSnapshot::Named { name } => name,
2277 IdlDefinedInnerSnapshot::Simple(name) => name,
2278 };
2279
2280 if !output.insert(type_name.clone()) {
2281 return;
2282 }
2283
2284 if account_names.contains(type_name) {
2285 return;
2286 }
2287
2288 let Some(type_def) = type_defs.get(type_name) else {
2289 return;
2290 };
2291
2292 match &type_def.type_def {
2293 IdlTypeDefKindSnapshot::Struct { fields, .. } => {
2294 for field in fields {
2295 collect_required_defined_types(
2296 &field.type_,
2297 type_defs,
2298 account_names,
2299 output,
2300 );
2301 }
2302 }
2303 IdlTypeDefKindSnapshot::TupleStruct { fields, .. } => {
2304 for field in fields {
2305 collect_required_defined_types(field, type_defs, account_names, output);
2306 }
2307 }
2308 IdlTypeDefKindSnapshot::Enum { variants, .. } => {
2309 for variant in variants {
2310 for field in &variant.fields {
2311 match field {
2312 IdlEnumVariantFieldSnapshot::Named(named) => {
2313 collect_required_defined_types(
2314 &named.type_,
2315 type_defs,
2316 account_names,
2317 output,
2318 );
2319 }
2320 IdlEnumVariantFieldSnapshot::Tuple(tuple) => {
2321 collect_required_defined_types(
2322 tuple,
2323 type_defs,
2324 account_names,
2325 output,
2326 );
2327 }
2328 }
2329 }
2330 }
2331 }
2332 }
2333 }
2334 }
2335}
2336
2337fn generate_type_defs_from_idl_type(
2338 type_def: &IdlTypeDefSnapshot,
2339 local_name_map: &BTreeMap<String, String>,
2340) -> Option<(String, String, String)> {
2341 let type_name = local_name_map
2342 .get(&type_def.name)
2343 .cloned()
2344 .unwrap_or_else(|| to_pascal_case(&type_def.name));
2345 let schema_name = format!("{}Schema", type_name);
2346
2347 match &type_def.type_def {
2348 IdlTypeDefKindSnapshot::Struct { fields, .. } => Some((
2349 generate_interface_from_idl_fields(&type_name, fields, local_name_map),
2350 schema_name,
2351 generate_schema_from_idl_fields(&type_name, fields, local_name_map),
2352 )),
2353 IdlTypeDefKindSnapshot::TupleStruct { fields, .. } => {
2354 let interface = format!(
2355 "export type {} = [{}];",
2356 type_name,
2357 fields
2358 .iter()
2359 .map(|field| idl_snapshot_type_to_typescript(field, local_name_map))
2360 .collect::<Vec<_>>()
2361 .join(", ")
2362 );
2363 let schema = format!(
2364 "export const {} = z.tuple([{}]);",
2365 schema_name,
2366 fields
2367 .iter()
2368 .map(|field| idl_snapshot_type_to_zod(field, local_name_map))
2369 .collect::<Vec<_>>()
2370 .join(", ")
2371 );
2372 Some((interface, schema_name, schema))
2373 }
2374 IdlTypeDefKindSnapshot::Enum { variants, .. } => {
2375 let variant_names = variants
2376 .iter()
2377 .map(|variant| format!("\"{}\"", variant.name))
2378 .collect::<Vec<_>>();
2379 let interface = if variant_names.is_empty() {
2380 format!("export type {} = string;", type_name)
2381 } else {
2382 format!("export type {} = {};", type_name, variant_names.join(" | "))
2383 };
2384 let schema = if variant_names.is_empty() {
2385 format!("export const {} = z.string();", schema_name)
2386 } else {
2387 format!(
2388 "export const {} = z.enum([{}]);",
2389 schema_name,
2390 variant_names.join(", ")
2391 )
2392 };
2393 Some((interface, schema_name, schema))
2394 }
2395 }
2396}
2397
2398fn generate_interface_from_idl_fields(
2399 name: &str,
2400 fields: &[IdlFieldSnapshot],
2401 local_name_map: &BTreeMap<String, String>,
2402) -> String {
2403 render_interface_from_ts_fields(name, &normalize_idl_fields(fields, local_name_map), true)
2404}
2405
2406fn generate_schema_from_idl_fields(
2407 name: &str,
2408 fields: &[IdlFieldSnapshot],
2409 local_name_map: &BTreeMap<String, String>,
2410) -> String {
2411 render_schema_from_ts_fields(name, &normalize_idl_fields(fields, local_name_map), true)
2412}
2413
2414fn normalize_idl_fields(
2415 fields: &[IdlFieldSnapshot],
2416 local_name_map: &BTreeMap<String, String>,
2417) -> Vec<TypeScriptField> {
2418 let mut canonical_names = BTreeMap::new();
2419 let mut normalized = Vec::with_capacity(fields.len());
2420
2421 for field in fields {
2422 let canonical_name = to_camel_case(&field.name);
2423 if let Some(existing_source) =
2424 canonical_names.insert(canonical_name.clone(), field.name.clone())
2425 {
2426 assert_eq!(
2427 existing_source, field.name,
2428 "IDL field normalization collision: '{}' and '{}' both normalize to '{}'",
2429 existing_source, field.name, canonical_name
2430 );
2431 }
2432
2433 let (normalized_type, nullable) = strip_nullable_idl_type(&field.type_);
2434
2435 normalized.push(TypeScriptField::required_with_schema(
2436 idl_field_wire_name(&field.name),
2437 canonical_name,
2438 idl_snapshot_type_to_typescript(normalized_type, local_name_map),
2439 nullable,
2440 idl_snapshot_type_to_zod(normalized_type, local_name_map),
2441 ));
2442 }
2443
2444 normalized
2445}
2446
2447fn idl_field_wire_name(field_name: &str) -> String {
2448 idl_to_snake_case(field_name)
2449}
2450
2451fn idl_snapshot_type_to_typescript(
2452 idl_type: &IdlTypeSnapshot,
2453 local_name_map: &BTreeMap<String, String>,
2454) -> String {
2455 match idl_type {
2456 IdlTypeSnapshot::Simple(type_name) => match type_name.as_str() {
2457 "u64" | "u128" | "i64" | "i128" => "bigint".to_string(),
2458 "u8" | "u16" | "u32" | "i8" | "i16" | "i32" | "f32" | "f64" => "number".to_string(),
2459 "bool" => "boolean".to_string(),
2460 "string" => "string".to_string(),
2461 "pubkey" | "publicKey" => "string".to_string(),
2462 "bytes" => "number[]".to_string(),
2463 _ => "any".to_string(),
2464 },
2465 IdlTypeSnapshot::Array(array_type) => {
2466 let (inner_ts, size) = match array_type.array.as_slice() {
2467 [IdlArrayElementSnapshot::Type(inner), IdlArrayElementSnapshot::Size(size)] => (
2468 Some(idl_snapshot_type_to_typescript(inner, local_name_map)),
2469 Some(*size),
2470 ),
2471 [IdlArrayElementSnapshot::TypeName(inner), IdlArrayElementSnapshot::Size(size)] => {
2472 (
2473 Some(idl_snapshot_type_to_typescript(
2474 &IdlTypeSnapshot::Simple(inner.clone()),
2475 local_name_map,
2476 )),
2477 Some(*size),
2478 )
2479 }
2480 _ => (None, None),
2481 };
2482 let inner_ts = inner_ts.unwrap_or_else(|| "any".to_string());
2483 if let Some(size) = size {
2484 format!(
2485 "{}[]",
2486 if size == 0 {
2487 "never".to_string()
2488 } else {
2489 inner_ts
2490 }
2491 )
2492 } else {
2493 format!("{}[]", inner_ts)
2494 }
2495 }
2496 IdlTypeSnapshot::Option(option_type) => {
2497 format!(
2498 "{} | null",
2499 idl_snapshot_type_to_typescript(&option_type.option, local_name_map)
2500 )
2501 }
2502 IdlTypeSnapshot::Vec(vec_type) => {
2503 format!(
2504 "{}[]",
2505 idl_snapshot_type_to_typescript(&vec_type.vec, local_name_map)
2506 )
2507 }
2508 IdlTypeSnapshot::HashMap(hash_map_type) => {
2509 format!(
2510 "Record<string, {}>",
2511 idl_snapshot_type_to_typescript(&hash_map_type.hash_map.1, local_name_map)
2512 )
2513 }
2514 IdlTypeSnapshot::Defined(defined_type) => {
2515 let type_name = match &defined_type.defined {
2516 IdlDefinedInnerSnapshot::Named { name } => name,
2517 IdlDefinedInnerSnapshot::Simple(name) => name,
2518 };
2519 local_name_map
2520 .get(type_name)
2521 .cloned()
2522 .unwrap_or_else(|| to_pascal_case(type_name))
2523 }
2524 }
2525}
2526
2527fn idl_snapshot_type_to_zod(
2528 idl_type: &IdlTypeSnapshot,
2529 local_name_map: &BTreeMap<String, String>,
2530) -> String {
2531 match idl_type {
2532 IdlTypeSnapshot::Simple(type_name) => match type_name.as_str() {
2533 "u64" | "u128" | "i64" | "i128" => bigint_zod(),
2534 "u8" | "u16" | "u32" | "i8" | "i16" | "i32" | "f32" | "f64" => "z.number()".to_string(),
2535 "bool" => "z.boolean()".to_string(),
2536 "string" => "z.string()".to_string(),
2537 "pubkey" | "publicKey" => "z.string()".to_string(),
2538 "bytes" => "z.array(z.number())".to_string(),
2539 _ => "z.any()".to_string(),
2540 },
2541 IdlTypeSnapshot::Array(array_type) => match array_type.array.as_slice() {
2542 [IdlArrayElementSnapshot::Type(inner), IdlArrayElementSnapshot::Size(size)] => {
2543 format!(
2544 "z.array({}).length({})",
2545 idl_snapshot_type_to_zod(inner, local_name_map),
2546 size
2547 )
2548 }
2549 [IdlArrayElementSnapshot::TypeName(inner), IdlArrayElementSnapshot::Size(size)] => {
2550 format!(
2551 "z.array({}).length({})",
2552 idl_snapshot_type_to_zod(
2553 &IdlTypeSnapshot::Simple(inner.clone()),
2554 local_name_map
2555 ),
2556 size
2557 )
2558 }
2559 _ => "z.array(z.any())".to_string(),
2560 },
2561 IdlTypeSnapshot::Option(option_type) => {
2562 format!(
2563 "{}.nullable()",
2564 idl_snapshot_type_to_zod(&option_type.option, local_name_map)
2565 )
2566 }
2567 IdlTypeSnapshot::Vec(vec_type) => {
2568 format!(
2569 "z.array({})",
2570 idl_snapshot_type_to_zod(&vec_type.vec, local_name_map)
2571 )
2572 }
2573 IdlTypeSnapshot::HashMap(hash_map_type) => {
2574 format!(
2575 "z.record({})",
2576 idl_snapshot_type_to_zod(&hash_map_type.hash_map.1, local_name_map)
2577 )
2578 }
2579 IdlTypeSnapshot::Defined(defined_type) => {
2580 let type_name = match &defined_type.defined {
2581 IdlDefinedInnerSnapshot::Named { name } => name,
2582 IdlDefinedInnerSnapshot::Simple(name) => name,
2583 };
2584 let resolved_name = local_name_map
2585 .get(type_name)
2586 .cloned()
2587 .unwrap_or_else(|| to_pascal_case(type_name));
2588 format!("z.lazy(() => {}Schema)", resolved_name)
2589 }
2590 }
2591}
2592
2593fn typescript_integer_type_from_rust(rust_type: &str) -> Option<&'static str> {
2594 IntegerKind::from_rust_type(rust_type).map(integer_kind_to_typescript)
2595}
2596
2597fn typescript_integer_type(
2598 integer_kind: Option<IntegerKind>,
2599 rust_type: Option<&str>,
2600) -> Option<&'static str> {
2601 integer_kind
2602 .map(integer_kind_to_typescript)
2603 .or_else(|| rust_type.and_then(typescript_integer_type_from_rust))
2604}
2605
2606fn typescript_scalar_array_element(inner_type: &str) -> Option<&'static str> {
2610 let trimmed = inner_type.trim();
2611 let element = trimmed
2612 .strip_prefix("Vec <")
2613 .and_then(|rest| rest.strip_suffix('>'))
2614 .or_else(|| {
2615 trimmed
2616 .strip_prefix("Vec<")
2617 .and_then(|rest| rest.strip_suffix('>'))
2618 })
2619 .map(str::trim)
2620 .unwrap_or(trimmed);
2621 match element {
2622 "f32" | "f64" => Some("number"),
2623 "bool" => Some("boolean"),
2624 "String" | "&str" | "str" => Some("string"),
2625 _ => None,
2626 }
2627}
2628
2629fn integer_kind_to_typescript(integer_kind: IntegerKind) -> &'static str {
2630 if integer_kind.is_bigint() {
2631 "bigint"
2632 } else {
2633 "number"
2634 }
2635}
2636
2637fn strip_nullable_idl_type(mut idl_type: &IdlTypeSnapshot) -> (&IdlTypeSnapshot, bool) {
2638 let mut nullable = false;
2639 while let IdlTypeSnapshot::Option(option_type) = idl_type {
2640 nullable = true;
2641 idl_type = &option_type.option;
2642 }
2643 (idl_type, nullable)
2644}
2645
2646fn typescript_type_to_zod_static(ts_type: &str) -> String {
2647 let trimmed = ts_type.trim();
2648
2649 if let Some(inner) = trimmed.strip_suffix("[]") {
2650 return format!("z.array({})", typescript_type_to_zod_static(inner));
2651 }
2652
2653 if let Some(inner) = trimmed.strip_prefix("EventWrapper<") {
2654 if let Some(inner) = inner.strip_suffix('>') {
2655 return format!(
2656 "EventWrapperSchema({})",
2657 typescript_type_to_zod_static(inner)
2658 );
2659 }
2660 }
2661
2662 if let Some(inner) = trimmed.strip_prefix("CaptureWrapper<") {
2663 if let Some(inner) = inner.strip_suffix('>') {
2664 return format!(
2665 "CaptureWrapperSchema({})",
2666 typescript_type_to_zod_static(inner)
2667 );
2668 }
2669 }
2670
2671 match trimmed {
2672 "string" => "z.string()".to_string(),
2673 "number" => "z.number()".to_string(),
2674 "bigint" => bigint_zod(),
2675 "boolean" => "z.boolean()".to_string(),
2676 "any" => "z.any()".to_string(),
2677 "Record<string, any>" => "z.record(z.any())".to_string(),
2678 _ => format!("{}Schema", trimmed),
2679 }
2680}
2681
2682fn typescript_type_to_zod_for_schema_static(
2683 ts_type: &str,
2684 mode: SchemaMode,
2685 patch_schema_types: &HashSet<String>,
2686) -> String {
2687 let trimmed = ts_type.trim();
2688
2689 if let Some(inner) = trimmed.strip_suffix("[]") {
2690 return format!(
2691 "z.array({})",
2692 typescript_type_to_zod_for_schema_static(inner, mode, patch_schema_types)
2693 );
2694 }
2695
2696 if let Some(inner) = trimmed.strip_prefix("EventWrapper<") {
2697 if let Some(inner) = inner.strip_suffix('>') {
2698 return format!(
2699 "EventWrapperSchema({})",
2700 typescript_type_to_zod_for_schema_static(inner, mode, patch_schema_types)
2701 );
2702 }
2703 }
2704
2705 if let Some(inner) = trimmed.strip_prefix("CaptureWrapper<") {
2706 if let Some(inner) = inner.strip_suffix('>') {
2707 return format!(
2708 "CaptureWrapperSchema({})",
2709 typescript_type_to_zod_for_schema_static(
2710 inner,
2711 SchemaMode::Canonical,
2712 patch_schema_types,
2713 )
2714 );
2715 }
2716 }
2717
2718 match trimmed {
2719 "string" => "z.string()".to_string(),
2720 "number" => "z.number()".to_string(),
2721 "bigint" => bigint_zod(),
2722 "boolean" => "z.boolean()".to_string(),
2723 "any" => "z.any()".to_string(),
2724 "Record<string, any>" => "z.record(z.any())".to_string(),
2725 _ => {
2726 if mode == SchemaMode::Patch && patch_schema_types.contains(trimmed) {
2727 format!("{}PatchSchema", trimmed)
2728 } else {
2729 format!("{}Schema", trimmed)
2730 }
2731 }
2732 }
2733}
2734
2735fn render_interface_from_ts_fields(
2736 name: &str,
2737 fields: &[TypeScriptField],
2738 force_required: bool,
2739) -> String {
2740 if fields.is_empty() {
2741 return format!("export interface {} {{\n}}", name);
2742 }
2743
2744 let field_definitions = fields
2745 .iter()
2746 .map(|field| {
2747 let optional = if force_required || matches!(field.presence, FieldPresence::Required) {
2748 ""
2749 } else {
2750 "?"
2751 };
2752 format!(
2753 " {}{}: {};",
2754 field.name,
2755 optional,
2756 field.rendered_ts_type()
2757 )
2758 })
2759 .collect::<Vec<_>>();
2760
2761 format!(
2762 "export interface {} {{\n{}\n}}",
2763 name,
2764 field_definitions.join("\n")
2765 )
2766}
2767
2768fn render_schema_from_ts_fields(
2769 name: &str,
2770 fields: &[TypeScriptField],
2771 force_required: bool,
2772) -> String {
2773 if fields.is_empty() {
2774 return format!("export const {}Schema = z.object({{}});", name);
2775 }
2776
2777 let field_definitions = fields
2778 .iter()
2779 .map(|field| {
2780 let base_schema = field
2781 .zod_schema
2782 .clone()
2783 .unwrap_or_else(|| typescript_type_to_zod_static(&field.ts_type));
2784 let with_nullable = if field.nullable {
2785 format!("{}.nullable()", base_schema)
2786 } else {
2787 base_schema
2788 };
2789 let schema = if force_required || matches!(field.presence, FieldPresence::Required) {
2790 with_nullable
2791 } else {
2792 format!("{}.optional()", with_nullable)
2793 };
2794 format!(" {}: {},", field.raw_name, schema)
2795 })
2796 .collect::<Vec<_>>();
2797
2798 let transform_fields = fields
2799 .iter()
2800 .map(|field| format!(" {}: value.{},", field.name, field.raw_name))
2801 .collect::<Vec<_>>();
2802
2803 format!(
2804 "export const {}Schema = z.object({{\n{}\n}}).transform((value) => ({{\n{}\n}}));",
2805 name,
2806 field_definitions.join("\n"),
2807 transform_fields.join("\n")
2808 )
2809}
2810
2811fn bigint_zod() -> String {
2812 "z.union([z.bigint(), z.string(), z.number().int()]).transform((value) => BigInt(value))"
2813 .to_string()
2814}
2815
2816fn extract_idl_enum_type_names(idl: &serde_json::Value) -> HashSet<String> {
2817 let mut names = HashSet::new();
2818 if let Some(types_array) = idl.get("types").and_then(|v| v.as_array()) {
2819 for type_def in types_array {
2820 if let (Some(type_name), Some(type_obj)) = (
2821 type_def.get("name").and_then(|v| v.as_str()),
2822 type_def.get("type").and_then(|v| v.as_object()),
2823 ) {
2824 if type_obj.get("kind").and_then(|v| v.as_str()) == Some("enum") {
2825 names.insert(to_pascal_case(type_name));
2826 }
2827 }
2828 }
2829 }
2830 names
2831}
2832
2833fn extract_emitted_enum_type_names(interfaces: &str, idl: Option<&IdlSnapshot>) -> HashSet<String> {
2836 let mut names = HashSet::new();
2837
2838 let idl_enum_names: HashSet<String> = idl
2840 .and_then(|idl| serde_json::to_value(idl).ok())
2841 .map(|v| extract_idl_enum_type_names(&v))
2842 .unwrap_or_default();
2843
2844 for line in interfaces.lines() {
2847 if let Some(start) = line.find("export const ") {
2848 let end = line
2849 .find("Schema = z.enum")
2850 .or_else(|| line.find("Schema = z.string()"));
2851 if let Some(end) = end {
2852 let schema_name = line[start + 13..end].trim();
2853 if idl_enum_names.contains(schema_name) {
2855 names.insert(schema_name.to_string());
2856 }
2857 }
2858 }
2859 }
2860
2861 names
2862}
2863
2864fn unique_resolved_type_name_ts(
2865 resolved: &ResolvedStructType,
2866 reserved_names: &mut HashSet<String>,
2867) -> String {
2868 let base_name = to_pascal_case(&resolved.type_name);
2869 if reserved_names.insert(base_name.clone()) {
2870 return base_name;
2871 }
2872
2873 let suffix = if resolved.is_account {
2874 "Account"
2875 } else if resolved.is_event {
2876 "Event"
2877 } else if resolved.is_instruction {
2878 "Instruction"
2879 } else {
2880 "Type"
2881 };
2882
2883 let preferred = format!("{}{}", base_name, suffix);
2884 if reserved_names.insert(preferred.clone()) {
2885 return preferred;
2886 }
2887
2888 let mut index = 2;
2889 loop {
2890 let candidate = format!("{}{}{}", base_name, suffix, index);
2891 if reserved_names.insert(candidate.clone()) {
2892 return candidate;
2893 }
2894 index += 1;
2895 }
2896}
2897
2898pub(crate) fn to_pascal_case(s: &str) -> String {
2900 s.split(['_', '-', '.'])
2901 .map(|word| {
2902 let mut chars = word.chars();
2903 match chars.next() {
2904 None => String::new(),
2905 Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
2906 }
2907 })
2908 .collect()
2909}
2910
2911fn to_camel_case(s: &str) -> String {
2912 let pascal = to_pascal_case(s);
2913 let mut chars = pascal.chars();
2914 match chars.next() {
2915 Some(first) => first.to_lowercase().collect::<String>() + chars.as_str(),
2916 None => pascal,
2917 }
2918}
2919
2920fn normalize_for_comparison(s: &str) -> String {
2923 s.chars()
2924 .filter(|c| *c != '_')
2925 .flat_map(|c| c.to_lowercase())
2926 .collect()
2927}
2928
2929fn is_root_section(name: &str) -> bool {
2930 name.eq_ignore_ascii_case("root")
2931}
2932
2933fn state_view_key_definition(
2934 entity_name: &str,
2935 identity: &IdentitySpec,
2936 field_mappings: &BTreeMap<String, FieldTypeInfo>,
2937 sections: &[EntitySection],
2938) -> Result<StateViewKeyDefinition, String> {
2939 let mut seen = HashSet::new();
2940 let distinct_keys: Vec<&str> = identity
2941 .primary_keys
2942 .iter()
2943 .map(String::as_str)
2944 .filter(|key| seen.insert(*key))
2945 .collect();
2946
2947 if distinct_keys.len() > 1 {
2948 return Err(format!(
2949 "TypeScript SDK generation does not support composite state keys for entity '{}': distinct identity.primary_keys are [{}]",
2950 entity_name,
2951 distinct_keys.join(", ")
2952 ));
2953 }
2954
2955 let key_path = distinct_keys.first().copied().ok_or_else(|| {
2956 format!(
2957 "TypeScript SDK generation requires a primary key for entity '{}'",
2958 entity_name
2959 )
2960 })?;
2961 let key_leaf = key_path.rsplit('.').next().unwrap_or(key_path);
2962 let field_info = field_mappings.get(key_path).or_else(|| {
2963 sections.iter().find_map(|section| {
2964 section.fields.iter().find(|field| {
2965 field.raw_field_name() == key_path
2966 || field.raw_field_name() == key_leaf
2967 || field.field_name == key_path
2968 || field.field_name == key_leaf
2969 })
2970 })
2971 });
2972
2973 let field_name = field_info
2974 .map(FieldTypeInfo::canonical_field_name)
2975 .unwrap_or_else(|| to_camel_case(key_leaf));
2976 let typescript_type = match field_info {
2977 Some(field) if field.is_array => {
2978 return Err(format!(
2979 "TypeScript SDK generation does not support array state key '{}' for entity '{}'",
2980 key_path, entity_name
2981 ));
2982 }
2983 Some(field) => match field.base_type {
2984 BaseType::String | BaseType::Binary | BaseType::Pubkey => "string".to_string(),
2985 BaseType::Integer | BaseType::Timestamp => field
2986 .effective_integer_kind()
2987 .map(integer_kind_to_typescript)
2988 .unwrap_or("number")
2989 .to_string(),
2990 _ => {
2991 return Err(format!(
2992 "TypeScript SDK generation does not support state key '{}' with type '{}' for entity '{}'",
2993 key_path, field.rust_type_name, entity_name
2994 ));
2995 }
2996 },
2997 None => "string".to_string(),
2999 };
3000
3001 Ok(StateViewKeyDefinition {
3002 field_name,
3003 typescript_type,
3004 })
3005}
3006
3007fn is_builtin_resolver_type(type_name: &str) -> bool {
3008 crate::resolvers::is_resolver_output_type(type_name)
3009}
3010
3011fn to_kebab_case(s: &str) -> String {
3013 let mut result = String::new();
3014
3015 for ch in s.chars() {
3016 if ch.is_uppercase() && !result.is_empty() {
3017 result.push('-');
3018 }
3019 result.push(ch.to_lowercase().next().unwrap());
3020 }
3021
3022 result
3023}
3024
3025pub fn generate_typescript_from_spec_fn<F, S>(
3028 spec_fn: F,
3029 entity_name: String,
3030 config: Option<TypeScriptConfig>,
3031) -> Result<TypeScriptOutput, String>
3032where
3033 F: Fn() -> TypedStreamSpec<S>,
3034{
3035 let spec = spec_fn();
3036 let compiler =
3037 TypeScriptCompiler::new(spec, entity_name).with_config(config.unwrap_or_default());
3038
3039 compiler.try_compile()
3040}
3041
3042pub fn write_typescript_to_file(
3044 output: &TypeScriptOutput,
3045 path: &std::path::Path,
3046) -> Result<(), std::io::Error> {
3047 std::fs::write(path, output.full_file())
3048}
3049
3050pub fn compile_serializable_spec(
3053 spec: SerializableStreamSpec,
3054 entity_name: String,
3055 config: Option<TypeScriptConfig>,
3056) -> Result<TypeScriptOutput, String> {
3057 compile_serializable_spec_with_emitted(spec, entity_name, config, HashSet::new())
3058}
3059
3060fn compile_serializable_spec_with_emitted(
3061 spec: SerializableStreamSpec,
3062 entity_name: String,
3063 config: Option<TypeScriptConfig>,
3064 already_emitted_types: HashSet<String>,
3065) -> Result<TypeScriptOutput, String> {
3066 let idl = spec
3067 .idl
3068 .as_ref()
3069 .and_then(|idl_snapshot| serde_json::to_value(idl_snapshot).ok());
3070
3071 let handlers = serde_json::to_value(&spec.handlers).ok();
3072 let views = spec.views.clone();
3073
3074 let typed_spec: TypedStreamSpec<()> = TypedStreamSpec::from_serializable(spec);
3075
3076 let compiler = TypeScriptCompiler::new(typed_spec, entity_name)
3077 .with_idl(idl)
3078 .with_handlers_json(handlers)
3079 .with_views(views)
3080 .with_config(config.unwrap_or_default())
3081 .with_already_emitted_types(already_emitted_types);
3082
3083 compiler.try_compile()
3084}
3085
3086#[derive(Debug, Clone, PartialEq, Eq)]
3087pub struct TypeScriptProgramDefinitionMetadata {
3088 pub program_id: String,
3089 pub sdk_definition_hash: Option<String>,
3090 pub program_spec_hash: String,
3091 pub idl_content_hash: String,
3092 pub normalized_idl_hash: String,
3093}
3094
3095#[derive(Debug, Clone, PartialEq, Eq)]
3096pub struct TypeScriptProgramReleaseReference {
3097 pub program_release_hash: String,
3098 pub program_spec_hash: String,
3099}
3100
3101#[derive(Debug, Clone, PartialEq)]
3102pub struct TypeScriptProgramReadBinding {
3103 pub endpoint: String,
3104 pub program_read_binding_id: String,
3105 pub auth: serde_json::Value,
3106}
3107
3108#[derive(Debug, Clone, PartialEq)]
3109pub enum TypeScriptProgramReadTransport {
3110 LocalHttp,
3111 HostedBinding(TypeScriptProgramReadBinding),
3112}
3113
3114#[derive(Debug, Clone, PartialEq)]
3115pub struct TypeScriptProgramConfig {
3116 pub definition: TypeScriptProgramDefinitionMetadata,
3117 pub release: TypeScriptProgramReleaseReference,
3118 pub transport: TypeScriptProgramReadTransport,
3119}
3120
3121impl From<&arete_hash::OssProgramIdentityV1> for TypeScriptProgramConfig {
3122 fn from(identity: &arete_hash::OssProgramIdentityV1) -> Self {
3123 Self {
3124 definition: TypeScriptProgramDefinitionMetadata {
3125 program_id: identity.program_spec.program_id.clone(),
3126 sdk_definition_hash: None,
3127 program_spec_hash: identity.program_spec_hash.to_string(),
3128 idl_content_hash: identity.program_spec.idl_content_hash.to_string(),
3129 normalized_idl_hash: identity.program_spec.normalized_idl_hash.to_string(),
3130 },
3131 release: TypeScriptProgramReleaseReference {
3132 program_release_hash: identity.release_hash.to_string(),
3133 program_spec_hash: identity.program_spec_hash.to_string(),
3134 },
3135 transport: TypeScriptProgramReadTransport::LocalHttp,
3136 }
3137 }
3138}
3139
3140#[derive(Debug, Clone)]
3141pub struct TypeScriptStackConfig {
3142 pub package_name: String,
3143 pub generate_helpers: bool,
3144 pub export_const_name: String,
3145 pub websocket_url: Option<String>,
3146 pub http_url: Option<String>,
3147 pub extension_import: Option<String>,
3148 pub programs: Option<Vec<TypeScriptProgramConfig>>,
3151}
3152
3153impl Default for TypeScriptStackConfig {
3154 fn default() -> Self {
3155 Self {
3156 package_name: "@usearete/react".to_string(),
3157 generate_helpers: true,
3158 export_const_name: "STACK".to_string(),
3159 websocket_url: None,
3160 http_url: None,
3161 extension_import: None,
3162 programs: None,
3163 }
3164 }
3165}
3166
3167#[derive(Debug, Clone)]
3168pub struct TypeScriptStackOutput {
3169 pub interfaces: String,
3170 pub stack_definition: String,
3171 pub imports: String,
3172 pub warnings: Vec<String>,
3175 pub pda_degradations: Vec<crate::typescript_instructions::PdaDegradation>,
3177}
3178
3179#[derive(Debug, Clone, Default)]
3180pub struct TypeScriptLiveEndpoints {
3181 pub websocket_url: Option<String>,
3182 pub http_url: Option<String>,
3183}
3184
3185#[derive(Debug, Clone, Default)]
3186pub struct TypeScriptCompositionConfig {
3187 pub stack: TypeScriptStackConfig,
3188 pub live_endpoints: BTreeMap<String, TypeScriptLiveEndpoints>,
3189 pub live_module_imports: BTreeMap<String, String>,
3190 pub program_module_imports: BTreeMap<String, String>,
3191}
3192
3193#[derive(Debug, Clone)]
3194pub struct TypeScriptAliasedStackOutput {
3195 pub alias: String,
3196 pub module_name: String,
3197 pub output: TypeScriptStackOutput,
3198}
3199
3200#[derive(Debug, Clone)]
3201pub struct TypeScriptProgramCollectionOutput {
3202 pub module_name: String,
3203 pub output: TypeScriptStackOutput,
3204 pub members: Vec<(String, String)>,
3205}
3206
3207#[derive(Debug, Clone)]
3208pub struct TypeScriptCompositionOutput {
3209 pub name: String,
3210 pub live_stacks: Vec<TypeScriptAliasedStackOutput>,
3211 pub program_collection: Option<TypeScriptProgramCollectionOutput>,
3212 pub session_definition: String,
3213 pub warnings: Vec<String>,
3214 pub pda_degradations: Vec<crate::typescript_instructions::PdaDegradation>,
3215}
3216
3217impl TypeScriptStackOutput {
3218 pub fn full_file(&self) -> String {
3219 let mut parts = Vec::new();
3220 if !self.imports.is_empty() {
3221 parts.push(self.imports.as_str());
3222 }
3223 if !self.interfaces.is_empty() {
3224 parts.push(self.interfaces.as_str());
3225 }
3226 if !self.stack_definition.is_empty() {
3227 parts.push(self.stack_definition.as_str());
3228 }
3229 parts.join("\n\n")
3230 }
3231}
3232
3233fn resolve_program_configs(
3234 stack_spec: &SerializableStackSpec,
3235 configured: Option<&[TypeScriptProgramConfig]>,
3236 allow_view_only: bool,
3237) -> Result<Vec<TypeScriptProgramConfig>, String> {
3238 if stack_spec.idls.is_empty() {
3239 if stack_spec.program_specs.is_empty()
3240 && configured.is_none_or(|programs| programs.is_empty())
3241 {
3242 return Ok(Vec::new());
3243 }
3244 return Err(format!(
3245 "Stack '{}' has program metadata but no ordered IDL list",
3246 stack_spec.stack_name
3247 ));
3248 }
3249
3250 if stack_spec.program_specs.is_empty() {
3251 let view_only = stack_spec.program_ids.is_empty()
3252 && stack_spec.instructions.is_empty()
3253 && stack_spec.pdas.is_empty()
3254 && stack_spec
3255 .idls
3256 .iter()
3257 .all(|idl| idl.accounts.is_empty() && idl.instructions.is_empty());
3258 if allow_view_only && view_only && configured.is_none_or(|programs| programs.is_empty()) {
3259 return Ok(Vec::new());
3260 }
3261 return Err(format!(
3262 "Stack '{}' uses program SDK/account definitions but its AST has no exact public ProgramSpecV1 values. Regenerate the .stack.json with the current arete-macros before generating an SDK.",
3263 stack_spec.stack_name
3264 ));
3265 }
3266
3267 let expected = stack_spec.idls.len();
3268 if stack_spec.program_ids.len() != expected || stack_spec.program_specs.len() != expected {
3269 return Err(format!(
3270 "Stack '{}' program metadata is not aligned: program_ids={}, idls={}, program_specs={}. Regenerate the .stack.json with the current arete-macros.",
3271 stack_spec.stack_name,
3272 stack_spec.program_ids.len(),
3273 stack_spec.idls.len(),
3274 stack_spec.program_specs.len(),
3275 ));
3276 }
3277
3278 let mut exact_definitions = Vec::with_capacity(expected);
3279 let mut program_keys = BTreeSet::new();
3280 for (index, ((program_id, idl), program_spec)) in stack_spec
3281 .program_ids
3282 .iter()
3283 .zip(&stack_spec.idls)
3284 .zip(&stack_spec.program_specs)
3285 .enumerate()
3286 {
3287 program_spec.validate().map_err(|error| {
3288 format!(
3289 "Stack '{}' program_specs[{index}] is invalid: {error}",
3290 stack_spec.stack_name
3291 )
3292 })?;
3293 if program_id != &program_spec.program_id {
3294 return Err(format!(
3295 "Stack '{}' program ID mismatch at index {index}: program_ids has '{}', ProgramSpecV1 has '{}'",
3296 stack_spec.stack_name, program_id, program_spec.program_id
3297 ));
3298 }
3299 if idl.program_id.as_deref() != Some(program_spec.program_id.as_str()) {
3300 return Err(format!(
3301 "Stack '{}' IDL program ID mismatch at index {index}: expected '{}'",
3302 stack_spec.stack_name, program_spec.program_id
3303 ));
3304 }
3305 if idl.name != program_spec.idl_snapshot.snapshot.name {
3306 return Err(format!(
3307 "Stack '{}' IDL/ProgramSpec name mismatch at index {index}: '{}' != '{}'",
3308 stack_spec.stack_name, idl.name, program_spec.idl_snapshot.snapshot.name
3309 ));
3310 }
3311 let program_key = to_camel_case(&idl.name);
3312 if !program_keys.insert(program_key.clone()) {
3313 return Err(format!(
3314 "Stack '{}' has an ambiguous duplicate generated program key '{}'",
3315 stack_spec.stack_name, program_key
3316 ));
3317 }
3318 exact_definitions.push(TypeScriptProgramDefinitionMetadata {
3319 program_id: program_spec.program_id.clone(),
3320 sdk_definition_hash: None,
3321 program_spec_hash: program_spec
3322 .hash()
3323 .map_err(|error| {
3324 format!(
3325 "Stack '{}' could not hash ProgramSpecV1 at index {index}: {error}",
3326 stack_spec.stack_name
3327 )
3328 })?
3329 .to_string(),
3330 idl_content_hash: program_spec.idl_content_hash.to_string(),
3331 normalized_idl_hash: program_spec.normalized_idl_hash.to_string(),
3332 });
3333 }
3334
3335 let Some(configured) = configured else {
3336 return stack_spec
3337 .program_specs
3338 .iter()
3339 .enumerate()
3340 .map(|(index, program_spec)| {
3341 arete_hash::OssProgramIdentityV1::new(program_spec.clone())
3342 .map(|identity| TypeScriptProgramConfig::from(&identity))
3343 .map_err(|error| {
3344 format!(
3345 "Stack '{}' could not derive the OSS release for program index {index}: {error}",
3346 stack_spec.stack_name
3347 )
3348 })
3349 })
3350 .collect();
3351 };
3352 if configured.len() != expected {
3353 return Err(format!(
3354 "Stack '{}' hosted descriptor count mismatch: expected {expected}, received {}",
3355 stack_spec.stack_name,
3356 configured.len()
3357 ));
3358 }
3359
3360 for (index, (hosted, exact)) in configured.iter().zip(&exact_definitions).enumerate() {
3361 if hosted.definition.program_id != exact.program_id {
3362 return Err(format!(
3363 "Stack '{}' hosted descriptor program ID mismatch at index {index}: expected '{}', received '{}'",
3364 stack_spec.stack_name,
3365 exact.program_id,
3366 hosted.definition.program_id
3367 ));
3368 }
3369 if hosted.definition.program_spec_hash != exact.program_spec_hash {
3370 return Err(format!(
3371 "Stack '{}' hosted descriptor programSpecHash mismatch at index {index}: expected '{}', received '{}'",
3372 stack_spec.stack_name,
3373 exact.program_spec_hash,
3374 hosted.definition.program_spec_hash
3375 ));
3376 }
3377 if hosted.definition.idl_content_hash != exact.idl_content_hash
3378 || hosted.definition.normalized_idl_hash != exact.normalized_idl_hash
3379 {
3380 return Err(format!(
3381 "Stack '{}' hosted descriptor definition hashes mismatch at index {index}",
3382 stack_spec.stack_name
3383 ));
3384 }
3385 if hosted.release.program_spec_hash != hosted.definition.program_spec_hash {
3386 return Err(format!(
3387 "Stack '{}' hosted descriptor release programSpecHash mismatch at index {index}",
3388 stack_spec.stack_name
3389 ));
3390 }
3391 if let TypeScriptProgramReadTransport::HostedBinding(binding) = &hosted.transport {
3392 let target_kind = binding
3393 .auth
3394 .get("targetKind")
3395 .and_then(serde_json::Value::as_str);
3396 let session_endpoint = binding
3397 .auth
3398 .get("sessionEndpoint")
3399 .and_then(serde_json::Value::as_str);
3400 if binding.endpoint.trim().is_empty()
3401 || binding.program_read_binding_id.trim().is_empty()
3402 || target_kind != Some("program-read-binding")
3403 || session_endpoint.is_none_or(|value| value.trim().is_empty())
3404 {
3405 return Err(format!(
3406 "Stack '{}' hosted descriptor binding is incomplete at index {index}",
3407 stack_spec.stack_name
3408 ));
3409 }
3410 }
3411 }
3412
3413 Ok(configured.to_vec())
3414}
3415
3416pub fn compile_stack_spec(
3423 stack_spec: SerializableStackSpec,
3424 config: Option<TypeScriptStackConfig>,
3425) -> Result<TypeScriptStackOutput, String> {
3426 compile_stack_spec_with_view_selection(stack_spec, config, false)
3427}
3428
3429fn compile_stack_spec_with_view_selection(
3430 stack_spec: SerializableStackSpec,
3431 config: Option<TypeScriptStackConfig>,
3432 exact_views: bool,
3433) -> Result<TypeScriptStackOutput, String> {
3434 let config = config.unwrap_or_default();
3435 let program_configs = resolve_program_configs(&stack_spec, config.programs.as_deref(), true)?;
3436 let stack_name = &stack_spec.stack_name;
3437 let stack_kebab = to_kebab_case(stack_name);
3438
3439 let mut all_interfaces = Vec::new();
3441 let mut entity_names = Vec::new();
3442 let mut schema_names: Vec<String> = Vec::new();
3443 let mut emitted_types: HashSet<String> = HashSet::new();
3444
3445 for entity_spec in &stack_spec.entities {
3446 let mut spec = entity_spec.clone();
3447 if spec.idl.is_none() {
3449 spec.idl = stack_spec.idls.first().cloned();
3450 }
3451 let entity_name = spec.state_name.clone();
3452 entity_names.push(entity_name.clone());
3453
3454 let per_entity_config = TypeScriptConfig {
3455 package_name: config.package_name.clone(),
3456 generate_helpers: false,
3457 interface_prefix: String::new(),
3458 export_const_name: config.export_const_name.clone(),
3459 url: config.websocket_url.clone(),
3460 };
3461
3462 let builtin_type_names = extract_builtin_resolver_type_names(&spec);
3464 let idl_for_check = spec.idl.clone();
3466
3467 let output = compile_serializable_spec_with_emitted(
3468 spec,
3469 entity_name,
3470 Some(per_entity_config),
3471 emitted_types.clone(),
3472 )?;
3473
3474 let emitted_enum_names =
3477 extract_emitted_enum_type_names(&output.interfaces, idl_for_check.as_ref());
3478 emitted_types.extend(emitted_enum_names);
3479 emitted_types.extend(builtin_type_names);
3480 if output
3481 .interfaces
3482 .contains("export interface CaptureWrapper<T>")
3483 {
3484 emitted_types.insert("CaptureWrapper".to_string());
3485 }
3486
3487 if !output.interfaces.is_empty() {
3489 all_interfaces.push(output.interfaces);
3490 }
3491
3492 schema_names.extend(output.schema_names);
3493 }
3494
3495 let mut interfaces = all_interfaces.join("\n\n");
3496
3497 let mut reserved_type_names: std::collections::HashSet<String> =
3502 std::collections::HashSet::new();
3503 for line in interfaces.lines() {
3504 for prefix in ["export interface ", "export type "] {
3505 if let Some(rest) = line.strip_prefix(prefix) {
3506 let name: String = rest
3507 .chars()
3508 .take_while(|c| c.is_alphanumeric() || *c == '_')
3509 .collect();
3510 if !name.is_empty() {
3511 reserved_type_names.insert(name);
3512 }
3513 }
3514 }
3515 }
3516 let idl_account_artifacts =
3517 generate_idl_account_artifacts(&stack_spec.idls, &reserved_type_names);
3518 for type_name in &idl_account_artifacts.type_names {
3519 reserved_type_names.insert(type_name.clone());
3520 }
3521 if !idl_account_artifacts.code.is_empty() {
3522 if interfaces.is_empty() {
3523 interfaces = idl_account_artifacts.code.clone();
3524 } else {
3525 interfaces = format!("{}\n\n{}", interfaces, idl_account_artifacts.code);
3526 }
3527 }
3528 schema_names.extend(idl_account_artifacts.schema_names.clone());
3529
3530 let instructions_codegen = crate::typescript_instructions::generate_instructions_code(
3531 stack_name,
3532 &stack_spec.instructions,
3533 &stack_spec.idls,
3534 &stack_spec.pdas,
3535 &stack_spec.program_ids,
3536 &reserved_type_names,
3537 );
3538 if !instructions_codegen.code.is_empty() {
3539 if interfaces.is_empty() {
3540 interfaces = instructions_codegen.code.clone();
3541 } else {
3542 interfaces = format!("{}\n\n{}", interfaces, instructions_codegen.code);
3543 }
3544 }
3545
3546 let stack_definition = generate_stack_definition_multi(
3548 stack_name,
3549 &stack_kebab,
3550 &stack_spec.entities,
3551 &entity_names,
3552 &stack_spec.idls,
3553 &stack_spec.pdas,
3554 &stack_spec.program_ids,
3555 &schema_names,
3556 &idl_account_artifacts.account_type_names,
3557 &instructions_codegen.stack_entries,
3558 &program_configs,
3559 &config,
3560 exact_views,
3561 )?;
3562
3563 let imports = assemble_sdk_imports(
3565 collect_emitted_pda_imports(&stack_spec.idls, &stack_spec.pdas),
3566 !idl_account_artifacts.account_type_names.is_empty(),
3567 &instructions_codegen,
3568 );
3569
3570 Ok(TypeScriptStackOutput {
3571 imports,
3572 interfaces,
3573 stack_definition,
3574 warnings: instructions_codegen.warnings,
3575 pda_degradations: instructions_codegen.pda_degradations,
3576 })
3577}
3578
3579pub fn compile_stack_spec_with_exact_views(
3582 stack_spec: SerializableStackSpec,
3583 config: Option<TypeScriptStackConfig>,
3584) -> Result<TypeScriptStackOutput, String> {
3585 compile_stack_spec_with_view_selection(stack_spec, config, true)
3586}
3587
3588pub fn compile_public_artifacts(
3590 programs: &[arete_artifacts::ProgramSpecArtifact],
3591 live_spec: &arete_artifacts::LiveSpecArtifact,
3592 manifest: &arete_artifacts::StackManifestArtifact,
3593 config: Option<TypeScriptStackConfig>,
3594) -> Result<TypeScriptStackOutput, String> {
3595 let stack_spec =
3596 crate::public_artifacts::stack_spec_from_artifacts(programs, live_spec, manifest)?;
3597 compile_stack_spec(stack_spec, config)
3598}
3599
3600pub fn compile_public_artifacts_v2(
3602 programs: &[arete_artifacts::ProgramSpecArtifact],
3603 live_spec: &arete_artifacts::LiveSpecArtifactV2,
3604 manifest: &arete_artifacts::StackManifestArtifactV2,
3605 config: Option<TypeScriptStackConfig>,
3606) -> Result<TypeScriptStackOutput, String> {
3607 let stack_spec =
3608 crate::public_artifacts::stack_spec_from_artifacts_v2(programs, live_spec, manifest)?;
3609 compile_stack_spec_with_view_selection(stack_spec, config, true)
3610}
3611
3612pub fn compile_composed_public_artifacts_v2(
3615 programs: &[arete_artifacts::ProgramSpecArtifact],
3616 live_specs: &[(String, arete_artifacts::LiveSpecArtifactV2)],
3617 manifest: &arete_artifacts::StackManifestArtifactV2,
3618 config: Option<TypeScriptCompositionConfig>,
3619) -> Result<TypeScriptCompositionOutput, String> {
3620 let composed =
3621 crate::public_artifacts::stack_specs_from_artifacts_v2(programs, live_specs, manifest)?;
3622 if composed.live_specs.is_empty() {
3623 return Err(
3624 "TypeScript session generation requires at least one aliased LiveSpec".to_string(),
3625 );
3626 }
3627 let config = config.unwrap_or_default();
3628 let live_aliases = composed
3629 .live_specs
3630 .iter()
3631 .map(|live| live.alias.as_str())
3632 .collect::<BTreeSet<_>>();
3633 if let Some(alias) = config
3634 .live_module_imports
3635 .keys()
3636 .find(|alias| !live_aliases.contains(alias.as_str()))
3637 {
3638 return Err(format!(
3639 "composition module import references unknown LiveSpec alias '{alias}'"
3640 ));
3641 }
3642 let mut outputs = Vec::with_capacity(composed.live_specs.len());
3643 let mut warnings = Vec::new();
3644 let mut pda_degradations = Vec::new();
3645
3646 let live_program_hashes = live_specs
3647 .iter()
3648 .flat_map(|(_, live)| &live.payload.programs)
3649 .map(|requirement| requirement.program_spec_hash.to_string())
3650 .collect::<BTreeSet<_>>();
3651 let independent_programs = programs
3652 .iter()
3653 .filter(|program| !live_program_hashes.contains(&program.artifact_hash.to_string()))
3654 .cloned()
3655 .collect::<Vec<_>>();
3656 let independent_program_keys = independent_programs
3657 .iter()
3658 .map(|program| {
3659 let source = to_camel_case(&program.payload.idl_snapshot.snapshot.name);
3660 composition_program_key(program, &source)
3661 })
3662 .collect::<BTreeSet<_>>();
3663 if let Some(alias) = config
3664 .program_module_imports
3665 .keys()
3666 .find(|alias| !independent_program_keys.contains(alias.as_str()))
3667 {
3668 return Err(format!(
3669 "composition program module import references unknown independent program alias '{alias}'"
3670 ));
3671 }
3672 let program_collection = if independent_programs.is_empty() {
3673 None
3674 } else {
3675 let program_stack = crate::public_artifacts::stack_spec_from_program_artifacts(
3676 format!("{}Programs", composed.name),
3677 &independent_programs,
3678 )?;
3679 let mut program_config = config.stack.clone();
3680 program_config.websocket_url = None;
3681 program_config.http_url = None;
3682 program_config.programs =
3683 subset_program_configs(&program_stack, config.stack.programs.as_deref())?;
3684 let output =
3685 compile_stack_spec_with_view_selection(program_stack, Some(program_config), true)?;
3686 warnings.extend(output.warnings.iter().cloned());
3687 pda_degradations.extend(output.pda_degradations.iter().cloned());
3688 Some(TypeScriptProgramCollectionOutput {
3689 module_name: format!("{}-programs", to_kebab_case(&composed.name)),
3690 output,
3691 members: independent_programs
3692 .iter()
3693 .map(|program| {
3694 let source = to_camel_case(&program.payload.idl_snapshot.snapshot.name);
3695 let public = composition_program_key(program, &source);
3696 (public, source)
3697 })
3698 .collect(),
3699 })
3700 };
3701
3702 let mut promoted_programs = Vec::new();
3703 let mut promoted_hashes = BTreeMap::<String, String>::new();
3704 for live in composed.live_specs {
3705 let mut stack_config = config.stack.clone();
3706 if let Some(endpoints) = config.live_endpoints.get(&live.alias) {
3707 stack_config.websocket_url = endpoints.websocket_url.clone();
3708 stack_config.http_url = endpoints.http_url.clone();
3709 } else {
3710 stack_config.websocket_url = None;
3711 stack_config.http_url = None;
3712 }
3713 stack_config.programs =
3714 subset_program_configs(&live.stack_spec, config.stack.programs.as_deref())?;
3715 for program in &live.stack_spec.program_specs {
3716 let source = to_camel_case(&program.idl_snapshot.snapshot.name);
3717 let hash = program
3718 .hash()
3719 .map_err(|error| error.to_string())?
3720 .to_string();
3721 if let Some(existing_hash) = promoted_hashes.get(&source) {
3722 if existing_hash != &hash {
3723 return Err(format!(
3724 "composition programs use duplicate generated key '{source}' for different ProgramSpecs"
3725 ));
3726 }
3727 continue;
3728 }
3729 promoted_hashes.insert(source.clone(), hash);
3730 promoted_programs.push((source.clone(), live.alias.clone(), source));
3731 }
3732 let module_name = typescript_module_name(&live.alias);
3733 let output =
3734 compile_stack_spec_with_view_selection(live.stack_spec, Some(stack_config), true)?;
3735 warnings.extend(output.warnings.iter().cloned());
3736 pda_degradations.extend(output.pda_degradations.iter().cloned());
3737 outputs.push(TypeScriptAliasedStackOutput {
3738 alias: live.alias,
3739 module_name,
3740 output,
3741 });
3742 }
3743
3744 let session_definition = generate_session_definition(
3745 &composed.name,
3746 &outputs,
3747 &promoted_programs,
3748 program_collection.as_ref(),
3749 &config.live_module_imports,
3750 &config.program_module_imports,
3751 );
3752 Ok(TypeScriptCompositionOutput {
3753 name: composed.name,
3754 live_stacks: outputs,
3755 program_collection,
3756 session_definition,
3757 warnings,
3758 pda_degradations,
3759 })
3760}
3761
3762fn subset_program_configs(
3763 stack_spec: &SerializableStackSpec,
3764 configured: Option<&[TypeScriptProgramConfig]>,
3765) -> Result<Option<Vec<TypeScriptProgramConfig>>, String> {
3766 let Some(configured) = configured else {
3767 return Ok(None);
3768 };
3769 let by_hash = configured
3770 .iter()
3771 .map(|program| {
3772 (
3773 program.definition.program_spec_hash.as_str(),
3774 program.clone(),
3775 )
3776 })
3777 .collect::<BTreeMap<_, _>>();
3778 stack_spec
3779 .program_specs
3780 .iter()
3781 .map(|program| {
3782 let hash = program
3783 .hash()
3784 .map_err(|error| error.to_string())?
3785 .to_string();
3786 by_hash.get(hash.as_str()).cloned().ok_or_else(|| {
3787 format!("missing configured program descriptor for ProgramSpec {hash}")
3788 })
3789 })
3790 .collect::<Result<Vec<_>, _>>()
3791 .map(Some)
3792}
3793
3794fn generate_session_definition(
3795 manifest_name: &str,
3796 live_stacks: &[TypeScriptAliasedStackOutput],
3797 promoted_programs: &[(String, String, String)],
3798 program_collection: Option<&TypeScriptProgramCollectionOutput>,
3799 live_module_imports: &BTreeMap<String, String>,
3800 program_module_imports: &BTreeMap<String, String>,
3801) -> String {
3802 let manifest_pascal = safe_pascal_identifier(manifest_name);
3803 let definition_name = format!(
3804 "{}_SESSION_DEFINITION",
3805 to_screaming_snake_case(&manifest_pascal)
3806 );
3807 let imports = live_stacks
3808 .iter()
3809 .map(|live| {
3810 let import = live_module_imports
3811 .get(&live.alias)
3812 .cloned()
3813 .unwrap_or_else(|| format!("./{}.js", live.module_name));
3814 format!(
3815 "import {}Stack from '{}';",
3816 safe_pascal_identifier(&live.alias),
3817 import
3818 )
3819 })
3820 .collect::<Vec<_>>()
3821 .join("\n");
3822 let program_import = program_collection
3823 .map(|programs| {
3824 format!(
3825 "import {manifest_pascal}Programs from './{}.js';",
3826 programs.module_name
3827 )
3828 })
3829 .unwrap_or_default();
3830 let program_module_import_lines = program_module_imports
3831 .iter()
3832 .map(|(alias, import)| {
3833 format!(
3834 "import {}Program from '{}';",
3835 safe_pascal_identifier(alias),
3836 import
3837 )
3838 })
3839 .collect::<Vec<_>>()
3840 .join("\n");
3841 let program_members = program_collection
3842 .map(|programs| {
3843 let definitions = programs
3844 .members
3845 .iter()
3846 .map(|(public, source)| {
3847 let value = if program_module_imports.contains_key(public) {
3848 format!("{}Program", safe_pascal_identifier(public))
3849 } else {
3850 format!(
3851 "{manifest_pascal}Programs.programs.{}",
3852 typescript_property_key(source)
3853 )
3854 };
3855 format!(" {}: {value},", typescript_property_key(public))
3856 })
3857 .collect::<Vec<_>>()
3858 .join("\n");
3859 let reads = programs
3860 .members
3861 .iter()
3862 .map(|(public, source)| {
3863 format!(
3864 " {}: {manifest_pascal}Programs.programReads.{},",
3865 typescript_property_key(public),
3866 typescript_property_key(source)
3867 )
3868 })
3869 .collect::<Vec<_>>()
3870 .join("\n");
3871 (definitions, reads)
3872 })
3873 .unwrap_or_default();
3874 let promoted_definitions = promoted_programs
3875 .iter()
3876 .map(|(public, live_alias, source)| {
3877 format!(
3878 " {}: {}Stack.programs.{},",
3879 typescript_property_key(public),
3880 safe_pascal_identifier(live_alias),
3881 typescript_property_key(source)
3882 )
3883 })
3884 .collect::<Vec<_>>()
3885 .join("\n");
3886 let promoted_reads = promoted_programs
3887 .iter()
3888 .map(|(public, live_alias, source)| {
3889 format!(
3890 " {}: {}Stack.programReads.{},",
3891 typescript_property_key(public),
3892 safe_pascal_identifier(live_alias),
3893 typescript_property_key(source)
3894 )
3895 })
3896 .collect::<Vec<_>>()
3897 .join("\n");
3898 let definitions = [promoted_definitions, program_members.0]
3899 .into_iter()
3900 .filter(|members| !members.is_empty())
3901 .collect::<Vec<_>>()
3902 .join("\n");
3903 let reads = [promoted_reads, program_members.1]
3904 .into_iter()
3905 .filter(|members| !members.is_empty())
3906 .collect::<Vec<_>>()
3907 .join("\n");
3908 let program_members =
3909 format!(" programs: {{\n{definitions}\n }},\n programReads: {{\n{reads}\n }},");
3910 let members = live_stacks
3911 .iter()
3912 .map(|live| {
3913 format!(
3914 " {}: {}Stack,",
3915 typescript_property_key(&live.alias),
3916 safe_pascal_identifier(&live.alias)
3917 )
3918 })
3919 .collect::<Vec<_>>()
3920 .join("\n");
3921 format!(
3922 r#"import {{ createSession, type CompositionSessionOptions }} from '@usearete/sdk';
3923{imports}
3924{program_import}
3925{program_module_import_lines}
3926
3927export const {definition_name} = {{
3928 mode: 'composition',
3929 stacks: {{
3930{members}
3931 }},
3932{program_members}
3933}} as const;
3934
3935export type {manifest_pascal}SessionDefinition = typeof {definition_name};
3936export const {manifest_screaming}_SDK = {definition_name};
3937export type {manifest_pascal}Sdk = {manifest_pascal}SessionDefinition;
3938
3939export function create{manifest_pascal}Session(
3940 options: CompositionSessionOptions<{manifest_pascal}SessionDefinition>
3941) {{
3942 return createSession({definition_name}, options);
3943}}
3944"#,
3945 imports = imports,
3946 program_import = program_import,
3947 program_module_import_lines = program_module_import_lines,
3948 definition_name = definition_name,
3949 members = members,
3950 program_members = program_members,
3951 manifest_pascal = manifest_pascal,
3952 manifest_screaming = to_screaming_snake_case(&manifest_pascal),
3953 )
3954}
3955
3956fn safe_pascal_identifier(value: &str) -> String {
3957 let mut output = value
3958 .split(|character: char| !character.is_ascii_alphanumeric())
3959 .filter(|segment| !segment.is_empty())
3960 .map(to_pascal_case)
3961 .collect::<String>();
3962 if output.is_empty() {
3963 output.push_str("Manifest");
3964 }
3965 if output
3966 .chars()
3967 .next()
3968 .is_some_and(|character| character.is_ascii_digit())
3969 {
3970 output.insert(0, 'A');
3971 }
3972 output
3973}
3974
3975fn typescript_module_name(alias: &str) -> String {
3976 let module = alias
3977 .chars()
3978 .map(|character| {
3979 if character.is_ascii_alphanumeric() {
3980 character.to_ascii_lowercase()
3981 } else {
3982 '-'
3983 }
3984 })
3985 .collect::<String>();
3986 format!("{module}-stack")
3987}
3988
3989fn composition_program_key(
3990 program: &arete_artifacts::ProgramSpecArtifact,
3991 generated_key: &str,
3992) -> String {
3993 match program.payload.program_id.as_str() {
3994 "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA" => "splToken".to_string(),
3995 "ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL" => "splAta".to_string(),
3996 _ => generated_key.to_string(),
3997 }
3998}
3999
4000fn assemble_sdk_imports(
4003 pda_imports: PdaImportUsage,
4004 has_account_reads: bool,
4005 instructions_codegen: &crate::typescript_instructions::InstructionsCodegen,
4006) -> String {
4007 let mut sdk_named: Vec<String> = Vec::new();
4008 for (needed, helper) in [
4009 (pda_imports.pda, "pda"),
4010 (pda_imports.literal, "literal"),
4011 (pda_imports.account, "account"),
4012 (pda_imports.arg, "arg"),
4013 (pda_imports.bytes, "bytes"),
4014 ] {
4015 if needed {
4016 sdk_named.push(helper.to_string());
4017 }
4018 }
4019 if has_account_reads {
4020 sdk_named.push("programAccountRead".to_string());
4021 }
4022 if instructions_codegen.needs_runtime_import {
4023 sdk_named.push("createInstructionHandler".to_string());
4024 sdk_named.push("type ErrorMetadata".to_string());
4025 }
4026 if !instructions_codegen.stack_entries.is_empty() {
4027 sdk_named.push("buildInstruction".to_string());
4028 }
4029 if instructions_codegen.needs_build_options {
4030 sdk_named.push("type BuildOptions".to_string());
4031 }
4032 if instructions_codegen.needs_program_runtime_extensions {
4033 sdk_named.push("PROGRAM_OPERATION_EXTENSIONS".to_string());
4034 sdk_named.push("instructionOperation".to_string());
4035 sdk_named.push("createPreparedInstruction".to_string());
4036 }
4037 if instructions_codegen.needs_operation_context {
4038 sdk_named.push("type ProgramOperationContext".to_string());
4039 }
4040 if instructions_codegen.needs_amount_input {
4041 sdk_named.push("type AmountInput".to_string());
4042 }
4043 if instructions_codegen.needs_resolve_amount_to_raw {
4044 sdk_named.push("resolveAmountToRaw".to_string());
4045 }
4046 if instructions_codegen.needs_to_raw_amount {
4047 sdk_named.push("toRawAmount".to_string());
4048 }
4049 if sdk_named.is_empty() {
4050 "import { z } from 'zod';".to_string()
4051 } else {
4052 format!(
4053 "import {{ z }} from 'zod';\nimport {{ {} }} from '@usearete/sdk';",
4054 sdk_named.join(", ")
4055 )
4056 }
4057}
4058
4059#[derive(Debug, Clone, Copy, Default)]
4060struct PdaImportUsage {
4061 pda: bool,
4062 literal: bool,
4063 account: bool,
4064 arg: bool,
4065 bytes: bool,
4066}
4067
4068fn collect_emitted_pda_imports(
4069 idls: &[IdlSnapshot],
4070 pdas: &BTreeMap<String, BTreeMap<String, PdaDefinition>>,
4071) -> PdaImportUsage {
4072 let mut usage = PdaImportUsage::default();
4073
4074 for idl in idls {
4075 let Some(program_pdas) = pdas
4076 .get(&idl.name)
4077 .or_else(|| pdas.get(&to_camel_case(&idl.name)))
4078 .filter(|program_pdas| !program_pdas.is_empty())
4079 else {
4080 continue;
4081 };
4082
4083 usage.pda = true;
4084 for seed in program_pdas.values().flat_map(|pda| &pda.seeds) {
4085 match seed {
4086 PdaSeedDef::Literal { .. } => usage.literal = true,
4087 PdaSeedDef::AccountRef { .. } => usage.account = true,
4088 PdaSeedDef::ArgRef { .. } => usage.arg = true,
4089 PdaSeedDef::Bytes { .. } => usage.bytes = true,
4090 }
4091 }
4092 }
4093
4094 usage
4095}
4096
4097pub fn compile_program_modules(
4102 stack_spec: SerializableStackSpec,
4103 config: Option<TypeScriptStackConfig>,
4104) -> Result<TypeScriptStackOutput, String> {
4105 let config = config.unwrap_or_default();
4106 let program_configs = resolve_program_configs(&stack_spec, config.programs.as_deref(), false)?;
4107 let stack_name = &stack_spec.stack_name;
4108
4109 if stack_spec.idls.is_empty() {
4110 return Err(format!(
4111 "Stack '{}' carries no IDLs; a program-only SDK has nothing to emit",
4112 stack_name
4113 ));
4114 }
4115
4116 let mut reserved_type_names: std::collections::HashSet<String> =
4117 std::collections::HashSet::new();
4118 let idl_account_artifacts =
4119 generate_idl_account_artifacts(&stack_spec.idls, &reserved_type_names);
4120 for type_name in &idl_account_artifacts.type_names {
4121 reserved_type_names.insert(type_name.clone());
4122 }
4123
4124 let mut interfaces = idl_account_artifacts.code.clone();
4125
4126 let instructions_codegen = crate::typescript_instructions::generate_instructions_code(
4127 stack_name,
4128 &stack_spec.instructions,
4129 &stack_spec.idls,
4130 &stack_spec.pdas,
4131 &stack_spec.program_ids,
4132 &reserved_type_names,
4133 );
4134 if !instructions_codegen.code.is_empty() {
4135 if interfaces.is_empty() {
4136 interfaces = instructions_codegen.code.clone();
4137 } else {
4138 interfaces = format!("{}\n\n{}", interfaces, instructions_codegen.code);
4139 }
4140 }
4141
4142 let unique_schemas: BTreeSet<String> =
4143 idl_account_artifacts.schema_names.iter().cloned().collect();
4144
4145 let program_context = ProgramGenerationContext {
4146 pdas: &stack_spec.pdas,
4147 program_ids: &stack_spec.program_ids,
4148 instruction_entries: &instructions_codegen.stack_entries,
4149 schema_names: &unique_schemas,
4150 account_type_names: &idl_account_artifacts.account_type_names,
4151 programs: &program_configs,
4152 };
4153 let stack_definition =
4154 generate_program_definitions(stack_name, &stack_spec.idls, &program_context);
4155
4156 let imports = assemble_sdk_imports(
4157 collect_emitted_pda_imports(&stack_spec.idls, &stack_spec.pdas),
4158 !idl_account_artifacts.account_type_names.is_empty(),
4159 &instructions_codegen,
4160 );
4161
4162 Ok(TypeScriptStackOutput {
4163 imports,
4164 interfaces,
4165 stack_definition,
4166 warnings: instructions_codegen.warnings,
4167 pda_degradations: instructions_codegen.pda_degradations,
4168 })
4169}
4170
4171pub fn compile_program_artifacts(
4173 name: impl Into<String>,
4174 programs: &[arete_artifacts::ProgramSpecArtifact],
4175 config: Option<TypeScriptStackConfig>,
4176) -> Result<TypeScriptStackOutput, String> {
4177 let stack_spec = crate::public_artifacts::stack_spec_from_program_artifacts(name, programs)?;
4178 compile_program_modules(stack_spec, config)
4179}
4180
4181pub fn write_stack_typescript_to_file(
4183 output: &TypeScriptStackOutput,
4184 path: &std::path::Path,
4185) -> Result<(), std::io::Error> {
4186 std::fs::write(path, output.full_file())
4187}
4188
4189#[allow(clippy::too_many_arguments)]
4213fn generate_stack_definition_multi(
4214 stack_name: &str,
4215 stack_kebab: &str,
4216 entities: &[SerializableStreamSpec],
4217 entity_names: &[String],
4218 idls: &[IdlSnapshot],
4219 pdas: &BTreeMap<String, BTreeMap<String, PdaDefinition>>,
4220 program_ids: &[String],
4221 schema_names: &[String],
4222 account_type_names: &BTreeMap<(String, String), String>,
4223 instruction_entries: &[crate::typescript_instructions::StackInstructionEntry],
4224 program_configs: &[TypeScriptProgramConfig],
4225 config: &TypeScriptStackConfig,
4226 exact_views: bool,
4227) -> Result<String, String> {
4228 let export_name = format!(
4229 "{}_{}",
4230 to_screaming_snake_case(stack_name),
4231 config.export_const_name
4232 );
4233 let core_export_name = format!("{}_CORE", export_name);
4234
4235 let view_helpers = generate_view_helpers_static();
4236
4237 let websocket_endpoint = match &config.websocket_url {
4238 Some(url) => format!(" ws: '{}',", url),
4239 None => " ws: '', // TODO: Set after first deployment or pass useArete(..., { url })"
4240 .to_string(),
4241 };
4242 let http_endpoint = match &config.http_url {
4243 Some(url) => format!(" http: '{}',", url),
4244 None => {
4245 " http: '', // TODO: Set after first deployment or pass useArete(..., { httpUrl })"
4246 .to_string()
4247 }
4248 };
4249 let endpoints_block = format!(
4250 " endpoints: {{\n{}\n{}\n }},",
4251 websocket_endpoint, http_endpoint
4252 );
4253
4254 let mut entity_view_blocks = Vec::new();
4256 for (i, entity_spec) in entities.iter().enumerate() {
4257 let entity_name = &entity_names[i];
4258 let entity_pascal = to_pascal_case(entity_name);
4259 let mut view_entries = Vec::new();
4260
4261 if !exact_views
4262 || entity_spec
4263 .views
4264 .iter()
4265 .any(|view| view.id == format!("{entity_name}/state"))
4266 {
4267 let state_view_key = state_view_key_definition(
4268 entity_name,
4269 &entity_spec.identity,
4270 &entity_spec.field_mappings,
4271 &entity_spec.sections,
4272 )?;
4273 view_entries.push(format!(
4274 " state: stateView<{entity}, {key_type}>('{entity_name}/state', {key_fields}),",
4275 entity = entity_pascal,
4276 entity_name = entity_name,
4277 key_type = state_view_key.object_type(),
4278 key_fields = state_view_key.fields_literal(),
4279 ));
4280 }
4281
4282 if !exact_views
4283 || entity_spec
4284 .views
4285 .iter()
4286 .any(|view| view.id == format!("{entity_name}/list"))
4287 {
4288 view_entries.push(format!(
4289 " list: listView<{entity}>('{entity_name}/list'),",
4290 entity = entity_pascal,
4291 entity_name = entity_name
4292 ));
4293 }
4294
4295 for view in &entity_spec.views {
4296 if !view.id.ends_with("/state")
4297 && !view.id.ends_with("/list")
4298 && view.id.starts_with(entity_name)
4299 {
4300 let view_name = view.id.split('/').nth(1).unwrap_or("unknown");
4301 view_entries.push(format!(
4302 " {}: listView<{entity}>('{}'),",
4303 typescript_property_key(view_name),
4304 view.id,
4305 entity = entity_pascal
4306 ));
4307 }
4308 }
4309
4310 if !view_entries.is_empty() {
4311 entity_view_blocks.push(format!(
4312 " {}: {{\n{}\n }},",
4313 typescript_property_key(entity_name),
4314 view_entries.join("\n")
4315 ));
4316 }
4317 }
4318
4319 let views_body = entity_view_blocks.join("\n");
4320
4321 let mut unique_schemas: BTreeSet<String> = BTreeSet::new();
4322 for name in schema_names {
4323 unique_schemas.insert(name.clone());
4324 }
4325 let schemas_block = if unique_schemas.is_empty() {
4326 String::new()
4327 } else {
4328 let schema_entries: Vec<String> = unique_schemas
4329 .iter()
4330 .filter(|name| name.ends_with("Schema") && !name.ends_with("PatchSchema"))
4331 .map(|name| format!(" {}: {},", name.trim_end_matches("Schema"), name))
4332 .collect();
4333 if schema_entries.is_empty() {
4334 String::new()
4335 } else {
4336 format!("\n schemas: {{\n{}\n }},", schema_entries.join("\n"))
4337 }
4338 };
4339 let patch_schema_entries: Vec<String> = entity_names
4340 .iter()
4341 .map(|entity_name| {
4342 let entity_pascal = to_pascal_case(entity_name);
4343 format!(" {}: {}PatchSchema,", entity_pascal, entity_pascal)
4344 })
4345 .collect();
4346 let patch_schemas_block = if patch_schema_entries.is_empty() {
4347 String::new()
4348 } else {
4349 format!(
4350 "\n patchSchemas: {{\n{}\n }},",
4351 patch_schema_entries.join("\n")
4352 )
4353 };
4354
4355 let program_context = ProgramGenerationContext {
4356 pdas,
4357 program_ids,
4358 instruction_entries,
4359 schema_names: &unique_schemas,
4360 account_type_names,
4361 programs: program_configs,
4362 };
4363 let programs_block = generate_programs_block(idls, &program_context);
4364 let program_reads_block = generate_program_reads_block(idls, &program_context);
4365 let addresses_block = generate_stack_addresses_block(idls, pdas, program_ids);
4366
4367 let entity_types: Vec<String> = entity_names.iter().map(|n| to_pascal_case(n)).collect();
4368
4369 let stack_export = format!(
4370 r#"export const {core_export_name} = {{
4371 name: '{stack_kebab}',
4372{endpoints_block}
4373 views: {{
4374{views_body}
4375 }},{schemas_section}{patch_schemas_section}{programs_section}{program_reads_section}{addresses_section}
4376}} as const;"#,
4377 core_export_name = core_export_name,
4378 stack_kebab = stack_kebab,
4379 endpoints_block = endpoints_block,
4380 views_body = views_body,
4381 schemas_section = schemas_block,
4382 patch_schemas_section = patch_schemas_block,
4383 programs_section = programs_block,
4384 program_reads_section = program_reads_block,
4385 addresses_section = addresses_block,
4386 );
4387
4388 Ok(format!(
4389 r#"{view_helpers}
4390
4391// ============================================================================
4392// Stack Definition
4393// ============================================================================
4394
4395/** Stack definition for {stack_name} with {entity_count} entities */
4396{stack_export}
4397
4398/** Type alias for the core stack */
4399export type {stack_name}CoreStack = typeof {core_export_name};
4400
4401/** Entity types in this stack */
4402export type {stack_name}Entity = {entity_union};
4403
4404/** Default export for convenience */
4405export default {core_export_name};"#,
4406 view_helpers = view_helpers,
4407 stack_name = stack_name,
4408 entity_count = entities.len(),
4409 core_export_name = core_export_name,
4410 stack_export = stack_export,
4411 entity_union = if entity_types.is_empty() {
4412 "never".to_string()
4413 } else {
4414 entity_types.join(" | ")
4415 },
4416 ))
4417}
4418
4419fn typescript_property_key(value: &str) -> String {
4420 let mut characters = value.chars();
4421 let valid = characters
4422 .next()
4423 .is_some_and(|character| character.is_ascii_alphabetic() || matches!(character, '_' | '$'))
4424 && characters
4425 .all(|character| character.is_ascii_alphanumeric() || matches!(character, '_' | '$'));
4426 if valid {
4427 value.to_string()
4428 } else {
4429 serde_json::to_string(value).expect("string property serialization cannot fail")
4430 }
4431}
4432
4433struct ProgramGenerationContext<'a> {
4434 pdas: &'a BTreeMap<String, BTreeMap<String, PdaDefinition>>,
4435 program_ids: &'a [String],
4436 instruction_entries: &'a [crate::typescript_instructions::StackInstructionEntry],
4437 schema_names: &'a BTreeSet<String>,
4438 account_type_names: &'a BTreeMap<(String, String), String>,
4439 programs: &'a [TypeScriptProgramConfig],
4440}
4441
4442fn generate_single_program_sections(
4447 idl: &IdlSnapshot,
4448 index: usize,
4449 context: &ProgramGenerationContext<'_>,
4450) -> (String, Vec<String>) {
4451 let program_key = to_camel_case(&idl.name);
4452 let metadata = &context.programs[index];
4453 let multi_program = context.program_ids.len() > 1
4454 || context
4455 .instruction_entries
4456 .iter()
4457 .any(|entry| entry.program_key.is_some());
4458 let program_id = context
4459 .program_ids
4460 .get(index)
4461 .cloned()
4462 .or_else(|| idl.program_id.clone())
4463 .unwrap_or_default();
4464
4465 let instruction_entries_for_program: Vec<
4466 &crate::typescript_instructions::StackInstructionEntry,
4467 > = context
4468 .instruction_entries
4469 .iter()
4470 .filter(|entry| {
4471 if multi_program {
4472 entry.program_key.as_deref() == Some(program_key.as_str())
4473 } else {
4474 true
4475 }
4476 })
4477 .collect();
4478 let instruction_entry_literals: Vec<String> = instruction_entries_for_program
4479 .iter()
4480 .map(|entry| {
4481 format!(
4482 " {}: {},",
4483 entry.instruction_name, entry.handler_const
4484 )
4485 })
4486 .collect();
4487
4488 let account_entries: Vec<String> = idl
4489 .accounts
4490 .iter()
4491 .filter_map(|account| {
4492 let type_name = context
4493 .account_type_names
4494 .get(&(program_key.clone(), account.name.clone()))?
4495 .clone();
4496 let schema_name = format!("{}Schema", type_name);
4497 if !context.schema_names.contains(&schema_name) {
4498 return None;
4499 }
4500 Some((account.name.clone(), type_name, schema_name))
4501 })
4502 .map(|account| {
4503 format!(
4504 " {account_name}: programAccountRead<{type_name}>({{ account: '{account_name}', schema: {schema_name} }}),",
4505 account_name = account.0,
4506 type_name = account.1,
4507 schema_name = account.2,
4508 )
4509 })
4510 .collect();
4511
4512 let program_pdas = context
4513 .pdas
4514 .get(&idl.name)
4515 .or_else(|| context.pdas.get(&program_key))
4516 .filter(|program_pdas| !program_pdas.is_empty());
4517
4518 let mut sections: Vec<String> = vec![
4519 format!(" name: '{}',", idl.name),
4520 format!(" programId: '{}',", program_id),
4521 ];
4522 if let Some(definition_hash) = &metadata.definition.sdk_definition_hash {
4523 sections.push(format!(" sdkDefinitionHash: '{}',", definition_hash));
4524 }
4525 sections.extend([
4526 format!(
4527 " programSpecHash: '{}',",
4528 metadata.definition.program_spec_hash
4529 ),
4530 format!(
4531 " idlContentHash: '{}',",
4532 metadata.definition.idl_content_hash
4533 ),
4534 format!(
4535 " normalizedIdlHash: '{}',",
4536 metadata.definition.normalized_idl_hash
4537 ),
4538 ]);
4539
4540 if let Some(program_pdas) = program_pdas {
4541 let pda_entries = generate_program_pda_entries(program_pdas, &program_id, " ");
4542 if !pda_entries.is_empty() {
4543 sections.push(format!(
4544 " pdas: {{\n{}\n }},",
4545 pda_entries.join("\n")
4546 ));
4547 sections.push(format!(
4548 " addresses: {{\n{}\n }},",
4549 pda_entries.join("\n")
4550 ));
4551 }
4552 }
4553
4554 if !account_entries.is_empty() {
4555 sections.push(format!(
4556 " accounts: {{\n{}\n }},",
4557 account_entries.join("\n")
4558 ));
4559 }
4560
4561 if !instruction_entry_literals.is_empty() {
4562 sections.push(format!(
4563 " rawInstructions: {{\n{}\n }},",
4564 instruction_entry_literals.join("\n")
4565 ));
4566 if let Some(semantic_block) =
4567 generate_program_semantic_instructions_block(&instruction_entries_for_program, " ")
4568 {
4569 sections.push(semantic_block);
4570 }
4571 }
4572
4573 (program_key, sections)
4574}
4575
4576fn generate_programs_block(idls: &[IdlSnapshot], context: &ProgramGenerationContext<'_>) -> String {
4577 if idls.is_empty() || context.programs.is_empty() {
4578 return String::new();
4579 }
4580
4581 let mut program_blocks = Vec::new();
4582
4583 for (index, idl) in idls.iter().enumerate() {
4584 let (program_key, sections) = generate_single_program_sections(idl, index, context);
4585
4586 program_blocks.push(format!(
4587 " {}: {{\n{}\n }},",
4588 program_key,
4589 sections.join("\n")
4590 ));
4591 }
4592
4593 if program_blocks.is_empty() {
4594 return String::new();
4595 }
4596
4597 format!("\n programs: {{\n{}\n }},", program_blocks.join("\n"))
4598}
4599
4600fn generate_program_read_sections(metadata: &TypeScriptProgramConfig, indent: &str) -> Vec<String> {
4601 let mut sections = vec![format!(
4602 "{indent}release: {{ programReleaseHash: {release_hash}, programSpecHash: {spec_hash} }},",
4603 release_hash = serde_json::to_string(&metadata.release.program_release_hash)
4604 .expect("program release hash must serialize"),
4605 spec_hash = serde_json::to_string(&metadata.release.program_spec_hash)
4606 .expect("program spec hash must serialize"),
4607 )];
4608 sections.push(match &metadata.transport {
4609 TypeScriptProgramReadTransport::LocalHttp => format!(
4610 "{indent}transport: {{ kind: 'local-http', endpointSource: 'connect-http-url' }},"
4611 ),
4612 TypeScriptProgramReadTransport::HostedBinding(binding) => format!(
4613 "{indent}transport: {{ kind: 'hosted-binding', binding: {{ endpoint: {endpoint}, programReadBindingId: {binding_id}, auth: {auth} }} }},",
4614 endpoint = serde_json::to_string(&binding.endpoint)
4615 .expect("program endpoint must serialize"),
4616 binding_id = serde_json::to_string(&binding.program_read_binding_id)
4617 .expect("program binding ID must serialize"),
4618 auth = serde_json::to_string(&binding.auth)
4619 .expect("program auth metadata must serialize"),
4620 ),
4621 });
4622 sections
4623}
4624
4625fn generate_program_reads_block(
4626 idls: &[IdlSnapshot],
4627 context: &ProgramGenerationContext<'_>,
4628) -> String {
4629 if idls.is_empty() || context.programs.is_empty() {
4630 return String::new();
4631 }
4632
4633 let entries = idls
4634 .iter()
4635 .zip(context.programs)
4636 .map(|(idl, metadata)| {
4637 format!(
4638 " {}: {{\n{}\n }},",
4639 to_camel_case(&idl.name),
4640 generate_program_read_sections(metadata, " ").join("\n")
4641 )
4642 })
4643 .collect::<Vec<_>>();
4644 format!("\n programReads: {{\n{}\n }},", entries.join("\n"))
4645}
4646
4647fn dedent_lines(text: &str, spaces: usize) -> String {
4649 text.lines()
4650 .map(|line| {
4651 let strip = line
4652 .char_indices()
4653 .take_while(|(i, c)| *i < spaces && *c == ' ')
4654 .count();
4655 &line[strip..]
4656 })
4657 .collect::<Vec<_>>()
4658 .join("\n")
4659}
4660
4661fn generate_program_definitions(
4671 stack_name: &str,
4672 idls: &[IdlSnapshot],
4673 context: &ProgramGenerationContext<'_>,
4674) -> String {
4675 let mut program_consts = Vec::new();
4676 let mut map_entries = Vec::new();
4677 let mut program_read_consts = Vec::new();
4678 let mut read_map_entries = Vec::new();
4679
4680 for (index, idl) in idls.iter().enumerate() {
4681 let (program_key, sections) = generate_single_program_sections(idl, index, context);
4682 let const_name = to_screaming_snake_case(&idl.name);
4683 let body = dedent_lines(§ions.join("\n"), 4);
4684 program_consts.push(format!(
4685 "/** Standalone program SDK for '{name}' */\nexport const {const_name} = {{\n{body}\n}} as const;",
4686 name = idl.name,
4687 const_name = const_name,
4688 body = body,
4689 ));
4690 map_entries.push(format!(" {}: {},", program_key, const_name));
4691 let read_const_name = format!("{}_READ", const_name);
4692 let read_body = dedent_lines(
4693 &generate_program_read_sections(&context.programs[index], " ").join("\n"),
4694 4,
4695 );
4696 program_read_consts.push(format!(
4697 "/** Release and explicit read transport for '{name}' */\nexport const {read_const_name} = {{\n{read_body}\n}} as const;",
4698 name = idl.name,
4699 ));
4700 read_map_entries.push(format!(" {}: {},", program_key, read_const_name));
4701 }
4702
4703 let map_name = format!("{}_PROGRAMS", to_screaming_snake_case(stack_name));
4704 let reads_map_name = format!("{}_PROGRAM_READS", to_screaming_snake_case(stack_name));
4705 let type_name = format!("{}Programs", to_pascal_case(stack_name));
4706
4707 format!(
4708 r#"// ============================================================================
4709// Program Definitions
4710// ============================================================================
4711
4712{program_consts}
4713
4714{program_read_consts}
4715
4716/** All portable programs from the {stack_name} stack */
4717export const {map_name} = {{
4718{map_entries}
4719}} as const;
4720
4721/** Parallel release/read metadata keyed identically to {map_name} */
4722export const {reads_map_name} = {{
4723{read_map_entries}
4724}} as const;
4725
4726export type {type_name} = typeof {map_name};
4727
4728export default {map_name};"#,
4729 program_consts = program_consts.join("\n\n"),
4730 program_read_consts = program_read_consts.join("\n\n"),
4731 stack_name = stack_name,
4732 map_name = map_name,
4733 map_entries = map_entries.join("\n"),
4734 reads_map_name = reads_map_name,
4735 read_map_entries = read_map_entries.join("\n"),
4736 type_name = type_name,
4737 )
4738}
4739
4740fn generate_program_pda_entries(
4741 program_pdas: &BTreeMap<String, PdaDefinition>,
4742 default_program_id: &str,
4743 indent: &str,
4744) -> Vec<String> {
4745 if program_pdas.is_empty() {
4746 return Vec::new();
4747 }
4748
4749 program_pdas
4750 .iter()
4751 .map(|(pda_name, pda_def)| {
4752 let seeds_str = pda_def
4753 .seeds
4754 .iter()
4755 .map(|seed| match seed {
4756 PdaSeedDef::Literal { value } => format!("literal('{}')", value),
4757 PdaSeedDef::AccountRef { account_name } => {
4758 format!("account('{}')", account_name)
4759 }
4760 PdaSeedDef::ArgRef { arg_name, arg_type } => {
4761 if let Some(t) = arg_type {
4762 format!("arg('{}', '{}')", arg_name, t)
4763 } else {
4764 format!("arg('{}')", arg_name)
4765 }
4766 }
4767 PdaSeedDef::Bytes { value } => {
4768 let bytes_arr: Vec<String> = value.iter().map(|b| b.to_string()).collect();
4769 format!("bytes(new Uint8Array([{}]))", bytes_arr.join(", "))
4770 }
4771 })
4772 .collect::<Vec<_>>()
4773 .join(", ");
4774
4775 let pid = pda_def.program_id.as_deref().unwrap_or(default_program_id);
4776 let rendered_seeds = if seeds_str.is_empty() {
4777 String::new()
4778 } else {
4779 format!(", {}", seeds_str)
4780 };
4781 format!("{}{}: pda('{}'{}),", indent, pda_name, pid, rendered_seeds)
4782 })
4783 .collect()
4784}
4785
4786fn generate_stack_addresses_block(
4787 idls: &[IdlSnapshot],
4788 pdas: &BTreeMap<String, BTreeMap<String, PdaDefinition>>,
4789 program_ids: &[String],
4790) -> String {
4791 if idls.is_empty() {
4792 return String::new();
4793 }
4794
4795 if idls.len() == 1 {
4796 let idl = &idls[0];
4797 let program_id = program_ids
4798 .first()
4799 .cloned()
4800 .or_else(|| idl.program_id.clone())
4801 .unwrap_or_default();
4802 let Some(program_pdas) = pdas
4803 .get(&idl.name)
4804 .or_else(|| pdas.get(&to_camel_case(&idl.name)))
4805 else {
4806 return String::new();
4807 };
4808 let entries = generate_program_pda_entries(program_pdas, &program_id, " ");
4809 if entries.is_empty() {
4810 return String::new();
4811 }
4812 return format!("\n addresses: {{\n{}\n }},", entries.join("\n"));
4813 }
4814
4815 let mut blocks = Vec::new();
4816 for (index, idl) in idls.iter().enumerate() {
4817 let program_id = program_ids
4818 .get(index)
4819 .cloned()
4820 .or_else(|| idl.program_id.clone())
4821 .unwrap_or_default();
4822 let Some(program_pdas) = pdas
4823 .get(&idl.name)
4824 .or_else(|| pdas.get(&to_camel_case(&idl.name)))
4825 else {
4826 continue;
4827 };
4828 let entries = generate_program_pda_entries(program_pdas, &program_id, " ");
4829 if entries.is_empty() {
4830 continue;
4831 }
4832 blocks.push(format!(
4833 " {}: {{\n{}\n }},",
4834 to_camel_case(&idl.name),
4835 entries.join("\n")
4836 ));
4837 }
4838
4839 if blocks.is_empty() {
4840 String::new()
4841 } else {
4842 format!("\n addresses: {{\n{}\n }},", blocks.join("\n"))
4843 }
4844}
4845
4846fn is_valid_ts_identifier(name: &str) -> bool {
4847 name.chars()
4848 .next()
4849 .map(|c| c.is_ascii_alphabetic() || c == '_' || c == '$')
4850 .unwrap_or(false)
4851 && name
4852 .chars()
4853 .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '$')
4854}
4855
4856fn escape_ts_single_quotes(value: &str) -> String {
4857 value
4858 .replace('\\', "\\\\")
4859 .replace('\'', "\\'")
4860 .replace(['\n', '\r'], " ")
4861}
4862
4863fn render_ts_property_name_literal(name: &str) -> String {
4864 if is_valid_ts_identifier(name) {
4865 name.to_string()
4866 } else {
4867 format!("'{}'", escape_ts_single_quotes(name))
4868 }
4869}
4870
4871fn render_program_semantic_instruction_entry(
4872 entry: &crate::typescript_instructions::StackInstructionEntry,
4873 indent: &str,
4874) -> Option<String> {
4875 let semantic_params_type = entry.semantic_params_type.as_ref()?;
4876 entry.runtime_program_key.as_ref()?;
4877
4878 if entry.semantic_amount_args.is_empty() {
4879 return Some(format!(
4880 "{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}}}),",
4881 indent = indent,
4882 instruction_name = entry.instruction_name,
4883 semantic_params_type = semantic_params_type,
4884 handler_const = entry.handler_const,
4885 ));
4886 }
4887
4888 let raw_params_setup = if entry.semantic_extra_params.is_empty() {
4889 format!(
4890 "{indent} const {{ build, ...rawParams }} = params;",
4891 indent = indent
4892 )
4893 } else {
4894 format!(
4895 "{indent} const {{ build, {extras}, ...rawParams }} = params;",
4896 indent = indent,
4897 extras = entry.semantic_extra_params.join(", "),
4898 )
4899 };
4900 let resolution_lines: Vec<String> = entry
4901 .semantic_amount_args
4902 .iter()
4903 .map(|amount_arg| {
4904 format!(
4905 "{indent} const {binding_name} = {raw_expression};",
4906 indent = indent,
4907 binding_name = amount_arg.binding_name,
4908 raw_expression = amount_arg.raw_expression,
4909 )
4910 })
4911 .collect();
4912 let raw_assignments: Vec<String> = entry
4913 .semantic_amount_args
4914 .iter()
4915 .map(|amount_arg| {
4916 format!(
4917 "{indent} {}: {},",
4918 render_ts_property_name_literal(&amount_arg.arg_name),
4919 amount_arg.binding_name,
4920 indent = indent,
4921 )
4922 })
4923 .collect();
4924
4925 Some(format!(
4926 "{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} }}),",
4927 indent = indent,
4928 instruction_name = entry.instruction_name,
4929 semantic_params_type = semantic_params_type,
4930 raw_params_setup = raw_params_setup,
4931 resolutions = resolution_lines.join("\n"),
4932 handler_const = entry.handler_const,
4933 assignments = raw_assignments.join("\n"),
4934 ))
4935}
4936
4937fn generate_program_semantic_instructions_block(
4938 instruction_entries: &[&crate::typescript_instructions::StackInstructionEntry],
4939 indent: &str,
4940) -> Option<String> {
4941 let entry_indent = format!("{} ", indent);
4942 let entries: Vec<String> = instruction_entries
4943 .iter()
4944 .filter_map(|entry| render_program_semantic_instruction_entry(entry, &entry_indent))
4945 .collect();
4946 if entries.is_empty() {
4947 return None;
4948 }
4949
4950 let context_param = if instruction_entries
4951 .iter()
4952 .any(|entry| entry.uses_operation_context)
4953 {
4954 "context: ProgramOperationContext"
4955 } else {
4956 ""
4957 };
4958
4959 Some(format!(
4960 "{indent}[PROGRAM_OPERATION_EXTENSIONS]: {{\n{indent} createOperations({context_param}) {{\n{indent} return {{\n{indent} instructions: {{\n{entries}\n{indent} }},\n{indent} }};\n{indent} }},\n{indent}}},",
4961 indent = indent,
4962 context_param = context_param,
4963 entries = entries.join("\n"),
4964 ))
4965}
4966
4967fn generate_view_helpers_static() -> String {
4968 r#"// ============================================================================
4969// View Definition Types (framework-agnostic)
4970// ============================================================================
4971
4972export type ViewKeyFields<TKey> = unknown extends TKey
4973 ? readonly string[]
4974 : TKey extends object
4975 ? readonly Extract<keyof TKey, string>[]
4976 : readonly string[];
4977
4978/** View definition with embedded entity and state-key types */
4979export interface ViewDef<T, TMode extends 'state' | 'list', TKey = unknown> {
4980 readonly mode: TMode;
4981 readonly view: string;
4982 readonly keyFields?: ViewKeyFields<TKey>;
4983 /** Phantom field for type inference - not present at runtime */
4984 readonly _entity?: T;
4985 readonly _key?: TKey;
4986}
4987
4988/** Helper to create typed state view definitions (keyed lookups) */
4989function stateView<T, TKey = unknown>(
4990 view: string,
4991 keyFields: ViewKeyFields<TKey>
4992): ViewDef<T, 'state', TKey> {
4993 return { mode: 'state', view, keyFields } as const;
4994}
4995
4996/** Helper to create typed list view definitions (collections) */
4997function listView<T>(view: string): ViewDef<T, 'list'> {
4998 return { mode: 'list', view } as const;
4999}"#
5000 .to_string()
5001}
5002
5003pub(crate) fn to_screaming_snake_case(s: &str) -> String {
5005 let mut result = String::new();
5006 for (i, ch) in s.chars().enumerate() {
5007 if ch.is_uppercase() && i > 0 {
5008 result.push('_');
5009 }
5010 result.push(ch.to_uppercase().next().unwrap());
5011 }
5012 result
5013}
5014
5015#[cfg(test)]
5016mod tests {
5017 use super::*;
5018
5019 fn demo_program_spec() -> arete_hash::ProgramSpecV1 {
5020 arete_hash::build_program_spec_v1_from_bytes(
5021 br#"{
5022 "address":"Prog111",
5023 "version":"0.1.0",
5024 "name":"demo",
5025 "instructions":[],
5026 "accounts":[],
5027 "types":[],
5028 "events":[],
5029 "errors":[]
5030 }"#,
5031 None,
5032 )
5033 .expect("test ProgramSpecV1")
5034 }
5035
5036 fn named_program_spec(name: &str, program_id: &str) -> arete_hash::ProgramSpecV1 {
5037 arete_hash::build_program_spec_v1_from_bytes(
5038 format!(
5039 r#"{{
5040 "address":"{program_id}",
5041 "version":"0.1.0",
5042 "name":"{name}",
5043 "instructions":[],
5044 "accounts":[],
5045 "types":[],
5046 "events":[],
5047 "errors":[]
5048 }}"#
5049 )
5050 .as_bytes(),
5051 None,
5052 )
5053 .expect("named test ProgramSpecV1")
5054 }
5055
5056 fn two_program_test_spec() -> SerializableStackSpec {
5057 let specs = vec![
5058 named_program_spec("second_program", "Program222"),
5059 named_program_spec("first_program", "Program111"),
5060 ];
5061 SerializableStackSpec {
5062 ast_version: CURRENT_AST_VERSION.to_string(),
5063 stack_name: "OrderedStream".to_string(),
5064 program_ids: specs.iter().map(|spec| spec.program_id.clone()).collect(),
5065 idls: specs
5066 .iter()
5067 .map(|spec| spec.idl_snapshot.snapshot.clone())
5068 .collect(),
5069 program_specs: specs,
5070 entities: vec![],
5071 pdas: BTreeMap::new(),
5072 instructions: vec![],
5073 content_hash: None,
5074 }
5075 }
5076
5077 fn program_only_test_spec(
5078 pdas: BTreeMap<String, BTreeMap<String, PdaDefinition>>,
5079 instructions: Vec<InstructionDef>,
5080 ) -> SerializableStackSpec {
5081 let program_spec = demo_program_spec();
5082 SerializableStackSpec {
5083 ast_version: CURRENT_AST_VERSION.to_string(),
5084 stack_name: "DemoStream".to_string(),
5085 program_ids: vec!["Prog111".to_string()],
5086 idls: vec![program_spec.idl_snapshot.snapshot.clone()],
5087 program_specs: vec![program_spec],
5088 entities: vec![],
5089 pdas,
5090 instructions,
5091 content_hash: None,
5092 }
5093 }
5094
5095 fn test_instruction(amount_hint: Option<InstructionAmountHint>) -> InstructionDef {
5096 InstructionDef {
5097 name: "deposit".to_string(),
5098 discriminator: vec![9],
5099 discriminator_size: 1,
5100 accounts: vec![],
5101 args: amount_hint
5102 .map(|amount_hint| InstructionArgDef {
5103 name: "amount".to_string(),
5104 arg_type: "u64".to_string(),
5105 docs: vec![],
5106 amount_hint: Some(amount_hint),
5107 })
5108 .into_iter()
5109 .collect(),
5110 errors: vec![],
5111 program_id: Some("Prog111".to_string()),
5112 docs: vec![],
5113 }
5114 }
5115
5116 fn state_key_test_spec(primary_keys: Vec<&str>) -> SerializableStreamSpec {
5117 let round_id = FieldTypeInfo::new("round_id".to_string(), "u64".to_string());
5118 SerializableStreamSpec {
5119 ast_version: CURRENT_AST_VERSION.to_string(),
5120 state_name: "OreRound".to_string(),
5121 program_id: None,
5122 idl: None,
5123 identity: IdentitySpec {
5124 primary_keys: primary_keys.into_iter().map(str::to_string).collect(),
5125 lookup_indexes: vec![],
5126 },
5127 handlers: vec![],
5128 sections: vec![EntitySection {
5129 name: "id".to_string(),
5130 fields: vec![round_id.clone()],
5131 is_nested_struct: false,
5132 parent_field: None,
5133 }],
5134 field_mappings: BTreeMap::from([("id.round_id".to_string(), round_id)]),
5135 resolver_hooks: vec![],
5136 instruction_hooks: vec![],
5137 resolver_specs: vec![],
5138 computed_fields: vec![],
5139 computed_field_specs: vec![],
5140 content_hash: None,
5141 views: vec![],
5142 }
5143 }
5144
5145 fn endpoint_test_spec() -> SerializableStackSpec {
5146 SerializableStackSpec {
5147 ast_version: CURRENT_AST_VERSION.to_string(),
5148 stack_name: "EndpointStream".to_string(),
5149 program_ids: vec![],
5150 idls: vec![],
5151 program_specs: vec![],
5152 entities: vec![state_key_test_spec(vec!["id.round_id"])],
5153 pdas: BTreeMap::new(),
5154 instructions: vec![],
5155 content_hash: None,
5156 }
5157 }
5158
5159 #[test]
5160 fn test_case_conversions() {
5161 assert_eq!(to_pascal_case("settlement_game"), "SettlementGame");
5162 assert_eq!(to_kebab_case("SettlementGame"), "settlement-game");
5163 }
5164
5165 #[test]
5166 fn local_stack_codegen_is_endpointless_by_default() {
5167 let output = compile_stack_spec(endpoint_test_spec(), None)
5168 .expect("local stack generation should succeed");
5169
5170 assert!(output.stack_definition.contains(
5171 " endpoints: {\n ws: '', // TODO: Set after first deployment or pass useArete(..., { url })\n http: '', // TODO: Set after first deployment or pass useArete(..., { httpUrl })\n },"
5172 ));
5173 }
5174
5175 #[test]
5176 fn stack_codegen_emits_independent_endpoints_exactly() {
5177 let websocket_url = "wss://stream.example.test/ws/v2?tenant=endpoint";
5178 let http_url = "https://reads.unrelated.test/api/arete/v3";
5179 let output = compile_stack_spec(
5180 endpoint_test_spec(),
5181 Some(TypeScriptStackConfig {
5182 websocket_url: Some(websocket_url.to_string()),
5183 http_url: Some(http_url.to_string()),
5184 ..TypeScriptStackConfig::default()
5185 }),
5186 )
5187 .expect("configured stack generation should succeed");
5188
5189 assert!(output.stack_definition.contains(&format!(
5190 " endpoints: {{\n ws: '{}',\n http: '{}',\n }},",
5191 websocket_url, http_url
5192 )));
5193 assert!(!output
5194 .stack_definition
5195 .contains("https://stream.example.test/ws/v2"));
5196 }
5197
5198 #[test]
5199 fn explicit_local_websocket_does_not_derive_http() {
5200 let output = compile_stack_spec(
5201 endpoint_test_spec(),
5202 Some(TypeScriptStackConfig {
5203 websocket_url: Some("ws://127.0.0.1:8878/socket".to_string()),
5204 ..TypeScriptStackConfig::default()
5205 }),
5206 )
5207 .expect("configured local stack generation should succeed");
5208
5209 assert!(output
5210 .stack_definition
5211 .contains(" ws: 'ws://127.0.0.1:8878/socket',"));
5212 assert!(output.stack_definition.contains(
5213 " http: '', // TODO: Set after first deployment or pass useArete(..., { httpUrl })"
5214 ));
5215 assert!(!output
5216 .stack_definition
5217 .contains("http://127.0.0.1:8878/socket"));
5218 }
5219
5220 #[test]
5221 fn test_normalize_for_comparison() {
5222 assert_eq!(normalize_for_comparison("claim_sol"), "claimsol");
5223 assert_eq!(normalize_for_comparison("claimSol"), "claimsol");
5224 assert_eq!(normalize_for_comparison("ClaimSol"), "claimsol");
5225 assert_eq!(
5226 normalize_for_comparison("admin_set_creator"),
5227 "adminsetcreator"
5228 );
5229 assert_eq!(
5230 normalize_for_comparison("AdminSetCreator"),
5231 "adminsetcreator"
5232 );
5233 }
5234
5235 #[test]
5236 fn test_value_to_typescript_type() {
5237 assert_eq!(value_to_typescript_type(&serde_json::json!(42)), "number");
5238 assert_eq!(
5239 value_to_typescript_type(&serde_json::json!("hello")),
5240 "string"
5241 );
5242 assert_eq!(
5243 value_to_typescript_type(&serde_json::json!(true)),
5244 "boolean"
5245 );
5246 assert_eq!(value_to_typescript_type(&serde_json::json!([])), "any[]");
5247 }
5248
5249 #[test]
5250 fn test_typescript_scalar_array_element() {
5251 assert_eq!(
5252 typescript_scalar_array_element("Vec < f64 >"),
5253 Some("number")
5254 );
5255 assert_eq!(typescript_scalar_array_element("Vec<f32>"), Some("number"));
5256 assert_eq!(typescript_scalar_array_element("f64"), Some("number"));
5257 assert_eq!(
5258 typescript_scalar_array_element("Vec < bool >"),
5259 Some("boolean")
5260 );
5261 assert_eq!(
5262 typescript_scalar_array_element("Vec < String >"),
5263 Some("string")
5264 );
5265 assert_eq!(typescript_scalar_array_element("Vec < u64 >"), None);
5266 assert_eq!(typescript_scalar_array_element("Vec < Pubkey >"), None);
5267 }
5268
5269 #[test]
5270 fn state_view_codegen_emits_exact_key_type_and_deduped_runtime_metadata() {
5271 let output = compile_serializable_spec(
5272 state_key_test_spec(vec!["id.round_id", "id.round_id"]),
5273 "OreRound".to_string(),
5274 None,
5275 )
5276 .expect("duplicate identical keys should compile");
5277
5278 assert!(output.stack_definition.contains(
5279 "export interface ViewDef<T, TMode extends 'state' | 'list', TKey = unknown>"
5280 ));
5281 assert!(output.stack_definition.contains(
5282 "state: stateView<OreRound, { roundId: bigint }>('OreRound/state', ['roundId'])"
5283 ));
5284 assert_eq!(output.stack_definition.matches("['roundId']").count(), 1);
5285 }
5286
5287 #[test]
5288 fn state_view_codegen_rejects_distinct_composite_keys() {
5289 let mut spec = state_key_test_spec(vec!["id.round_id", "id.authority"]);
5290 spec.field_mappings.insert(
5291 "id.authority".to_string(),
5292 FieldTypeInfo::new("authority".to_string(), "String".to_string()),
5293 );
5294
5295 let error = compile_serializable_spec(spec, "OreRound".to_string(), None)
5296 .expect_err("distinct composite keys must fail generation");
5297
5298 assert!(error.contains("does not support composite state keys"));
5299 assert!(error.contains("id.round_id, id.authority"));
5300 }
5301
5302 #[test]
5303 fn pda_imports_follow_emitted_seed_variants() {
5304 let empty_seed_pdas = BTreeMap::from([(
5305 "demo".to_string(),
5306 BTreeMap::from([(
5307 "singleton".to_string(),
5308 PdaDefinition {
5309 name: "singleton".to_string(),
5310 seeds: vec![],
5311 program_id: None,
5312 },
5313 )]),
5314 )]);
5315 let output = compile_program_modules(program_only_test_spec(empty_seed_pdas, vec![]), None)
5316 .expect("empty-seed PDA generation should succeed");
5317 assert_eq!(
5318 output.imports,
5319 "import { z } from 'zod';\nimport { pda } from '@usearete/sdk';"
5320 );
5321 assert!(output
5322 .stack_definition
5323 .contains("singleton: pda('Prog111'),"));
5324 assert!(!output.stack_definition.contains("pda('Prog111', )"));
5325
5326 let literal_account_pdas = BTreeMap::from([(
5327 "demo".to_string(),
5328 BTreeMap::from([(
5329 "vault".to_string(),
5330 PdaDefinition {
5331 name: "vault".to_string(),
5332 seeds: vec![
5333 PdaSeedDef::Literal {
5334 value: "vault".to_string(),
5335 },
5336 PdaSeedDef::AccountRef {
5337 account_name: "authority".to_string(),
5338 },
5339 ],
5340 program_id: None,
5341 },
5342 )]),
5343 )]);
5344 let output =
5345 compile_program_modules(program_only_test_spec(literal_account_pdas, vec![]), None)
5346 .expect("literal/account PDA generation should succeed");
5347 assert_eq!(
5348 output.imports,
5349 "import { z } from 'zod';\nimport { pda, literal, account } from '@usearete/sdk';"
5350 );
5351
5352 let arg_bytes_pdas = BTreeMap::from([(
5353 "demo".to_string(),
5354 BTreeMap::from([(
5355 "position".to_string(),
5356 PdaDefinition {
5357 name: "position".to_string(),
5358 seeds: vec![
5359 PdaSeedDef::ArgRef {
5360 arg_name: "roundId".to_string(),
5361 arg_type: Some("u64".to_string()),
5362 },
5363 PdaSeedDef::Bytes {
5364 value: vec![0, 255],
5365 },
5366 ],
5367 program_id: None,
5368 },
5369 )]),
5370 )]);
5371 let output = compile_program_modules(program_only_test_spec(arg_bytes_pdas, vec![]), None)
5372 .expect("arg/bytes PDA generation should succeed");
5373 assert_eq!(
5374 output.imports,
5375 "import { z } from 'zod';\nimport { pda, arg, bytes } from '@usearete/sdk';"
5376 );
5377 }
5378
5379 #[test]
5380 fn program_modules_emit_configured_sdk_definition_hash() {
5381 let stack_spec = program_only_test_spec(BTreeMap::new(), vec![]);
5382 let mut program = TypeScriptProgramConfig::from(
5383 &arete_hash::OssProgramIdentityV1::new(stack_spec.program_specs[0].clone()).unwrap(),
5384 );
5385 program.definition.sdk_definition_hash = Some("definition-v1".to_string());
5386 let output = compile_program_modules(
5387 stack_spec,
5388 Some(TypeScriptStackConfig {
5389 programs: Some(vec![program]),
5390 ..TypeScriptStackConfig::default()
5391 }),
5392 )
5393 .expect("program definition generation should succeed");
5394
5395 assert!(output
5396 .stack_definition
5397 .contains("sdkDefinitionHash: 'definition-v1',"));
5398 }
5399
5400 #[test]
5401 fn program_modules_separate_portable_definition_from_release_reference() {
5402 let mut stack_spec = program_only_test_spec(BTreeMap::new(), vec![]);
5403 let mut local = TypeScriptProgramConfig::from(
5404 &arete_hash::OssProgramIdentityV1::new(stack_spec.program_specs[0].clone()).unwrap(),
5405 );
5406 local.definition.sdk_definition_hash = Some("portable-definition".to_string());
5407 let hosted_release = "arete:h1:program-release:sha256:hosted";
5408 let output = compile_program_modules(
5409 stack_spec.clone(),
5410 Some(TypeScriptStackConfig {
5411 programs: Some(vec![TypeScriptProgramConfig {
5412 release: TypeScriptProgramReleaseReference {
5413 program_release_hash: hosted_release.to_string(),
5414 program_spec_hash: local.definition.program_spec_hash.clone(),
5415 },
5416 ..local.clone()
5417 }]),
5418 ..TypeScriptStackConfig::default()
5419 }),
5420 )
5421 .expect("program definition generation should succeed");
5422 let definition = output.stack_definition;
5423
5424 assert!(definition.contains("sdkDefinitionHash: 'portable-definition',"));
5425 assert!(definition.contains(&format!(
5426 "programSpecHash: '{}',",
5427 local.definition.program_spec_hash
5428 )));
5429 let programs = definition
5430 .split("/** Release and explicit read transport")
5431 .next()
5432 .unwrap();
5433 assert!(!programs.contains("programReleaseHash"));
5434 assert!(!programs.contains("decoderEngineId"));
5435 assert!(definition.contains(&format!("programReleaseHash: \"{hosted_release}\"")));
5436 assert!(!definition.contains("decoderEngineId"));
5437 stack_spec.program_specs.clear();
5438 assert!(compile_program_modules(stack_spec, None)
5439 .unwrap_err()
5440 .contains("Regenerate the .stack.json"));
5441 }
5442
5443 #[test]
5444 fn program_account_codegen_is_semantic_and_release_lives_in_program_reads() {
5445 let identity = crate::program_sdk::build_oss_program_identity_v1_from_idl_bytes(
5446 include_bytes!("../../arete-macros/tests/fixtures/nested-computed.idl.json"),
5447 None,
5448 )
5449 .expect("fixture identity");
5450 let stack_spec =
5451 crate::program_sdk::build_program_only_stack_spec_from_identity(&identity, "Presale");
5452 let output = compile_program_modules(stack_spec, None).expect("program SDK generation");
5453
5454 assert!(output.stack_definition.contains(
5455 "programAccountRead<Presale>({ account: 'Presale', schema: PresaleSchema })"
5456 ));
5457 assert!(!output.stack_definition.contains("path:"));
5458 assert!(output.stack_definition.contains(&format!(
5459 "programReleaseHash: \"{}\"",
5460 identity.release_hash
5461 )));
5462 assert!(output
5463 .stack_definition
5464 .contains("transport: { kind: 'local-http', endpointSource: 'connect-http-url' }"));
5465 assert!(!output.stack_definition.contains("path:"));
5466 }
5467
5468 #[test]
5469 fn legacy_account_reader_ast_without_exact_program_specs_fails_closed() {
5470 let identity = crate::program_sdk::build_oss_program_identity_v1_from_idl_bytes(
5471 include_bytes!("../../arete-macros/tests/fixtures/nested-computed.idl.json"),
5472 None,
5473 )
5474 .expect("fixture identity");
5475 let mut stack_spec =
5476 crate::program_sdk::build_program_only_stack_spec_from_identity(&identity, "Presale");
5477 assert!(!stack_spec.idls[0].accounts.is_empty());
5478 stack_spec.program_specs.clear();
5479
5480 let error = compile_program_modules(stack_spec, None).unwrap_err();
5481 assert!(error.contains("no exact public ProgramSpecV1 values"));
5482 assert!(error.contains("Regenerate the .stack.json"));
5483 }
5484
5485 #[test]
5486 fn hosted_program_configs_preserve_order_releases_endpoints_and_auth() {
5487 let stack_spec = two_program_test_spec();
5488 let programs = stack_spec
5489 .program_specs
5490 .iter()
5491 .enumerate()
5492 .map(|(index, spec)| {
5493 let identity = arete_hash::OssProgramIdentityV1::new(spec.clone()).unwrap();
5494 let mut config = TypeScriptProgramConfig::from(&identity);
5495 config.release.program_release_hash = format!("hosted-release-{index}");
5496 let binding_id = format!("prb_{index:032}");
5497 config.transport =
5498 TypeScriptProgramReadTransport::HostedBinding(TypeScriptProgramReadBinding {
5499 endpoint: format!("https://reads.example.test/exact/{index}/"),
5500 program_read_binding_id: binding_id.clone(),
5501 auth: serde_json::json!({
5502 "targetKind": "program-read-binding",
5503 "targetId": binding_id,
5504 "sessionEndpoint": format!("https://auth.example.test/{index}"),
5505 "index": index
5506 }),
5507 });
5508 config
5509 })
5510 .collect::<Vec<_>>();
5511
5512 let output = compile_stack_spec(
5513 stack_spec,
5514 Some(TypeScriptStackConfig {
5515 programs: Some(programs),
5516 ..Default::default()
5517 }),
5518 )
5519 .expect("ordered hosted descriptors should compile");
5520 let generated = output.stack_definition;
5521 let portable = generated
5522 .split(" programs: {")
5523 .nth(1)
5524 .expect("portable programs block")
5525 .split(" programReads: {")
5526 .next()
5527 .expect("portable programs block end");
5528 let reads = generated
5529 .split(" programReads: {")
5530 .nth(1)
5531 .expect("parallel program reads block");
5532
5533 assert!(portable.find("secondProgram:").unwrap() < portable.find("firstProgram:").unwrap());
5534 assert!(!portable.contains("programReleaseHash"));
5535 assert!(!portable.contains("endpoint"));
5536 assert!(reads.find("secondProgram:").unwrap() < reads.find("firstProgram:").unwrap());
5537 assert!(reads.contains("programReleaseHash: \"hosted-release-0\""));
5538 assert!(reads.contains("programReleaseHash: \"hosted-release-1\""));
5539 assert!(reads.contains("kind: 'hosted-binding'"));
5540 assert!(reads.contains("endpoint: \"https://reads.example.test/exact/0/\""));
5541 assert!(reads.contains("programReadBindingId: \"prb_00000000000000000000000000000001\""));
5542 assert!(reads.contains(
5543 "auth: {\"index\":0,\"sessionEndpoint\":\"https://auth.example.test/0\",\"targetId\":\"prb_00000000000000000000000000000000\",\"targetKind\":\"program-read-binding\"}"
5544 ));
5545 }
5546
5547 #[test]
5548 fn hosted_program_config_mismatches_fail_by_index_without_name_fallback() {
5549 let stack_spec = two_program_test_spec();
5550 let local = stack_spec
5551 .program_specs
5552 .iter()
5553 .map(|spec| {
5554 TypeScriptProgramConfig::from(
5555 &arete_hash::OssProgramIdentityV1::new(spec.clone()).unwrap(),
5556 )
5557 })
5558 .collect::<Vec<_>>();
5559
5560 let count_error = compile_program_modules(
5561 stack_spec.clone(),
5562 Some(TypeScriptStackConfig {
5563 programs: Some(vec![local[0].clone()]),
5564 ..Default::default()
5565 }),
5566 )
5567 .unwrap_err();
5568 assert!(count_error.contains("descriptor count mismatch"));
5569
5570 let mut swapped = local.clone();
5571 swapped.swap(0, 1);
5572 let order_error = compile_program_modules(
5573 stack_spec.clone(),
5574 Some(TypeScriptStackConfig {
5575 programs: Some(swapped),
5576 ..Default::default()
5577 }),
5578 )
5579 .unwrap_err();
5580 assert!(order_error.contains("program ID mismatch at index 0"));
5581
5582 let mut bad_hash = local;
5583 bad_hash[1].definition.program_spec_hash = "wrong-spec".to_string();
5584 let hash_error = compile_program_modules(
5585 stack_spec,
5586 Some(TypeScriptStackConfig {
5587 programs: Some(bad_hash),
5588 ..Default::default()
5589 }),
5590 )
5591 .unwrap_err();
5592 assert!(hash_error.contains("programSpecHash mismatch at index 1"));
5593 }
5594
5595 #[test]
5596 fn raw_operations_omit_semantic_only_types_and_context() {
5597 let output = compile_program_modules(
5598 program_only_test_spec(BTreeMap::new(), vec![test_instruction(None)]),
5599 None,
5600 )
5601 .expect("raw operation generation should succeed");
5602 let file = output.full_file();
5603
5604 assert!(!output.imports.contains("BuildOptions"));
5605 assert!(!output.imports.contains("ProgramOperationContext"));
5606 assert!(file.contains("createOperations()"));
5607 assert!(!file.contains("build?: BuildOptions;"));
5608 }
5609
5610 #[test]
5611 fn context_free_amount_operations_import_build_options_only() {
5612 let output = compile_program_modules(
5613 program_only_test_spec(
5614 BTreeMap::new(),
5615 vec![test_instruction(Some(InstructionAmountHint {
5616 decimals_source: AmountDecimalsSource::Constant { decimals: 9 },
5617 }))],
5618 ),
5619 None,
5620 )
5621 .expect("constant amount operation generation should succeed");
5622 let file = output.full_file();
5623
5624 assert!(output.imports.contains("type BuildOptions"));
5625 assert!(output.imports.contains("toRawAmount"));
5626 assert!(!output.imports.contains("ProgramOperationContext"));
5627 assert!(file.contains("build?: BuildOptions;"));
5628 assert!(file.contains("createOperations()"));
5629 assert!(!file.contains("context.chain"));
5630 }
5631
5632 #[test]
5633 fn streamed_entity_codegen_normalizes_canonical_names_and_schemas() {
5634 let spec = SerializableStreamSpec {
5635 ast_version: CURRENT_AST_VERSION.to_string(),
5636 state_name: "TokenPosition".to_string(),
5637 program_id: None,
5638 idl: None,
5639 identity: IdentitySpec {
5640 primary_keys: vec!["id.address".to_string()],
5641 lookup_indexes: vec![],
5642 },
5643 handlers: vec![],
5644 sections: vec![
5645 EntitySection {
5646 name: "root".to_string(),
5647 fields: vec![FieldTypeInfo::new(
5648 "total_deposit".to_string(),
5649 "u64".to_string(),
5650 )],
5651 is_nested_struct: false,
5652 parent_field: None,
5653 },
5654 EntitySection {
5655 name: "metrics".to_string(),
5656 fields: vec![FieldTypeInfo::new(
5657 "last_updated_at".to_string(),
5658 "i64".to_string(),
5659 )],
5660 is_nested_struct: false,
5661 parent_field: None,
5662 },
5663 ],
5664 field_mappings: BTreeMap::new(),
5665 resolver_hooks: vec![],
5666 resolver_specs: vec![],
5667 instruction_hooks: vec![],
5668 computed_fields: vec![],
5669 computed_field_specs: vec![],
5670 content_hash: None,
5671 views: vec![],
5672 };
5673
5674 let output = compile_serializable_spec(spec, "TokenPosition".to_string(), None)
5675 .expect("should compile");
5676 let file = output.full_file();
5677
5678 assert!(
5679 file.contains("export interface TokenPosition {"),
5680 "missing main interface:\n{}",
5681 file
5682 );
5683 assert!(
5684 file.contains("totalDeposit: bigint;"),
5685 "missing root canonical field:\n{}",
5686 file
5687 );
5688 assert!(
5689 file.contains("metrics: TokenPositionMetrics;"),
5690 "missing nested canonical section field:\n{}",
5691 file
5692 );
5693 assert!(
5694 file.contains("export interface TokenPositionMetrics {"),
5695 "missing nested interface:\n{}",
5696 file
5697 );
5698 assert!(
5699 file.contains("lastUpdatedAt: bigint;"),
5700 "missing nested canonical field:\n{}",
5701 file
5702 );
5703
5704 let bigint_schema = bigint_zod();
5705 assert!(
5706 file.contains("export const TokenPositionSchema = z.object({"),
5707 "missing main schema:\n{}",
5708 file
5709 );
5710 assert!(
5711 file.contains(&format!("total_deposit: {},", bigint_schema)),
5712 "missing raw root schema field:\n{}",
5713 file
5714 );
5715 assert!(
5716 file.contains("metrics: TokenPositionMetricsSchema,"),
5717 "missing nested schema ref:\n{}",
5718 file
5719 );
5720 assert!(
5721 file.contains("totalDeposit: value.total_deposit,"),
5722 "missing root transform:\n{}",
5723 file
5724 );
5725 assert!(
5726 file.contains("metrics: value.metrics,"),
5727 "missing nested transform:\n{}",
5728 file
5729 );
5730
5731 assert!(
5732 file.contains("export const TokenPositionMetricsSchema = z.object({"),
5733 "missing nested schema:\n{}",
5734 file
5735 );
5736 assert!(
5737 file.contains(&format!("last_updated_at: {},", bigint_schema)),
5738 "missing raw nested schema field:\n{}",
5739 file
5740 );
5741 assert!(
5742 file.contains("lastUpdatedAt: value.last_updated_at,"),
5743 "missing nested transform:\n{}",
5744 file
5745 );
5746
5747 assert!(
5748 file.contains("export const TokenPositionPatchSchema = z.object({"),
5749 "missing patch schema:\n{}",
5750 file
5751 );
5752 assert!(
5753 file.contains(&format!("total_deposit: {}.optional(),", bigint_schema)),
5754 "missing sparse patch field:\n{}",
5755 file
5756 );
5757 assert!(
5758 file.contains("metrics: TokenPositionMetricsPatchSchema.optional(),"),
5759 "missing nested patch schema ref:\n{}",
5760 file
5761 );
5762 assert!(
5763 file.contains("...(value.total_deposit !== undefined ? { totalDeposit: value.total_deposit } : {}),"),
5764 "missing sparse patch transform:\n{}",
5765 file
5766 );
5767 assert!(
5768 file.contains("export const TokenPositionMetricsPatchSchema = z.object({"),
5769 "missing nested patch schema:\n{}",
5770 file
5771 );
5772 assert!(
5773 file.contains("patchSchemas: {\n TokenPosition: TokenPositionPatchSchema,"),
5774 "missing stack patch schema map:\n{}",
5775 file
5776 );
5777 }
5778
5779 #[test]
5780 fn captured_accounts_keep_the_runtime_envelope_and_full_inner_schema() {
5781 let miner_snapshot = FieldTypeInfo {
5782 field_name: "miner_snapshot".to_string(),
5783 raw_name: Some("miner_snapshot".to_string()),
5784 canonical_name: Some("minerSnapshot".to_string()),
5785 rust_type_name: "Option<Miner>".to_string(),
5786 base_type: BaseType::Object,
5787 integer_kind: None,
5788 is_optional: true,
5789 is_array: false,
5790 inner_type: Some("Miner".to_string()),
5791 source_path: None,
5792 resolved_type: Some(ResolvedStructType {
5793 type_name: "Miner".to_string(),
5794 fields: vec![
5795 ResolvedField {
5796 field_name: "deployed".to_string(),
5797 raw_name: Some("deployed".to_string()),
5798 canonical_name: Some("deployed".to_string()),
5799 field_type: "u64".to_string(),
5800 base_type: BaseType::Integer,
5801 integer_kind: Some(IntegerKind::U64),
5802 is_optional: false,
5803 is_array: true,
5804 },
5805 ResolvedField {
5806 field_name: "round_id".to_string(),
5807 raw_name: Some("round_id".to_string()),
5808 canonical_name: Some("roundId".to_string()),
5809 field_type: "u64".to_string(),
5810 base_type: BaseType::Integer,
5811 integer_kind: Some(IntegerKind::U64),
5812 is_optional: false,
5813 is_array: false,
5814 },
5815 ],
5816 is_instruction: false,
5817 is_account: true,
5818 is_event: false,
5819 is_enum: false,
5820 enum_variants: vec![],
5821 }),
5822 emit: true,
5823 };
5824 let spec = SerializableStreamSpec {
5825 ast_version: CURRENT_AST_VERSION.to_string(),
5826 state_name: "OreMiner".to_string(),
5827 program_id: None,
5828 idl: None,
5829 identity: IdentitySpec {
5830 primary_keys: vec!["id.authority".to_string()],
5831 lookup_indexes: vec![],
5832 },
5833 handlers: vec![SerializableHandlerSpec {
5834 source: SourceSpec::Source {
5835 program_id: None,
5836 discriminator: None,
5837 type_name: "Miner".to_string(),
5838 serialization: None,
5839 is_account: true,
5840 },
5841 key_resolution: KeyResolutionStrategy::Embedded {
5842 primary_field: FieldPath::new(&["authority"]),
5843 },
5844 mappings: vec![SerializableFieldMapping {
5845 target_path: "miner_snapshot".to_string(),
5846 source: MappingSource::AsCapture {
5847 field_transforms: BTreeMap::new(),
5848 },
5849 transform: None,
5850 population: PopulationStrategy::LastWrite,
5851 condition: None,
5852 when: None,
5853 stop: None,
5854 emit: true,
5855 }],
5856 conditions: vec![],
5857 emit: true,
5858 }],
5859 sections: vec![
5860 EntitySection {
5861 name: "id".to_string(),
5862 fields: vec![FieldTypeInfo::new(
5863 "authority".to_string(),
5864 "String".to_string(),
5865 )],
5866 is_nested_struct: false,
5867 parent_field: None,
5868 },
5869 EntitySection {
5870 name: "root".to_string(),
5871 fields: vec![miner_snapshot],
5872 is_nested_struct: false,
5873 parent_field: None,
5874 },
5875 ],
5876 field_mappings: BTreeMap::new(),
5877 resolver_hooks: vec![],
5878 resolver_specs: vec![],
5879 instruction_hooks: vec![],
5880 computed_fields: vec![],
5881 computed_field_specs: vec![],
5882 content_hash: None,
5883 views: vec![],
5884 };
5885
5886 let output = compile_serializable_spec(spec, "OreMiner".to_string(), None)
5887 .expect("captured account generation should succeed");
5888 let file = output.full_file();
5889
5890 assert!(file.contains("minerSnapshot: CaptureWrapper<Miner> | null;"));
5891 assert!(file.contains("export interface CaptureWrapper<T> {"));
5892 assert!(file.contains("accountAddress: string;"));
5893 assert!(file.contains("account_address: z.string(),"));
5894 assert!(file.contains("accountAddress: value.account_address,"));
5895 assert!(file
5896 .contains("miner_snapshot: CaptureWrapperSchema(MinerSchema).nullable().optional(),"));
5897 assert!(file
5898 .contains("miner_snapshot: CaptureWrapperSchema(MinerSchema).nullable().optional(),"));
5899 assert!(!file.contains("CaptureWrapperSchema(MinerPatchSchema)"));
5900 }
5901
5902 #[test]
5903 fn streamed_builtin_token_metadata_codegen_is_canonical_and_sparse() {
5904 let spec = SerializableStreamSpec {
5905 ast_version: CURRENT_AST_VERSION.to_string(),
5906 state_name: "TokenHolder".to_string(),
5907 program_id: None,
5908 idl: None,
5909 identity: IdentitySpec {
5910 primary_keys: vec!["id.address".to_string()],
5911 lookup_indexes: vec![],
5912 },
5913 handlers: vec![],
5914 sections: vec![EntitySection {
5915 name: "root".to_string(),
5916 fields: vec![FieldTypeInfo {
5917 field_name: "base_token_metadata".to_string(),
5918 raw_name: Some("base_token_metadata".to_string()),
5919 canonical_name: Some("baseTokenMetadata".to_string()),
5920 rust_type_name: "Option<TokenMetadata>".to_string(),
5921 base_type: BaseType::Object,
5922 integer_kind: None,
5923 is_optional: true,
5924 is_array: false,
5925 inner_type: Some("TokenMetadata".to_string()),
5926 source_path: None,
5927 resolved_type: None,
5928 emit: true,
5929 }],
5930 is_nested_struct: false,
5931 parent_field: None,
5932 }],
5933 field_mappings: BTreeMap::new(),
5934 resolver_hooks: vec![],
5935 resolver_specs: vec![],
5936 instruction_hooks: vec![],
5937 computed_fields: vec![],
5938 computed_field_specs: vec![],
5939 content_hash: None,
5940 views: vec![],
5941 };
5942
5943 let output = compile_serializable_spec(spec, "TokenHolder".to_string(), None)
5944 .expect("should compile");
5945 let file = output.full_file();
5946
5947 assert!(
5948 file.contains("export interface TokenMetadata {"),
5949 "missing builtin interface:\n{}",
5950 file
5951 );
5952 assert!(
5953 file.contains("logoUri?: string | null;"),
5954 "missing canonical builtin field:\n{}",
5955 file
5956 );
5957 assert!(
5958 file.contains("export const TokenMetadataSchema = z.object({"),
5959 "missing builtin schema:\n{}",
5960 file
5961 );
5962 assert!(
5963 file.contains("logo_uri: z.string().nullable().optional(),"),
5964 "missing raw builtin input field:\n{}",
5965 file
5966 );
5967 assert!(
5968 file.contains("...(value.logo_uri !== undefined ? { logoUri: value.logo_uri } : {}),"),
5969 "missing canonical builtin transform:\n{}",
5970 file
5971 );
5972 assert!(
5973 file.contains("export const TokenMetadataPatchSchema = z.object({"),
5974 "missing builtin patch schema:\n{}",
5975 file
5976 );
5977 assert!(
5978 file.contains("base_token_metadata: TokenMetadataPatchSchema.nullable().optional(),"),
5979 "missing patch schema usage for builtin field:\n{}",
5980 file
5981 );
5982 }
5983
5984 #[test]
5985 fn streamed_section_codegen_localizes_prefixed_raw_field_names() {
5986 let spec = SerializableStreamSpec {
5987 ast_version: CURRENT_AST_VERSION.to_string(),
5988 state_name: "OreRound".to_string(),
5989 program_id: None,
5990 idl: None,
5991 identity: IdentitySpec {
5992 primary_keys: vec!["id.round_id".to_string()],
5993 lookup_indexes: vec![],
5994 },
5995 handlers: vec![],
5996 sections: vec![EntitySection {
5997 name: "results".to_string(),
5998 fields: vec![FieldTypeInfo {
5999 field_name: "results.expires_at_slot_hash".to_string(),
6000 raw_name: Some("results.expires_at_slot_hash".to_string()),
6001 canonical_name: Some("resultsExpiresAtSlotHash".to_string()),
6002 rust_type_name: "Option<String>".to_string(),
6003 base_type: BaseType::String,
6004 integer_kind: None,
6005 is_optional: true,
6006 is_array: false,
6007 inner_type: Some("String".to_string()),
6008 source_path: None,
6009 resolved_type: None,
6010 emit: true,
6011 }],
6012 is_nested_struct: false,
6013 parent_field: None,
6014 }],
6015 field_mappings: BTreeMap::new(),
6016 resolver_hooks: vec![],
6017 resolver_specs: vec![],
6018 instruction_hooks: vec![],
6019 computed_fields: vec![],
6020 computed_field_specs: vec![],
6021 content_hash: None,
6022 views: vec![],
6023 };
6024
6025 let output =
6026 compile_serializable_spec(spec, "OreRound".to_string(), None).expect("should compile");
6027 let file = output.full_file();
6028
6029 assert!(
6030 file.contains("export interface OreRoundResults {"),
6031 "missing section interface:\n{}",
6032 file
6033 );
6034 assert!(
6035 file.contains("expiresAtSlotHash: string | null;"),
6036 "missing localized canonical field:\n{}",
6037 file
6038 );
6039 assert!(
6040 file.contains("expires_at_slot_hash: z.string().nullable().optional(),"),
6041 "missing localized canonical schema field:\n{}",
6042 file
6043 );
6044 assert!(
6045 file.contains("expiresAtSlotHash: value.expires_at_slot_hash,"),
6046 "missing localized transform:\n{}",
6047 file
6048 );
6049 assert!(
6050 file.contains("expires_at_slot_hash: z.string().nullable().optional(),"),
6051 "missing localized patch schema field:\n{}",
6052 file
6053 );
6054 assert!(
6055 file.contains("...(value.expires_at_slot_hash !== undefined ? { expiresAtSlotHash: value.expires_at_slot_hash } : {}),"),
6056 "missing localized sparse transform:\n{}",
6057 file
6058 );
6059 }
6060
6061 #[test]
6062 fn test_streamed_completed_schema_allows_unwritten_nullable_fields() {
6063 let mut optional_count = FieldTypeInfo::new("count".to_string(), "u64".to_string());
6064 optional_count.is_optional = true;
6065 let spec = SerializableStreamSpec {
6066 ast_version: CURRENT_AST_VERSION.to_string(),
6067 state_name: "OreRound".to_string(),
6068 program_id: None,
6069 idl: None,
6070 identity: IdentitySpec {
6071 primary_keys: vec!["id.round_id".to_string()],
6072 lookup_indexes: vec![],
6073 },
6074 handlers: vec![],
6075 sections: vec![EntitySection {
6076 name: "state".to_string(),
6077 fields: vec![optional_count],
6078 is_nested_struct: false,
6079 parent_field: None,
6080 }],
6081 field_mappings: BTreeMap::new(),
6082 resolver_hooks: vec![],
6083 resolver_specs: vec![],
6084 instruction_hooks: vec![],
6085 computed_fields: vec![],
6086 computed_field_specs: vec![],
6087 content_hash: None,
6088 views: vec![],
6089 };
6090
6091 let output =
6092 compile_serializable_spec(spec, "OreRound".to_string(), None).expect("should compile");
6093 let file = output.full_file();
6094 assert!(
6095 file.contains("count: z.union([z.bigint(), z.string(), z.number().int()]).transform((value) => BigInt(value)).nullable().optional(),"),
6096 "completed schema should allow absent nullable fields:\n{}",
6097 file
6098 );
6099 }
6100
6101 #[test]
6102 fn test_derived_view_codegen() {
6103 let spec = SerializableStreamSpec {
6104 ast_version: CURRENT_AST_VERSION.to_string(),
6105 state_name: "OreRound".to_string(),
6106 program_id: None,
6107 idl: None,
6108 identity: IdentitySpec {
6109 primary_keys: vec!["id".to_string()],
6110 lookup_indexes: vec![],
6111 },
6112 handlers: vec![],
6113 sections: vec![],
6114 field_mappings: BTreeMap::new(),
6115 resolver_hooks: vec![],
6116 resolver_specs: vec![],
6117 instruction_hooks: vec![],
6118 computed_fields: vec![],
6119 computed_field_specs: vec![],
6120 content_hash: None,
6121 views: vec![
6122 ViewDef {
6123 id: "OreRound/latest".to_string(),
6124 source: ViewSource::Entity {
6125 name: "OreRound".to_string(),
6126 },
6127 pipeline: vec![ViewTransform::Last],
6128 output: ViewOutput::Single,
6129 },
6130 ViewDef {
6131 id: "OreRound/top10".to_string(),
6132 source: ViewSource::Entity {
6133 name: "OreRound".to_string(),
6134 },
6135 pipeline: vec![ViewTransform::Take { count: 10 }],
6136 output: ViewOutput::Collection,
6137 },
6138 ],
6139 };
6140
6141 let output =
6142 compile_serializable_spec(spec, "OreRound".to_string(), None).expect("should compile");
6143
6144 let stack_def = &output.stack_definition;
6145
6146 assert!(
6147 stack_def.contains("listView<OreRound>('OreRound/latest')"),
6148 "Expected 'latest' derived view using listView, got:\n{}",
6149 stack_def
6150 );
6151 assert!(
6152 stack_def.contains("listView<OreRound>('OreRound/top10')"),
6153 "Expected 'top10' derived view using listView, got:\n{}",
6154 stack_def
6155 );
6156 assert!(
6157 stack_def.contains("latest:"),
6158 "Expected 'latest' key, got:\n{}",
6159 stack_def
6160 );
6161 assert!(
6162 stack_def.contains("top10:"),
6163 "Expected 'top10' key, got:\n{}",
6164 stack_def
6165 );
6166 assert!(
6167 stack_def.contains("function listView<T>(view: string): ViewDef<T, 'list'>"),
6168 "Expected listView helper function, got:\n{}",
6169 stack_def
6170 );
6171 }
6172
6173 #[test]
6174 fn test_account_type_collision_uses_account_suffix() {
6175 let plan_field = FieldTypeInfo {
6176 field_name: "plan".to_string(),
6177 raw_name: Some("plan".to_string()),
6178 canonical_name: Some("plan".to_string()),
6179 rust_type_name: "Option<serde_json::Value>".to_string(),
6180 base_type: BaseType::Object,
6181 integer_kind: None,
6182 is_optional: false,
6183 is_array: false,
6184 inner_type: Some("Value".to_string()),
6185 source_path: None,
6186 resolved_type: Some(ResolvedStructType {
6187 type_name: "plan".to_string(),
6188 fields: vec![],
6189 is_instruction: false,
6190 is_account: true,
6191 is_event: false,
6192 is_enum: false,
6193 enum_variants: vec![],
6194 }),
6195 emit: true,
6196 };
6197
6198 let spec = SerializableStreamSpec {
6199 ast_version: CURRENT_AST_VERSION.to_string(),
6200 state_name: "Plan".to_string(),
6201 program_id: None,
6202 idl: None,
6203 identity: IdentitySpec {
6204 primary_keys: vec!["id.address".to_string()],
6205 lookup_indexes: vec![],
6206 },
6207 handlers: vec![],
6208 sections: vec![
6209 EntitySection {
6210 name: "id".to_string(),
6211 fields: vec![FieldTypeInfo::new(
6212 "address".to_string(),
6213 "String".to_string(),
6214 )],
6215 is_nested_struct: false,
6216 parent_field: None,
6217 },
6218 EntitySection {
6219 name: "plan".to_string(),
6220 fields: vec![plan_field],
6221 is_nested_struct: false,
6222 parent_field: None,
6223 },
6224 ],
6225 field_mappings: BTreeMap::new(),
6226 resolver_hooks: vec![],
6227 instruction_hooks: vec![],
6228 resolver_specs: vec![],
6229 computed_fields: vec![],
6230 computed_field_specs: vec![],
6231 content_hash: None,
6232 views: vec![],
6233 };
6234
6235 let output = compile_serializable_spec(spec, "Plan".to_string(), None)
6236 .expect("typescript sdk generation should succeed");
6237
6238 assert!(
6239 output.interfaces.contains("export interface PlanPlan {"),
6240 "expected PlanPlan section interface, got:\n{}",
6241 output.interfaces
6242 );
6243 assert!(
6244 output.interfaces.contains("plan: PlanAccount;"),
6245 "expected PlanAccount field reference, got:\n{}",
6246 output.interfaces
6247 );
6248 assert!(
6249 output.interfaces.contains("export interface PlanAccount {"),
6250 "expected PlanAccount interface, got:\n{}",
6251 output.interfaces
6252 );
6253 }
6254
6255 #[test]
6256 fn test_multi_entity_enum_dedup_uses_pascal_case_name_matching() {
6257 let shared_idl = serde_json::json!({
6258 "name": "subscriptions",
6259 "version": "0.1.0",
6260 "accounts": [],
6261 "instructions": [],
6262 "types": [
6263 {
6264 "name": "planStatus",
6265 "type": {
6266 "kind": "enum",
6267 "variants": [{ "name": "sunset" }, { "name": "active" }]
6268 }
6269 }
6270 ],
6271 "events": [],
6272 "errors": [],
6273 "discriminant_size": 8
6274 });
6275
6276 let idl_snapshot: IdlSnapshot =
6277 serde_json::from_value(shared_idl).expect("idl snapshot should deserialize");
6278
6279 let make_entity = |name: &str| SerializableStreamSpec {
6280 ast_version: CURRENT_AST_VERSION.to_string(),
6281 state_name: name.to_string(),
6282 program_id: None,
6283 idl: None,
6284 identity: IdentitySpec {
6285 primary_keys: vec!["id.address".to_string()],
6286 lookup_indexes: vec![],
6287 },
6288 handlers: vec![],
6289 sections: vec![EntitySection {
6290 name: "id".to_string(),
6291 fields: vec![FieldTypeInfo::new(
6292 "address".to_string(),
6293 "String".to_string(),
6294 )],
6295 is_nested_struct: false,
6296 parent_field: None,
6297 }],
6298 field_mappings: BTreeMap::new(),
6299 resolver_hooks: vec![],
6300 instruction_hooks: vec![],
6301 resolver_specs: vec![],
6302 computed_fields: vec![],
6303 computed_field_specs: vec![],
6304 content_hash: None,
6305 views: vec![],
6306 };
6307
6308 let stack_spec = SerializableStackSpec {
6309 ast_version: CURRENT_AST_VERSION.to_string(),
6310 stack_name: "Subscriptions".to_string(),
6311 program_ids: vec![],
6312 idls: vec![idl_snapshot],
6313 program_specs: vec![],
6314 entities: vec![make_entity("Plan"), make_entity("Subscription")],
6315 pdas: BTreeMap::new(),
6316 instructions: vec![],
6317 content_hash: None,
6318 };
6319
6320 let output =
6321 compile_stack_spec(stack_spec, None).expect("stack compilation should succeed");
6322 let file = output.full_file();
6323 let count = output
6324 .interfaces
6325 .matches("export type PlanStatus =")
6326 .count();
6327
6328 assert_eq!(
6329 count, 1,
6330 "expected shared enum type to be emitted once, got:\n{}",
6331 output.interfaces
6332 );
6333 assert!(
6334 file.contains("_STACK_CORE = {"),
6335 "core export missing:\n{}",
6336 file
6337 );
6338 assert!(
6339 !file.contains("extendStack"),
6340 "no extension wiring expected:\n{}",
6341 file
6342 );
6343 }
6344
6345 #[test]
6346 fn golden_ore_stack_json_compiles_program_modules_without_entities() {
6347 let path = concat!(
6348 env!("CARGO_MANIFEST_DIR"),
6349 "/../stacks/ore/.arete/OreStream.stack.json"
6350 );
6351 let json = match std::fs::read_to_string(path) {
6352 Ok(c) => c,
6353 Err(_) => return,
6355 };
6356 let mut spec: SerializableStackSpec =
6357 serde_json::from_str(&json).expect("ore stack json should deserialize");
6358
6359 if spec.program_specs.is_empty() {
6360 let error = compile_program_modules(spec, None).unwrap_err();
6361 assert!(error.contains("Regenerate the .stack.json"));
6362 return;
6363 }
6364
6365 spec.entities.clear();
6367
6368 let output =
6369 compile_program_modules(spec, None).expect("program-module compilation should succeed");
6370 let file = output.full_file();
6371
6372 assert!(
6374 file.contains("export const ORE = {"),
6375 "ore const missing:\n{}",
6376 file
6377 );
6378 assert!(
6379 file.contains("export const ENTROPY = {"),
6380 "entropy const missing"
6381 );
6382 assert!(
6383 file.contains("export const ORE_STREAM_PROGRAMS = {"),
6384 "combined program map missing"
6385 );
6386 assert!(file.contains(" ore: ORE,"));
6387 assert!(file.contains(" entropy: ENTROPY,"));
6388 assert!(file.contains("export default ORE_STREAM_PROGRAMS;"));
6389 assert!(file.contains("All portable programs from the OreStream stack"));
6390 assert!(file.contains("export const ORE_STREAM_PROGRAM_READS = {"));
6391
6392 assert!(file.contains("createInstructionHandler"));
6394 assert!(file.contains("pdas: {"));
6395 assert!(file.contains("addresses: {"));
6396 assert!(file.contains("instructions: {"));
6397 assert!(file.contains("createPreparedInstruction({"));
6398 assert!(file.contains("buildInstruction("));
6399
6400 assert!(!file.contains("stateView"), "no view helpers expected");
6402 assert!(!file.contains("listView"), "no view helpers expected");
6403 assert!(!file.contains("views:"), "no views block expected");
6404 assert!(!file.contains("endpoints:"), "no endpoints block expected");
6405 assert!(
6406 !file.contains("extendStack"),
6407 "no extension wiring expected"
6408 );
6409 }
6410
6411 #[test]
6412 fn golden_ore_stack_json_emits_typed_state_view_keys() {
6413 let path = concat!(
6414 env!("CARGO_MANIFEST_DIR"),
6415 "/../stacks/ore/.arete/OreStream.stack.json"
6416 );
6417 let json = match std::fs::read_to_string(path) {
6418 Ok(contents) => contents,
6419 Err(_) => return,
6421 };
6422 let spec: SerializableStackSpec =
6423 serde_json::from_str(&json).expect("ore stack json should deserialize");
6424
6425 if spec.program_specs.is_empty() {
6426 let error = compile_stack_spec(spec, None).unwrap_err();
6427 assert!(error.contains("Regenerate the .stack.json"));
6428 return;
6429 }
6430
6431 let output = compile_stack_spec(spec, None).expect("ore stack should compile");
6432 let stack = output.stack_definition;
6433
6434 assert!(stack.contains(
6435 "state: stateView<OreRound, { roundId: bigint }>('OreRound/state', ['roundId'])"
6436 ));
6437 assert!(stack.contains(
6438 "state: stateView<OreBoard, { address: string }>('OreBoard/state', ['address'])"
6439 ));
6440 assert!(stack.contains(
6441 "state: stateView<OreMiner, { authority: string }>('OreMiner/state', ['authority'])"
6442 ));
6443 assert_eq!(
6444 stack.matches(
6445 "state: stateView<OreMiner, { authority: string }>('OreMiner/state', ['authority'])"
6446 )
6447 .count(),
6448 1
6449 );
6450 }
6451
6452 #[test]
6453 fn compile_program_modules_emits_amount_aware_semantic_instruction_wrappers() {
6454 let stack_spec = SerializableStackSpec {
6455 ast_version: CURRENT_AST_VERSION.to_string(),
6456 stack_name: "DemoStream".to_string(),
6457 program_ids: vec!["Prog111".to_string()],
6458 idls: vec![IdlSnapshot {
6459 name: "demo".to_string(),
6460 program_id: Some("Prog111".to_string()),
6461 version: "0.1.0".to_string(),
6462 accounts: vec![],
6463 instructions: vec![IdlInstructionSnapshot {
6464 name: "deposit".to_string(),
6465 discriminator: vec![9],
6466 discriminant: None,
6467 docs: vec![],
6468 accounts: vec![],
6469 args: vec![
6470 IdlFieldSnapshot {
6471 name: "amount".to_string(),
6472 type_: IdlTypeSnapshot::Simple("u64".to_string()),
6473 amount_hint: None,
6474 },
6475 IdlFieldSnapshot {
6476 name: "mint".to_string(),
6477 type_: IdlTypeSnapshot::Simple("publicKey".to_string()),
6478 amount_hint: None,
6479 },
6480 ],
6481 }],
6482 types: vec![],
6483 events: vec![],
6484 errors: vec![],
6485 discriminant_size: 1,
6486 }],
6487 program_specs: vec![demo_program_spec()],
6488 entities: vec![],
6489 pdas: BTreeMap::new(),
6490 instructions: vec![InstructionDef {
6491 name: "deposit".to_string(),
6492 discriminator: vec![9],
6493 discriminator_size: 1,
6494 accounts: vec![],
6495 args: vec![
6496 InstructionArgDef {
6497 name: "amount".to_string(),
6498 arg_type: "u64".to_string(),
6499 docs: vec![],
6500 amount_hint: Some(InstructionAmountHint {
6501 decimals_source: AmountDecimalsSource::ArgMint {
6502 arg_name: "mint".to_string(),
6503 },
6504 }),
6505 },
6506 InstructionArgDef {
6507 name: "mint".to_string(),
6508 arg_type: "solana_pubkey::Pubkey".to_string(),
6509 docs: vec![],
6510 amount_hint: None,
6511 },
6512 ],
6513 errors: vec![],
6514 program_id: Some("Prog111".to_string()),
6515 docs: vec![],
6516 }],
6517 content_hash: None,
6518 };
6519
6520 let output = compile_program_modules(stack_spec, None)
6521 .expect("program-module compilation should succeed");
6522 let file = output.full_file();
6523
6524 assert!(
6525 file.contains("type AmountInput"),
6526 "amount import missing:\n{}",
6527 file
6528 );
6529 assert!(
6530 file.contains("PROGRAM_OPERATION_EXTENSIONS"),
6531 "runtime extension import missing"
6532 );
6533 assert!(
6534 output.imports.contains("type BuildOptions"),
6535 "build options import missing"
6536 );
6537 assert!(
6538 output.imports.contains("type ProgramOperationContext"),
6539 "operation context import missing"
6540 );
6541 assert!(
6542 file.contains("resolveAmountToRaw"),
6543 "amount resolver import missing"
6544 );
6545 assert!(file.contains("export interface DepositSemanticParams"));
6546 assert!(file.contains("build?: BuildOptions;"));
6547 assert!(file.contains("[PROGRAM_OPERATION_EXTENSIONS]: {"));
6548 assert!(file.contains("createOperations(context: ProgramOperationContext)"));
6549 assert!(file
6550 .contains("deposit: instructionOperation(async (params: DepositSemanticParams) => {"));
6551 assert!(file.contains("const { build, amountDecimals, ...rawParams } = params;"));
6552 assert!(file.contains("resolveAmountToRaw(context.chain"));
6553 assert!(file.contains("const instruction = buildInstruction(depositInstruction, {"));
6554 assert!(file.contains("createPreparedInstruction({"));
6555 }
6556
6557 #[test]
6558 fn golden_ore_stack_json_compiles_stack_with_root_helper_namespaces() {
6559 let path = concat!(
6560 env!("CARGO_MANIFEST_DIR"),
6561 "/../stacks/ore/.arete/OreStream.stack.json"
6562 );
6563 let json = match std::fs::read_to_string(path) {
6564 Ok(c) => c,
6565 Err(_) => return,
6566 };
6567 let spec: SerializableStackSpec =
6568 serde_json::from_str(&json).expect("ore stack json should deserialize");
6569
6570 if spec.program_specs.is_empty() {
6571 let error = compile_stack_spec(spec, None).unwrap_err();
6572 assert!(error.contains("Regenerate the .stack.json"));
6573 return;
6574 }
6575
6576 let output = compile_stack_spec(spec, None).expect("stack compilation should succeed");
6577 let file = output.full_file();
6578
6579 assert!(
6580 file.contains("endpoints:"),
6581 "stack endpoints missing:\n{}",
6582 file
6583 );
6584 assert!(
6585 file.contains("programs: {"),
6586 "program block missing:\n{}",
6587 file
6588 );
6589 assert!(
6590 file.contains("addresses: {"),
6591 "root addresses missing:\n{}",
6592 file
6593 );
6594 assert!(
6595 file.contains("instructions: {"),
6596 "program instructions missing:\n{}",
6597 file
6598 );
6599 assert!(
6600 file.contains("buildInstruction("),
6601 "instruction builders missing:\n{}",
6602 file
6603 );
6604 }
6605
6606 #[test]
6607 fn account_codegen_normalizes_raw_keys_and_nested_types() {
6608 let idl_snapshot = IdlSnapshot {
6609 name: "presale".to_string(),
6610 program_id: None,
6611 version: "0.1.0".to_string(),
6612 accounts: vec![IdlAccountSnapshot {
6613 name: "Presale".to_string(),
6614 discriminator: vec![1, 2, 3, 4, 5, 6, 7, 8],
6615 docs: vec![],
6616 serialization: None,
6617 fields: vec![
6618 IdlFieldSnapshot {
6619 name: "owner".to_string(),
6620 type_: IdlTypeSnapshot::Simple("pubkey".to_string()),
6621 amount_hint: None,
6622 },
6623 IdlFieldSnapshot {
6624 name: "total_deposit".to_string(),
6625 type_: IdlTypeSnapshot::Simple("u64".to_string()),
6626 amount_hint: None,
6627 },
6628 IdlFieldSnapshot {
6629 name: "optional_authority".to_string(),
6630 type_: IdlTypeSnapshot::Option(IdlOptionTypeSnapshot {
6631 option: Box::new(IdlTypeSnapshot::Simple("pubkey".to_string())),
6632 }),
6633 amount_hint: None,
6634 },
6635 IdlFieldSnapshot {
6636 name: "createKey".to_string(),
6637 type_: IdlTypeSnapshot::Simple("pubkey".to_string()),
6638 amount_hint: None,
6639 },
6640 IdlFieldSnapshot {
6641 name: "member".to_string(),
6642 type_: IdlTypeSnapshot::Defined(IdlDefinedTypeSnapshot {
6643 defined: IdlDefinedInnerSnapshot::Simple("MemberConfig".to_string()),
6644 }),
6645 amount_hint: None,
6646 },
6647 ],
6648 type_def: None,
6649 }],
6650 instructions: vec![],
6651 types: vec![IdlTypeDefSnapshot {
6652 name: "MemberConfig".to_string(),
6653 docs: vec![],
6654 serialization: None,
6655 type_def: IdlTypeDefKindSnapshot::Struct {
6656 kind: "struct".to_string(),
6657 fields: vec![
6658 IdlFieldSnapshot {
6659 name: "last_updated_at".to_string(),
6660 type_: IdlTypeSnapshot::Simple("i128".to_string()),
6661 amount_hint: None,
6662 },
6663 IdlFieldSnapshot {
6664 name: "authority_key".to_string(),
6665 type_: IdlTypeSnapshot::Simple("pubkey".to_string()),
6666 amount_hint: None,
6667 },
6668 ],
6669 },
6670 }],
6671 events: vec![],
6672 errors: vec![],
6673 discriminant_size: 8,
6674 };
6675
6676 let artifacts = generate_idl_account_artifacts(&[idl_snapshot], &HashSet::new());
6677 let account_bigint = bigint_zod();
6678
6679 assert!(artifacts.code.contains("export interface Presale {"));
6680 assert!(
6681 artifacts.code.contains("owner: string;"),
6682 "missing owner field:\n{}",
6683 artifacts.code
6684 );
6685 assert!(
6686 artifacts.code.contains("totalDeposit: bigint;"),
6687 "missing totalDeposit field:\n{}",
6688 artifacts.code
6689 );
6690 assert!(
6691 artifacts.code.contains("optionalAuthority: string | null;"),
6692 "missing optionalAuthority field:\n{}",
6693 artifacts.code
6694 );
6695 assert!(
6696 artifacts.code.contains("createKey: string;"),
6697 "missing createKey field:\n{}",
6698 artifacts.code
6699 );
6700 assert!(
6701 artifacts.code.contains("member: MemberConfig;"),
6702 "missing nested type field:\n{}",
6703 artifacts.code
6704 );
6705 assert!(artifacts.code.contains("export interface MemberConfig {"));
6706 assert!(
6707 artifacts.code.contains("lastUpdatedAt: bigint;"),
6708 "missing nested bigint field:\n{}",
6709 artifacts.code
6710 );
6711 assert!(
6712 artifacts.code.contains("authorityKey: string;"),
6713 "missing nested camelCase field:\n{}",
6714 artifacts.code
6715 );
6716
6717 assert!(artifacts
6718 .code
6719 .contains("export const PresaleSchema = z.object({"));
6720 assert!(
6721 artifacts.code.contains("owner: z.string(),"),
6722 "missing owner schema field:\n{}",
6723 artifacts.code
6724 );
6725 assert!(
6726 artifacts
6727 .code
6728 .contains(&format!("total_deposit: {},", account_bigint)),
6729 "missing total_deposit schema field:\n{}",
6730 artifacts.code
6731 );
6732 assert!(
6733 artifacts
6734 .code
6735 .contains("optional_authority: z.string().nullable(),"),
6736 "missing optional_authority schema field:\n{}",
6737 artifacts.code
6738 );
6739 assert!(
6740 artifacts.code.contains("create_key: z.string(),"),
6741 "missing create_key schema field:\n{}",
6742 artifacts.code
6743 );
6744 assert!(
6745 artifacts
6746 .code
6747 .contains("member: z.lazy(() => MemberConfigSchema),"),
6748 "missing nested schema field:\n{}",
6749 artifacts.code
6750 );
6751 assert!(
6752 artifacts.code.contains("owner: value.owner,"),
6753 "missing owner transform:\n{}",
6754 artifacts.code
6755 );
6756 assert!(
6757 artifacts
6758 .code
6759 .contains("totalDeposit: value.total_deposit,"),
6760 "missing totalDeposit transform:\n{}",
6761 artifacts.code
6762 );
6763 assert!(
6764 artifacts
6765 .code
6766 .contains("optionalAuthority: value.optional_authority,"),
6767 "missing optionalAuthority transform:\n{}",
6768 artifacts.code
6769 );
6770 assert!(
6771 artifacts.code.contains("createKey: value.create_key,"),
6772 "missing createKey transform:\n{}",
6773 artifacts.code
6774 );
6775 assert!(
6776 artifacts.code.contains("member: value.member,"),
6777 "missing nested transform:\n{}",
6778 artifacts.code
6779 );
6780
6781 assert!(artifacts
6782 .code
6783 .contains("export const MemberConfigSchema = z.object({"));
6784 assert!(
6785 artifacts
6786 .code
6787 .contains(&format!("last_updated_at: {},", bigint_zod())),
6788 "missing nested bigint schema field:\n{}",
6789 artifacts.code
6790 );
6791 assert!(
6792 artifacts.code.contains("authority_key: z.string(),"),
6793 "missing nested schema field:\n{}",
6794 artifacts.code
6795 );
6796 assert!(
6797 artifacts
6798 .code
6799 .contains("lastUpdatedAt: value.last_updated_at,"),
6800 "missing nested bigint transform:\n{}",
6801 artifacts.code
6802 );
6803 assert!(
6804 artifacts
6805 .code
6806 .contains("authorityKey: value.authority_key,"),
6807 "missing nested camelCase transform:\n{}",
6808 artifacts.code
6809 );
6810 }
6811
6812 #[test]
6813 fn account_codegen_falls_back_to_same_named_type_def_when_account_fields_are_empty() {
6814 let idl_snapshot = IdlSnapshot {
6815 name: "presale".to_string(),
6816 program_id: None,
6817 version: "0.1.0".to_string(),
6818 accounts: vec![IdlAccountSnapshot {
6819 name: "Presale".to_string(),
6820 discriminator: vec![1, 2, 3, 4, 5, 6, 7, 8],
6821 docs: vec![],
6822 serialization: None,
6823 fields: vec![],
6824 type_def: None,
6825 }],
6826 instructions: vec![],
6827 types: vec![IdlTypeDefSnapshot {
6828 name: "Presale".to_string(),
6829 docs: vec![],
6830 serialization: None,
6831 type_def: IdlTypeDefKindSnapshot::Struct {
6832 kind: "struct".to_string(),
6833 fields: vec![
6834 IdlFieldSnapshot {
6835 name: "owner".to_string(),
6836 type_: IdlTypeSnapshot::Simple("pubkey".to_string()),
6837 amount_hint: None,
6838 },
6839 IdlFieldSnapshot {
6840 name: "total_deposit".to_string(),
6841 type_: IdlTypeSnapshot::Simple("u64".to_string()),
6842 amount_hint: None,
6843 },
6844 ],
6845 },
6846 }],
6847 events: vec![],
6848 errors: vec![],
6849 discriminant_size: 8,
6850 };
6851
6852 let artifacts = generate_idl_account_artifacts(&[idl_snapshot], &HashSet::new());
6853
6854 assert!(artifacts.code.contains("export interface Presale {"));
6855 assert!(
6856 artifacts.code.contains("owner: string;"),
6857 "missing owner field:\n{}",
6858 artifacts.code
6859 );
6860 assert!(
6861 artifacts.code.contains("totalDeposit: bigint;"),
6862 "missing totalDeposit field:\n{}",
6863 artifacts.code
6864 );
6865 assert!(
6866 artifacts
6867 .code
6868 .contains("export const PresaleSchema = z.object({"),
6869 "missing schema:\n{}",
6870 artifacts.code
6871 );
6872 assert!(
6873 artifacts.code.contains("owner: z.string(),"),
6874 "missing owner schema field:\n{}",
6875 artifacts.code
6876 );
6877 assert!(
6878 artifacts
6879 .code
6880 .contains(&format!("total_deposit: {},", bigint_zod())),
6881 "missing total_deposit schema field:\n{}",
6882 artifacts.code
6883 );
6884 assert!(
6885 artifacts.code.contains("owner: value.owner,"),
6886 "missing owner transform:\n{}",
6887 artifacts.code
6888 );
6889 assert!(
6890 artifacts
6891 .code
6892 .contains("totalDeposit: value.total_deposit,"),
6893 "missing totalDeposit transform:\n{}",
6894 artifacts.code
6895 );
6896 }
6897
6898 #[test]
6899 fn compile_program_modules_rejects_specs_without_idls() {
6900 let stack_spec = SerializableStackSpec {
6901 ast_version: CURRENT_AST_VERSION.to_string(),
6902 stack_name: "Empty".to_string(),
6903 program_ids: vec![],
6904 idls: vec![],
6905 program_specs: vec![],
6906 entities: vec![],
6907 pdas: BTreeMap::new(),
6908 instructions: vec![],
6909 content_hash: None,
6910 };
6911
6912 let error =
6913 compile_program_modules(stack_spec, None).expect_err("no IDLs should be an error");
6914 assert!(error.contains("no IDLs"), "unexpected error: {}", error);
6915 }
6916}