1use crate::ast::*;
2use crate::identifiers::{typescript as ts_ident, IdentifierCase};
3use crate::stack_types::{
4 entity_idl, entity_program_name, resolved_type_namespaces, ResolvedTypeClaim,
5 StackResolvedTypes,
6};
7use arete_idl::utils::to_snake_case as idl_to_snake_case;
8use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
9
10#[derive(Debug, Clone)]
12pub struct TypeScriptOutput {
13 pub interfaces: String,
14 pub stack_definition: String,
15 pub imports: String,
16 pub schema_names: Vec<String>,
17}
18
19impl TypeScriptOutput {
20 pub fn full_file(&self) -> String {
21 format!(
22 "{}\n\n{}\n\n{}",
23 self.imports, self.interfaces, self.stack_definition
24 )
25 }
26}
27
28#[derive(Debug, Clone)]
30pub struct TypeScriptConfig {
31 pub package_name: String,
32 pub generate_helpers: bool,
33 pub interface_prefix: String,
34 pub export_const_name: String,
35 pub url: Option<String>,
37}
38
39impl Default for TypeScriptConfig {
40 fn default() -> Self {
41 Self {
42 package_name: "@usearete/react".to_string(),
43 generate_helpers: true,
44 interface_prefix: "".to_string(),
45 export_const_name: "STACK".to_string(),
46 url: None,
47 }
48 }
49}
50
51pub trait TypeScriptGenerator {
53 fn generate_typescript(&self, config: &TypeScriptConfig) -> String;
54}
55
56pub trait TypeScriptInterfaceGenerator {
58 fn generate_interface(&self, name: &str, config: &TypeScriptConfig) -> String;
59}
60
61pub trait TypeScriptTypeMapper {
63 fn to_typescript_type(&self) -> String;
64}
65
66pub struct TypeScriptCompiler<S> {
68 spec: TypedStreamSpec<S>,
69 entity_name: String,
70 config: TypeScriptConfig,
71 idl: Option<serde_json::Value>, handlers_json: Option<serde_json::Value>, views: Vec<ViewDef>, already_emitted_types: HashSet<String>,
75 stack_types: StackResolvedTypes,
77 program_name: Option<String>,
79 field_notes: BTreeMap<String, String>,
82}
83
84struct ResolvedTypeNames {
86 names: HashMap<String, String>,
88 shared: HashSet<String>,
91}
92
93impl ResolvedTypeNames {
94 fn record(&mut self, resolved: &ResolvedStructType, claim: ResolvedTypeClaim) {
95 if claim.is_shared() {
96 self.shared.insert(claim.name().to_string());
97 }
98 self.names
99 .insert(resolved.type_name.clone(), claim.into_name());
100 }
101}
102
103#[derive(Debug, Clone, PartialEq, Eq)]
104struct StateViewKeyDefinition {
105 field_name: String,
106 typescript_type: String,
107}
108
109impl StateViewKeyDefinition {
110 fn object_type(&self) -> String {
111 format!(
112 "{{ {}: {} }}",
113 render_ts_property_name_literal(&self.field_name),
114 self.typescript_type
115 )
116 }
117
118 fn fields_literal(&self) -> String {
119 format!("['{}']", escape_ts_single_quotes(&self.field_name))
120 }
121}
122
123impl<S> TypeScriptCompiler<S> {
124 pub fn new(spec: TypedStreamSpec<S>, entity_name: String) -> Self {
125 Self {
126 spec,
127 entity_name,
128 config: TypeScriptConfig::default(),
129 idl: None,
130 handlers_json: None,
131 views: Vec::new(),
132 already_emitted_types: HashSet::new(),
133 stack_types: StackResolvedTypes::default(),
134 program_name: None,
135 field_notes: BTreeMap::new(),
136 }
137 }
138
139 pub fn with_config(mut self, config: TypeScriptConfig) -> Self {
140 self.config = config;
141 self
142 }
143
144 pub fn with_idl(mut self, idl: Option<serde_json::Value>) -> Self {
145 self.idl = idl;
146 self
147 }
148
149 pub fn with_handlers_json(mut self, handlers: Option<serde_json::Value>) -> Self {
150 self.handlers_json = handlers;
151 self
152 }
153
154 pub fn with_views(mut self, views: Vec<ViewDef>) -> Self {
155 self.views = views;
156 self
157 }
158
159 pub fn with_already_emitted_types(mut self, types: HashSet<String>) -> Self {
160 self.already_emitted_types = types;
161 self
162 }
163
164 pub fn with_field_notes(mut self, notes: BTreeMap<String, String>) -> Self {
167 self.field_notes = notes;
168 self
169 }
170
171 fn with_stack_resolved_types(
172 mut self,
173 types: StackResolvedTypes,
174 program_name: Option<String>,
175 ) -> Self {
176 self.stack_types = types;
177 self.program_name = program_name;
178 self
179 }
180
181 pub fn compile(&self) -> TypeScriptOutput {
182 self.try_compile()
183 .expect("TypeScript SDK generation failed")
184 }
185
186 pub fn try_compile(&self) -> Result<TypeScriptOutput, String> {
187 let state_view_key = state_view_key_definition(
188 &self.entity_name,
189 &self.spec.identity,
190 &self.spec.field_mappings,
191 &self.spec.sections,
192 )?;
193 let imports = self.generate_imports();
194 let interfaces = self.generate_interfaces();
195 let schema_output = self.generate_schemas();
196 let combined_interfaces = if schema_output.definitions.is_empty() {
197 interfaces
198 } else if interfaces.is_empty() {
199 schema_output.definitions.clone()
200 } else {
201 format!("{}\n\n{}", interfaces, schema_output.definitions)
202 };
203 let stack_definition = self.generate_stack_definition(&state_view_key);
204
205 Ok(TypeScriptOutput {
206 imports,
207 interfaces: combined_interfaces,
208 stack_definition,
209 schema_names: schema_output.names,
210 })
211 }
212
213 fn generate_imports(&self) -> String {
214 "import { z } from 'zod';".to_string()
215 }
216
217 fn generate_view_helpers(&self) -> String {
218 generate_view_helpers_static()
219 }
220
221 fn generate_interfaces(&self) -> String {
222 let mut interfaces = Vec::new();
223 let mut processed_types = HashSet::new();
224 let all_sections = self.collect_interface_sections();
225
226 for (section_name, fields) in all_sections {
229 if !is_root_section(§ion_name) && processed_types.insert(section_name.clone()) {
230 let deduplicated_fields = self.deduplicate_fields(fields);
231 let interface =
232 self.generate_interface_from_fields(§ion_name, &deduplicated_fields);
233 interfaces.push(interface);
234 }
235 }
236
237 let main_interface = self.generate_main_entity_interface();
239 interfaces.push(main_interface);
240
241 let nested_interfaces = self.generate_nested_interfaces();
242 interfaces.extend(nested_interfaces);
243
244 let builtin_interfaces = self.generate_builtin_resolver_interfaces();
245 interfaces.extend(builtin_interfaces);
246
247 if self.should_emit_capture_wrapper() {
248 interfaces.push(self.generate_capture_wrapper_interface());
249 }
250
251 if self.has_event_types() {
252 interfaces.push(self.generate_event_wrapper_interface());
253 }
254
255 interfaces.join("\n\n")
256 }
257
258 fn collect_interface_sections(&self) -> BTreeMap<String, Vec<TypeScriptField>> {
259 let mut all_sections: BTreeMap<String, Vec<TypeScriptField>> = BTreeMap::new();
260
261 for handler in &self.spec.handlers {
263 let interface_sections = self.extract_interface_sections_from_handler(handler);
264
265 for (section_name, mut fields) in interface_sections {
266 all_sections
267 .entry(section_name)
268 .or_default()
269 .append(&mut fields);
270 }
271 }
272
273 self.add_unmapped_fields(&mut all_sections);
276
277 all_sections
278 }
279
280 fn deduplicate_fields(&self, mut fields: Vec<TypeScriptField>) -> Vec<TypeScriptField> {
281 let mut seen = HashSet::new();
282 let mut unique_fields = Vec::new();
283
284 fields.sort_by(|a, b| a.name.cmp(&b.name));
286
287 for field in fields {
288 if seen.insert(field.name.clone()) {
289 unique_fields.push(field);
290 }
291 }
292
293 unique_fields
294 }
295
296 fn extract_interface_sections_from_handler(
297 &self,
298 handler: &TypedHandlerSpec<S>,
299 ) -> BTreeMap<String, Vec<TypeScriptField>> {
300 let mut sections: BTreeMap<String, Vec<TypeScriptField>> = BTreeMap::new();
301
302 for mapping in &handler.mappings {
303 if !mapping.emit {
304 continue;
305 }
306 let parts: Vec<&str> = mapping.target_path.split('.').collect();
307
308 if parts.len() > 1 {
309 let section_name = parts[0];
310 let field_name = parts[1];
311
312 let ts_field = TypeScriptField::patch(
313 field_name.to_string(),
314 self.mapping_to_typescript_type(mapping),
315 self.is_field_nullable(mapping),
316 );
317
318 sections
319 .entry(section_name.to_string())
320 .or_default()
321 .push(ts_field);
322 } else {
323 let ts_field = TypeScriptField::patch(
324 mapping.target_path.clone(),
325 self.mapping_to_typescript_type(mapping),
326 self.is_field_nullable(mapping),
327 );
328
329 sections
330 .entry("Root".to_string())
331 .or_default()
332 .push(ts_field);
333 }
334 }
335
336 sections
337 }
338
339 fn add_unmapped_fields(&self, sections: &mut BTreeMap<String, Vec<TypeScriptField>>) {
340 if !self.spec.sections.is_empty() {
342 for section in &self.spec.sections {
344 let section_fields = sections.entry(section.name.clone()).or_default();
345
346 for field_info in §ion.fields {
347 if !field_info.emit {
348 continue;
349 }
350 let already_exists = section_fields
352 .iter()
353 .any(|f| f.name == field_info.field_name);
354
355 if !already_exists {
356 let field_path = format!("{}.{}", section.name, field_info.field_name);
358 let effective_field_info =
359 if let Some(mapping) = self.spec.field_mappings.get(&field_path) {
360 if mapping
362 .inner_type
363 .as_ref()
364 .is_some_and(|t| is_builtin_resolver_type(t))
365 {
366 mapping
367 } else {
368 field_info
369 }
370 } else {
371 field_info
372 };
373 let (raw_name, canonical_name) = localize_section_field_names(
374 section.name.as_str(),
375 effective_field_info,
376 );
377
378 section_fields.push(TypeScriptField::from_names(
379 raw_name,
380 canonical_name,
381 self.field_type_info_to_typescript(effective_field_info),
382 effective_field_info.is_optional,
383 FieldPresence::Patch,
384 ));
385 }
386 }
387 }
388 } else {
389 for (field_path, field_type_info) in &self.spec.field_mappings {
391 if !field_type_info.emit {
392 continue;
393 }
394 let parts: Vec<&str> = field_path.split('.').collect();
395 if parts.len() > 1 {
396 let section_name = parts[0];
397 let field_name = parts[1];
398
399 let section_fields = sections.entry(section_name.to_string()).or_default();
400
401 let already_exists = section_fields.iter().any(|f| f.name == field_name);
402
403 if !already_exists {
404 section_fields.push(TypeScriptField::from_names(
405 field_name.to_string(),
406 field_type_info.canonical_field_name(),
407 self.base_type_to_typescript(
408 &field_type_info.base_type,
409 field_type_info.effective_integer_kind(),
410 field_type_info.is_array,
411 ),
412 field_type_info.is_optional,
413 FieldPresence::Patch,
414 ));
415 }
416 }
417 }
418 }
419 }
420
421 fn generate_interface_from_fields(&self, name: &str, fields: &[TypeScriptField]) -> String {
422 let interface_name = self.section_interface_name(name);
423 let fields = self.documented_entity_fields(Some(name), fields);
424 render_interface_from_ts_fields(&interface_name, &fields, true)
425 }
426
427 fn documented_entity_fields(
430 &self,
431 section: Option<&str>,
432 fields: &[TypeScriptField],
433 ) -> Vec<TypeScriptField> {
434 fields
435 .iter()
436 .map(|field| TypeScriptField {
437 description: self.entity_field_doc(section, field),
438 ..field.clone()
439 })
440 .collect()
441 }
442
443 fn entity_field_doc(&self, section: Option<&str>, field: &TypeScriptField) -> Option<String> {
448 let wire = match section {
449 Some(section) if !is_root_section(section) => format!("{section}.{}", field.raw_name),
450 _ => field.raw_name.clone(),
451 };
452 let mut parts = Vec::new();
453 if field.raw_name != field.name {
454 parts.push(format!("Wire name `{wire}` (CLI and MCP output)."));
455 }
456 if field.ts_type.contains("bigint") {
457 parts.push(BIGINT_FIELD_DOC.to_string());
458 }
459 if let Some(note) = self.field_notes.get(&wire) {
460 parts.push(note.clone());
461 }
462 (!parts.is_empty()).then(|| parts.join(" "))
463 }
464
465 fn section_interface_name(&self, name: &str) -> String {
466 section_interface_name(&self.config.interface_prefix, &self.entity_name, name)
467 }
468
469 fn generate_main_entity_interface(&self) -> String {
470 let entity_name = to_pascal_case(&self.entity_name);
471
472 let main_fields = self.collect_main_entity_fields();
473 if main_fields.is_empty() {
474 return format!(
475 "export interface {} {{\n // Generated interface - extend as needed\n}}",
476 entity_name
477 );
478 }
479
480 let main_fields = self.documented_entity_fields(None, &main_fields);
481 render_interface_from_ts_fields(&entity_name, &main_fields, true)
482 }
483
484 fn generate_schemas(&self) -> SchemaOutput {
485 let patch_schema_types = self.patch_schema_type_names();
486 let mut definitions = Vec::new();
487 let mut names = Vec::new();
488 let mut seen = HashSet::new();
489
490 let mut push_schema = |schema_name: String, definition: String, export_name: bool| {
491 if seen.insert(schema_name.clone()) {
492 if export_name {
493 names.push(schema_name);
494 }
495 definitions.push(definition);
496 }
497 };
498
499 for (schema_name, definition) in self.generate_builtin_resolver_schemas() {
500 push_schema(schema_name, definition, true);
501 }
502
503 if self.has_event_types() {
504 push_schema(
505 "EventWrapperSchema".to_string(),
506 self.generate_event_wrapper_schema(),
507 true,
508 );
509 }
510
511 if self.should_emit_capture_wrapper() {
512 push_schema(
513 "CaptureWrapperSchema".to_string(),
514 self.generate_capture_wrapper_schema(),
515 false,
516 );
517 }
518
519 for (schema_name, definition) in self.generate_resolved_type_schemas(&patch_schema_types) {
520 push_schema(schema_name, definition, true);
521 }
522
523 for (schema_name, definition) in
524 self.generate_resolved_type_patch_schemas(&patch_schema_types)
525 {
526 push_schema(schema_name, definition, false);
527 }
528
529 for (schema_name, definition) in self.generate_event_schemas() {
530 push_schema(schema_name, definition, true);
531 }
532
533 for (schema_name, definition) in self.generate_idl_enum_schemas() {
534 push_schema(schema_name, definition, true);
535 }
536
537 let all_sections = self.collect_interface_sections();
538
539 for (section_name, fields) in &all_sections {
540 if is_root_section(section_name) {
541 continue;
542 }
543 let deduplicated_fields = self.deduplicate_fields(fields.clone());
544 let interface_name = self.section_interface_name(section_name);
545 let schema_definition = self.generate_schema_for_fields(
546 &interface_name,
547 &deduplicated_fields,
548 true,
549 SchemaMode::Canonical,
550 &patch_schema_types,
551 );
552 push_schema(format!("{}Schema", interface_name), schema_definition, true);
553
554 let patch_schema_definition = self.generate_schema_for_fields(
555 &interface_name,
556 &deduplicated_fields,
557 false,
558 SchemaMode::Patch,
559 &patch_schema_types,
560 );
561 push_schema(
562 format!("{}PatchSchema", interface_name),
563 patch_schema_definition,
564 false,
565 );
566 }
567
568 let entity_name = to_pascal_case(&self.entity_name);
569 let main_fields = self.collect_main_entity_fields();
570 let entity_schema = self.generate_schema_for_fields(
571 &entity_name,
572 &main_fields,
573 true,
574 SchemaMode::Canonical,
575 &patch_schema_types,
576 );
577 push_schema(format!("{}Schema", entity_name), entity_schema, true);
578
579 let patch_schema = self.generate_schema_for_fields(
580 &entity_name,
581 &main_fields,
582 false,
583 SchemaMode::Patch,
584 &patch_schema_types,
585 );
586 push_schema(format!("{}PatchSchema", entity_name), patch_schema, false);
587
588 let completed_schema =
589 self.generate_completed_entity_schema(&entity_name, &patch_schema_types);
590 push_schema(
591 format!("{}CompletedSchema", entity_name),
592 completed_schema,
593 true,
594 );
595
596 SchemaOutput {
597 definitions: definitions.join("\n\n"),
598 names,
599 }
600 }
601
602 fn generate_event_wrapper_schema(&self) -> String {
603 r#"export const EventWrapperSchema = <T extends z.ZodTypeAny>(data: T) => z.object({
604 timestamp: z.number(),
605 data,
606 slot: z.number().optional(),
607 signature: z.string().optional(),
608 event_index: z.number().optional(),
609 ix_path: z.string().optional(),
610});"#
611 .to_string()
612 }
613
614 fn generate_capture_wrapper_schema(&self) -> String {
615 r#"export const CaptureWrapperSchema = <T extends z.ZodTypeAny>(data: T) => z.object({
616 timestamp: z.number(),
617 account_address: z.string(),
618 data,
619 slot: z.number().optional(),
620 signature: z.string().optional(),
621}).transform((value) => ({
622 timestamp: value.timestamp,
623 accountAddress: value.account_address,
624 data: value.data,
625 ...(value.slot !== undefined ? { slot: value.slot } : {}),
626 ...(value.signature !== undefined ? { signature: value.signature } : {}),
627}));"#
628 .to_string()
629 }
630
631 fn generate_builtin_resolver_schemas(&self) -> Vec<(String, String)> {
632 let mut schemas = Vec::new();
633 let registry = crate::resolvers::builtin_resolver_registry();
634
635 for resolver in registry.definitions() {
636 let output_type = resolver.output_type();
637 let should_emit = self.uses_builtin_type(output_type)
638 && !self.already_emitted_types.contains(output_type);
639
640 let extra_types_used = if let Some(ts_schema) = resolver.typescript_schema() {
642 ts_schema.definition.lines().any(|line| {
644 let line = line.trim();
645 if let Some(rest) = line.strip_prefix("export const ") {
647 let parts: Vec<&str> = rest.split_whitespace().collect();
648 if parts.len() >= 2 && parts[1] == "=" {
649 let schema_name = parts[0];
651 if let Some(type_name) = schema_name.strip_suffix("Schema") {
652 return self.uses_builtin_type(type_name)
653 && !self.already_emitted_types.contains(type_name);
654 }
655 }
656 }
657 false
658 })
659 } else {
660 false
661 };
662
663 if (should_emit || extra_types_used)
664 && !self.already_emitted_types.contains(output_type)
665 {
666 if let Some(schema) = resolver.typescript_schema() {
667 schemas.push((schema.name.to_string(), schema.definition.to_string()));
668 }
669 }
670 }
671
672 schemas
673 }
674
675 fn uses_builtin_type(&self, type_name: &str) -> bool {
676 for section in &self.spec.sections {
678 for field in §ion.fields {
679 if field.inner_type.as_deref() == Some(type_name) {
680 return true;
681 }
682 }
683 }
684 for field_info in self.spec.field_mappings.values() {
686 if field_info.inner_type.as_deref() == Some(type_name) {
687 return true;
688 }
689 }
690 false
691 }
692
693 fn generate_builtin_resolver_interfaces(&self) -> Vec<String> {
694 let mut interfaces = Vec::new();
695 let registry = crate::resolvers::builtin_resolver_registry();
696
697 for resolver in registry.definitions() {
698 let output_type = resolver.output_type();
699 let should_emit = self.uses_builtin_type(output_type)
700 && !self.already_emitted_types.contains(output_type);
701
702 let extra_types_used = if let Some(ts_interface) = resolver.typescript_interface() {
704 ts_interface.lines().any(|line| {
706 let line = line.trim();
707 if let Some(rest) = line.strip_prefix("export type ") {
709 if let Some(type_name) = rest.split_whitespace().next() {
710 return self.uses_builtin_type(type_name)
711 && !self.already_emitted_types.contains(type_name);
712 }
713 } else if let Some(rest) = line.strip_prefix("export interface ") {
714 if let Some(type_name) = rest.split_whitespace().next() {
715 return self.uses_builtin_type(type_name)
716 && !self.already_emitted_types.contains(type_name);
717 }
718 }
719 false
720 })
721 } else {
722 false
723 };
724
725 if should_emit || extra_types_used {
726 if let Some(interface) = resolver.typescript_interface() {
727 interfaces.push(interface.to_string());
728 }
729 }
730 }
731
732 interfaces
733 }
734
735 fn collect_main_entity_fields(&self) -> Vec<TypeScriptField> {
736 let mut sections = BTreeMap::new();
737
738 for handler in &self.spec.handlers {
739 for mapping in &handler.mappings {
740 if !mapping.emit {
741 continue;
742 }
743 let parts: Vec<&str> = mapping.target_path.split('.').collect();
744 if parts.len() > 1 {
745 sections.insert(parts[0], true);
746 }
747 }
748 }
749
750 if !self.spec.sections.is_empty() {
751 for section in &self.spec.sections {
752 if section.fields.iter().any(|field| field.emit) {
753 sections.insert(§ion.name, true);
754 }
755 }
756 } else {
757 for mapping in &self.spec.handlers {
758 for field_mapping in &mapping.mappings {
759 if !field_mapping.emit {
760 continue;
761 }
762 let parts: Vec<&str> = field_mapping.target_path.split('.').collect();
763 if parts.len() > 1 {
764 sections.insert(parts[0], true);
765 }
766 }
767 }
768 }
769
770 let mut fields = Vec::new();
771
772 for section in sections.keys() {
773 if !is_root_section(section) {
774 let base_name = if self.entity_name.contains("Game") {
775 "Game"
776 } else {
777 &self.entity_name
778 };
779 let section_interface_name = format!("{}{}", base_name, to_pascal_case(section));
780 fields.push(TypeScriptField::patch(
781 section.to_string(),
782 section_interface_name,
783 false,
784 ));
785 }
786 }
787
788 fields.extend(self.root_entity_fields());
789 fields
790 }
791
792 fn root_entity_fields(&self) -> Vec<TypeScriptField> {
795 let mut fields = Vec::new();
796 for section in &self.spec.sections {
797 if is_root_section(§ion.name) {
798 for field in §ion.fields {
799 if !field.emit {
800 continue;
801 }
802 fields.push(TypeScriptField::from_names(
803 field.raw_field_name().to_string(),
804 field.canonical_field_name(),
805 self.field_type_info_to_typescript(field),
806 field.is_optional,
807 FieldPresence::Patch,
808 ));
809 }
810 }
811 }
812 fields
813 }
814
815 fn entity_field_references(&self) -> Vec<TypeScriptFieldReference> {
818 let mut references = Vec::new();
819 let sections = self.collect_interface_sections();
820 for field in self.collect_main_entity_fields() {
821 let Some(section_fields) = sections
822 .get(&field.raw_name)
823 .filter(|_| !is_root_section(&field.raw_name))
824 else {
825 continue;
826 };
827 for member in self.deduplicate_fields(section_fields.clone()) {
828 references.push(TypeScriptFieldReference {
829 path: format!("{}.{}", field.name, member.name),
830 wire: format!("{}.{}", field.raw_name, member.raw_name),
831 ts_type: member.ts_type.clone(),
832 nullable: member.nullable,
833 });
834 }
835 }
836 for field in self.root_entity_fields() {
837 references.push(TypeScriptFieldReference {
838 path: field.name.clone(),
839 wire: field.raw_name.clone(),
840 ts_type: field.ts_type.clone(),
841 nullable: field.nullable,
842 });
843 }
844 references
845 }
846
847 fn generate_schema_for_fields(
848 &self,
849 name: &str,
850 fields: &[TypeScriptField],
851 required: bool,
852 mode: SchemaMode,
853 patch_schema_types: &HashSet<String>,
854 ) -> String {
855 if fields.is_empty() {
856 return format!(
857 "export const {} = z.object({{}});",
858 schema_constant_name(name, mode)
859 );
860 }
861
862 let mut field_definitions = Vec::new();
863 let mut transform_fields = Vec::new();
864
865 for field in fields {
866 let base_schema = field.zod_schema.clone().unwrap_or_else(|| {
867 self.typescript_type_to_zod_for_schema(&field.ts_type, mode, patch_schema_types)
868 });
869 let with_nullable = if field.nullable {
870 format!("{}.nullable()", base_schema)
871 } else {
872 base_schema
873 };
874 let nullable_keys_can_be_absent = matches!(mode, SchemaMode::Canonical);
875 let schema = if required || matches!(field.presence, FieldPresence::Required) {
876 if field.nullable && nullable_keys_can_be_absent {
877 format!("{}.optional()", with_nullable)
881 } else {
882 with_nullable
883 }
884 } else {
885 format!("{}.optional()", with_nullable)
886 };
887
888 field_definitions.push(format!(" {}: {},", field.raw_name, schema));
889 if mode == SchemaMode::Patch {
890 transform_fields.push(format!(
891 " ...(value.{raw_name} !== undefined ? {{ {field_name}: value.{raw_name} }} : {{}}),",
892 raw_name = field.raw_name,
893 field_name = field.name,
894 ));
895 } else {
896 transform_fields.push(format!(" {}: value.{},", field.name, field.raw_name));
897 }
898 }
899
900 format!(
901 "export const {} = z.object({{\n{}\n}}).transform((value) => ({{\n{}\n}}));",
902 schema_constant_name(name, mode),
903 field_definitions.join("\n"),
904 transform_fields.join("\n")
905 )
906 }
907
908 fn generate_completed_entity_schema(
909 &self,
910 entity_name: &str,
911 patch_schema_types: &HashSet<String>,
912 ) -> String {
913 let main_fields = self.collect_main_entity_fields();
914 self.generate_schema_for_fields(
915 &format!("{}Completed", entity_name),
916 &main_fields,
917 true,
918 SchemaMode::Canonical,
919 patch_schema_types,
920 )
921 }
922
923 fn generate_resolved_type_schemas(
924 &self,
925 patch_schema_types: &HashSet<String>,
926 ) -> Vec<(String, String)> {
927 let mut schemas = Vec::new();
928 let resolved_type_names = self.resolved_type_names();
929 let mut generated_types = resolved_type_names.shared.clone();
931 let resolved_name_map = resolved_type_names.names;
932
933 for section in &self.spec.sections {
934 for field_info in §ion.fields {
935 if let Some(resolved) = &field_info.resolved_type {
936 let type_name =
937 self.resolved_type_to_interface_name_with_map(resolved, &resolved_name_map);
938
939 if !generated_types.insert(type_name.clone()) {
940 continue;
941 }
942
943 if resolved.is_enum {
944 let variants: Vec<String> = resolved
945 .enum_variants
946 .iter()
947 .map(|v| format!("\"{}\"", to_pascal_case(v)))
948 .collect();
949 let schema = if variants.is_empty() {
950 format!("export const {}Schema = z.string();", type_name)
951 } else {
952 format!(
953 "export const {}Schema = z.enum([{}]);",
954 type_name,
955 variants.join(", ")
956 )
957 };
958 schemas.push((format!("{}Schema", type_name), schema));
959 continue;
960 }
961
962 let schema = self.generate_schema_for_fields(
963 &type_name,
964 &self.resolved_fields_to_typescript_fields(&resolved.fields),
965 true,
966 SchemaMode::Canonical,
967 patch_schema_types,
968 );
969 schemas.push((format!("{}Schema", type_name), schema));
970 }
971 }
972 }
973
974 schemas
975 }
976
977 fn generate_resolved_type_patch_schemas(
978 &self,
979 patch_schema_types: &HashSet<String>,
980 ) -> Vec<(String, String)> {
981 let mut schemas = Vec::new();
982 let resolved_type_names = self.resolved_type_names();
983 let mut generated_types = resolved_type_names.shared.clone();
985 let resolved_name_map = resolved_type_names.names;
986
987 for section in &self.spec.sections {
988 for field_info in §ion.fields {
989 if let Some(resolved) = &field_info.resolved_type {
990 let type_name =
991 self.resolved_type_to_interface_name_with_map(resolved, &resolved_name_map);
992
993 if !generated_types.insert(type_name.clone()) || resolved.is_enum {
994 continue;
995 }
996
997 let schema = self.generate_schema_for_fields(
998 &type_name,
999 &self.resolved_fields_to_typescript_fields(&resolved.fields),
1000 false,
1001 SchemaMode::Patch,
1002 patch_schema_types,
1003 );
1004 schemas.push((format!("{}PatchSchema", type_name), schema));
1005 }
1006 }
1007 }
1008
1009 schemas
1010 }
1011
1012 fn generate_event_schemas(&self) -> Vec<(String, String)> {
1013 let mut schemas = Vec::new();
1014 let mut generated_types = HashSet::new();
1015
1016 let handlers = match &self.handlers_json {
1017 Some(h) => h.as_array(),
1018 None => return schemas,
1019 };
1020
1021 let handlers_array = match handlers {
1022 Some(arr) => arr,
1023 None => return schemas,
1024 };
1025
1026 for handler in handlers_array {
1027 if let Some(mappings) = handler.get("mappings").and_then(|m| m.as_array()) {
1028 for mapping in mappings {
1029 if let Some(target_path) = mapping.get("target_path").and_then(|t| t.as_str()) {
1030 if target_path.contains(".events.") || target_path.starts_with("events.") {
1031 if let Some(source) = mapping.get("source") {
1032 if let Some(event_data) = self.extract_event_data(source) {
1033 if let Some(handler_source) = handler.get("source") {
1034 if let Some(instruction_name) =
1035 self.extract_instruction_name(handler_source)
1036 {
1037 let event_field_name =
1038 target_path.split('.').next_back().unwrap_or("");
1039 let interface_name = format!(
1040 "{}Event",
1041 to_pascal_case(event_field_name)
1042 );
1043
1044 if generated_types.insert(interface_name.clone()) {
1045 if let Some(schema) = self
1046 .generate_event_schema_from_idl(
1047 &interface_name,
1048 &instruction_name,
1049 &event_data,
1050 )
1051 {
1052 schemas.push((
1053 format!("{}Schema", interface_name),
1054 schema,
1055 ));
1056 }
1057 }
1058 }
1059 }
1060 }
1061 }
1062 }
1063 }
1064 }
1065 }
1066 }
1067
1068 schemas
1069 }
1070
1071 fn generate_event_schema_from_idl(
1072 &self,
1073 interface_name: &str,
1074 rust_instruction_name: &str,
1075 captured_fields: &[(String, Option<String>)],
1076 ) -> Option<String> {
1077 if captured_fields.is_empty() {
1078 return Some(format!(
1079 "export const {}Schema = z.object({{}});",
1080 interface_name
1081 ));
1082 }
1083
1084 let idl_value = self.idl.as_ref()?;
1085 let instructions = idl_value.get("instructions")?.as_array()?;
1086
1087 let instruction = self.find_instruction_in_idl(instructions, rust_instruction_name)?;
1088 let args = instruction.get("args")?.as_array()?;
1089
1090 let mut fields = Vec::new();
1091 for (field_name, transform) in captured_fields {
1092 for arg in args {
1093 if let Some(arg_name) = arg.get("name").and_then(|n| n.as_str()) {
1094 if arg_name == field_name {
1095 if let Some(arg_type) = arg.get("type") {
1096 let ts_type =
1097 self.idl_type_to_typescript(arg_type, transform.as_deref());
1098 fields.push(TypeScriptField::patch(
1099 field_name.to_string(),
1100 ts_type,
1101 false,
1102 ));
1103 }
1104 break;
1105 }
1106 }
1107 }
1108 }
1109
1110 Some(render_schema_from_ts_fields(interface_name, &fields, true))
1111 }
1112
1113 fn generate_idl_enum_artifacts(&self) -> Vec<(String, String, String)> {
1116 let definitions: Vec<IdlTypeDefSnapshot> = self
1117 .idl
1118 .as_ref()
1119 .and_then(|idl| idl.get("types"))
1120 .and_then(|types| types.as_array())
1121 .into_iter()
1122 .flatten()
1123 .filter_map(|def| serde_json::from_value(def.clone()).ok())
1124 .collect();
1125 let type_defs = definitions
1126 .iter()
1127 .map(|def| (def.name.clone(), def))
1128 .collect();
1129 let mut required = BTreeSet::new();
1130 for def in &definitions {
1131 if matches!(def.type_def, IdlTypeDefKindSnapshot::Enum { .. }) {
1132 collect_required_defined_types(
1133 &IdlTypeSnapshot::Defined(IdlDefinedTypeSnapshot {
1134 defined: IdlDefinedInnerSnapshot::Simple(def.name.clone()),
1135 }),
1136 &type_defs,
1137 &HashSet::new(),
1138 &mut required,
1139 );
1140 }
1141 }
1142 let name_map: BTreeMap<_, _> = required
1143 .iter()
1144 .map(|name| (name.clone(), to_pascal_case(name)))
1145 .collect();
1146 required
1147 .into_iter()
1148 .filter(|name| !self.already_emitted_types.contains(&name_map[name]))
1149 .filter_map(|name| {
1150 let mut def = (*type_defs.get(&name)?).clone();
1151 if let IdlTypeDefKindSnapshot::Enum { variants, .. } = &mut def.type_def {
1152 for variant in variants {
1153 variant.name = to_pascal_case(&variant.name);
1154 }
1155 }
1156 generate_type_defs_from_idl_type(&def, &name_map)
1157 })
1158 .collect()
1159 }
1160
1161 fn generate_idl_enum_schemas(&self) -> Vec<(String, String)> {
1162 self.generate_idl_enum_artifacts()
1163 .into_iter()
1164 .map(|(_, name, schema)| (name, schema))
1165 .collect()
1166 }
1167
1168 fn typescript_type_to_zod_for_schema(
1169 &self,
1170 ts_type: &str,
1171 mode: SchemaMode,
1172 patch_schema_types: &HashSet<String>,
1173 ) -> String {
1174 typescript_type_to_zod_for_schema_static(ts_type, mode, patch_schema_types)
1175 }
1176
1177 fn patch_schema_type_names(&self) -> HashSet<String> {
1178 let mut names = HashSet::new();
1179 let resolved_name_map = self.build_resolved_type_name_map();
1180
1181 for section in &self.spec.sections {
1182 if !is_root_section(§ion.name) && section.fields.iter().any(|field| field.emit) {
1183 names.insert(self.section_interface_name(§ion.name));
1184 }
1185
1186 for field in §ion.fields {
1187 let Some(resolved) = &field.resolved_type else {
1188 continue;
1189 };
1190
1191 if resolved.is_enum {
1192 continue;
1193 }
1194
1195 names.insert(
1196 self.resolved_type_to_interface_name_with_map(resolved, &resolved_name_map),
1197 );
1198 }
1199 }
1200
1201 names.insert("TokenMetadata".to_string());
1202 names
1203 }
1204
1205 fn generate_stack_definition(&self, state_view_key: &StateViewKeyDefinition) -> String {
1206 let stack_name = to_kebab_case(&self.entity_name);
1207 let entity_pascal = to_pascal_case(&self.entity_name);
1208 let export_name = format!(
1209 "{}_{}",
1210 self.entity_name.to_uppercase(),
1211 self.config.export_const_name
1212 );
1213
1214 let view_helpers = self.generate_view_helpers();
1215 let derived_views = self.generate_derived_view_entries();
1216 let schema_names = self.generate_schemas().names;
1217 let mut unique_schemas: BTreeSet<String> = BTreeSet::new();
1218 for name in schema_names {
1219 unique_schemas.insert(name);
1220 }
1221 let schemas_block = if unique_schemas.is_empty() {
1222 String::new()
1223 } else {
1224 let schema_entries: Vec<String> = unique_schemas
1225 .iter()
1226 .filter(|name| name.ends_with("Schema") && !name.ends_with("PatchSchema"))
1227 .map(|name| format!(" {}: {},", name.trim_end_matches("Schema"), name))
1228 .collect();
1229 if schema_entries.is_empty() {
1230 String::new()
1231 } else {
1232 format!("\n schemas: {{\n{}\n }},", schema_entries.join("\n"))
1233 }
1234 };
1235
1236 let patch_schemas_block = format!(
1237 "\n patchSchemas: {{\n {entity}: {entity}PatchSchema,\n }},",
1238 entity = entity_pascal
1239 );
1240
1241 let url_line = match &self.config.url {
1243 Some(url) => format!(" url: '{}',", url),
1244 None => " url: '', // TODO: Set after first deployment or pass useArete(..., { url })"
1245 .to_string(),
1246 };
1247
1248 format!(
1249 r#"{}
1250
1251// ============================================================================
1252// Stack Definition
1253// ============================================================================
1254
1255/** Stack definition for {} */
1256export const {} = {{
1257 name: '{}',
1258{}
1259 views: {{
1260 {}: {{
1261 state: stateView<{}, {}>('{}/state', {}),
1262 list: listView<{}>('{}/list'),{}
1263 }},
1264 }},{}{}
1265}} as const;
1266
1267/** Type alias for the stack */
1268export type {}Stack = typeof {};
1269
1270/** Default export for convenience */
1271export default {};"#,
1272 view_helpers,
1273 entity_pascal,
1274 export_name,
1275 stack_name,
1276 url_line,
1277 self.entity_name,
1278 entity_pascal,
1279 state_view_key.object_type(),
1280 self.entity_name,
1281 state_view_key.fields_literal(),
1282 entity_pascal,
1283 self.entity_name,
1284 derived_views,
1285 schemas_block,
1286 patch_schemas_block,
1287 entity_pascal,
1288 export_name,
1289 export_name
1290 )
1291 }
1292
1293 fn generate_derived_view_entries(&self) -> String {
1294 let derived_views: Vec<&ViewDef> = self
1295 .views
1296 .iter()
1297 .filter(|v| {
1298 !v.id.ends_with("/state")
1299 && !v.id.ends_with("/list")
1300 && v.id.starts_with(&self.entity_name)
1301 })
1302 .collect();
1303
1304 if derived_views.is_empty() {
1305 return String::new();
1306 }
1307
1308 let entity_pascal = to_pascal_case(&self.entity_name);
1309 let mut entries = Vec::new();
1310
1311 for view in derived_views {
1312 let view_name = view.id.split('/').nth(1).unwrap_or("unknown");
1313
1314 entries.push(format!(
1315 "\n {}: listView<{}>('{}'),",
1316 view_name, entity_pascal, view.id
1317 ));
1318 }
1319
1320 entries.join("")
1321 }
1322
1323 fn mapping_to_typescript_type(&self, mapping: &TypedFieldMapping<S>) -> String {
1324 if let Some(field_info) = self.spec.field_mappings.get(&mapping.target_path) {
1326 let ts_type = self.field_type_info_to_typescript(field_info);
1327
1328 if matches!(mapping.population, PopulationStrategy::Append) {
1330 return if ts_type.ends_with("[]") {
1331 ts_type
1332 } else {
1333 format!("{}[]", ts_type)
1334 };
1335 }
1336
1337 return ts_type;
1338 }
1339
1340 match &mapping.population {
1342 PopulationStrategy::Append => {
1343 match &mapping.source {
1345 MappingSource::AsEvent { .. } => "any[]".to_string(),
1346 _ => "any[]".to_string(),
1347 }
1348 }
1349 _ => {
1350 let base_type = match &mapping.source {
1352 MappingSource::FromSource { .. } => {
1353 self.infer_type_from_field_name(&mapping.target_path)
1354 }
1355 MappingSource::Constant(value) => value_to_typescript_type(value),
1356 MappingSource::AsEvent { .. } => "any".to_string(),
1357 _ => "any".to_string(),
1358 };
1359
1360 if let Some(transform) = &mapping.transform {
1362 match transform {
1363 Transformation::HexEncode | Transformation::HexDecode => {
1364 "string".to_string()
1365 }
1366 Transformation::Base58Encode | Transformation::Base58Decode => {
1367 "string".to_string()
1368 }
1369 Transformation::ToString => "string".to_string(),
1370 Transformation::ToNumber => "number".to_string(),
1371 }
1372 } else {
1373 base_type
1374 }
1375 }
1376 }
1377 }
1378
1379 fn field_type_info_to_typescript(&self, field_info: &FieldTypeInfo) -> String {
1380 if let Some(resolved) = &field_info.resolved_type {
1381 let interface_name = self.resolved_type_to_interface_name(resolved);
1382
1383 let base_type = if resolved.is_event || (resolved.is_instruction && field_info.is_array)
1384 {
1385 format!("EventWrapper<{}>", interface_name)
1386 } else if resolved.is_account && self.is_capture_field(field_info) {
1387 format!("CaptureWrapper<{}>", interface_name)
1388 } else {
1389 interface_name
1390 };
1391
1392 let with_array = if field_info.is_array {
1393 format!("{}[]", base_type)
1394 } else {
1395 base_type
1396 };
1397
1398 return with_array;
1399 }
1400
1401 if let Some(inner_type) = &field_info.inner_type {
1402 if is_builtin_resolver_type(inner_type) {
1403 return inner_type.clone();
1404 }
1405 }
1406
1407 if let Some(ts_type) = typescript_integer_type(
1408 field_info.effective_integer_kind(),
1409 field_info
1410 .inner_type
1411 .as_deref()
1412 .or(Some(field_info.rust_type_name.as_str())),
1413 ) {
1414 return if field_info.is_array {
1415 format!("{}[]", ts_type)
1416 } else {
1417 ts_type.to_string()
1418 };
1419 }
1420
1421 if field_info.base_type == BaseType::Array && field_info.is_array {
1424 if let Some(element) = field_info
1425 .inner_type
1426 .as_deref()
1427 .and_then(typescript_scalar_array_element)
1428 {
1429 return format!("{}[]", element);
1430 }
1431 }
1432
1433 if field_info.base_type == BaseType::Any
1434 || (field_info.base_type == BaseType::Array
1435 && field_info.inner_type.as_deref() == Some("Value"))
1436 {
1437 if let Some(event_type) = self.find_event_interface_for_field(&field_info.field_name) {
1438 return if field_info.is_array {
1439 format!("{}[]", event_type)
1440 } else {
1441 event_type
1442 };
1443 }
1444 }
1445
1446 self.base_type_to_typescript(
1447 &field_info.base_type,
1448 field_info.effective_integer_kind(),
1449 field_info.is_array,
1450 )
1451 }
1452
1453 fn find_event_interface_for_field(&self, field_name: &str) -> Option<String> {
1455 let handlers = self.handlers_json.as_ref()?.as_array()?;
1457
1458 for handler in handlers {
1460 if let Some(mappings) = handler.get("mappings").and_then(|m| m.as_array()) {
1461 for mapping in mappings {
1462 if let Some(target_path) = mapping.get("target_path").and_then(|t| t.as_str()) {
1463 let target_parts: Vec<&str> = target_path.split('.').collect();
1465 if let Some(target_field) = target_parts.last() {
1466 if *target_field == field_name {
1467 if let Some(source) = mapping.get("source") {
1469 if self.extract_event_data(source).is_some() {
1470 return Some(format!(
1472 "{}Event",
1473 to_pascal_case(field_name)
1474 ));
1475 }
1476 }
1477 }
1478 }
1479 }
1480 }
1481 }
1482 }
1483 None
1484 }
1485
1486 fn resolved_type_to_interface_name(&self, resolved: &ResolvedStructType) -> String {
1488 self.build_resolved_type_name_map()
1489 .get(&resolved.type_name)
1490 .cloned()
1491 .unwrap_or_else(|| to_pascal_case(&resolved.type_name))
1492 }
1493
1494 fn generate_nested_interfaces(&self) -> Vec<String> {
1496 let mut interfaces = Vec::new();
1497 let resolved_type_names = self.resolved_type_names();
1498 let mut generated_types = self.already_emitted_types.clone();
1500 generated_types.extend(resolved_type_names.shared);
1501 let resolved_name_map = resolved_type_names.names;
1502
1503 for section in &self.spec.sections {
1505 for field_info in §ion.fields {
1506 if let Some(resolved) = &field_info.resolved_type {
1507 let type_name =
1508 self.resolved_type_to_interface_name_with_map(resolved, &resolved_name_map);
1509
1510 if generated_types.insert(type_name) {
1512 let interface = self.generate_interface_for_resolved_type(resolved);
1513 interfaces.push(interface);
1514 }
1515 }
1516 }
1517 }
1518
1519 interfaces.extend(self.generate_event_interfaces(&mut generated_types));
1521
1522 for (interface, schema_name, _) in self.generate_idl_enum_artifacts() {
1523 let type_name = schema_name.trim_end_matches("Schema");
1524 if generated_types.insert(type_name.to_string()) {
1525 interfaces.push(interface);
1526 }
1527 }
1528
1529 interfaces
1530 }
1531
1532 fn generate_event_interfaces(&self, generated_types: &mut HashSet<String>) -> Vec<String> {
1534 let mut interfaces = Vec::new();
1535
1536 let handlers = match &self.handlers_json {
1538 Some(h) => h.as_array(),
1539 None => return interfaces,
1540 };
1541
1542 let handlers_array = match handlers {
1543 Some(arr) => arr,
1544 None => return interfaces,
1545 };
1546
1547 for handler in handlers_array {
1549 if let Some(mappings) = handler.get("mappings").and_then(|m| m.as_array()) {
1551 for mapping in mappings {
1552 if let Some(target_path) = mapping.get("target_path").and_then(|t| t.as_str()) {
1553 if target_path.contains(".events.") || target_path.starts_with("events.") {
1555 if let Some(source) = mapping.get("source") {
1557 if let Some(event_data) = self.extract_event_data(source) {
1558 if let Some(handler_source) = handler.get("source") {
1560 if let Some(instruction_name) =
1561 self.extract_instruction_name(handler_source)
1562 {
1563 let event_field_name =
1565 target_path.split('.').next_back().unwrap_or("");
1566 let interface_name = format!(
1567 "{}Event",
1568 to_pascal_case(event_field_name)
1569 );
1570
1571 if generated_types.insert(interface_name.clone()) {
1573 if let Some(interface) = self
1574 .generate_event_interface_from_idl(
1575 &interface_name,
1576 &instruction_name,
1577 &event_data,
1578 )
1579 {
1580 interfaces.push(interface);
1581 }
1582 }
1583 }
1584 }
1585 }
1586 }
1587 }
1588 }
1589 }
1590 }
1591 }
1592
1593 interfaces
1594 }
1595
1596 fn extract_event_data(
1598 &self,
1599 source: &serde_json::Value,
1600 ) -> Option<Vec<(String, Option<String>)>> {
1601 if let Some(as_event) = source.get("AsEvent") {
1602 if let Some(fields) = as_event.get("fields").and_then(|f| f.as_array()) {
1603 let mut event_fields = Vec::new();
1604 for field in fields {
1605 if let Some(from_source) = field.get("FromSource") {
1606 if let Some(path) = from_source
1607 .get("path")
1608 .and_then(|p| p.get("segments"))
1609 .and_then(|s| s.as_array())
1610 {
1611 if let Some(field_name) = path.last().and_then(|v| v.as_str()) {
1613 let transform = from_source
1614 .get("transform")
1615 .and_then(|t| t.as_str())
1616 .map(|s| s.to_string());
1617 event_fields.push((field_name.to_string(), transform));
1618 }
1619 }
1620 }
1621 }
1622 return Some(event_fields);
1623 }
1624 }
1625 None
1626 }
1627
1628 fn extract_instruction_name(&self, source: &serde_json::Value) -> Option<String> {
1630 if let Some(source_obj) = source.get("Source") {
1631 if let Some(type_name) = source_obj.get("type_name").and_then(|t| t.as_str()) {
1632 let instruction_part =
1633 crate::event_type_helpers::strip_event_type_suffix(type_name);
1634 return Some(instruction_part.to_string());
1635 }
1636 }
1637 None
1638 }
1639
1640 fn find_instruction_in_idl<'a>(
1644 &self,
1645 instructions: &'a [serde_json::Value],
1646 rust_name: &str,
1647 ) -> Option<&'a serde_json::Value> {
1648 let normalized_search = normalize_for_comparison(rust_name);
1649
1650 for instruction in instructions {
1651 if let Some(idl_name) = instruction.get("name").and_then(|n| n.as_str()) {
1652 if normalize_for_comparison(idl_name) == normalized_search {
1653 return Some(instruction);
1654 }
1655 }
1656 }
1657 None
1658 }
1659
1660 fn generate_event_interface_from_idl(
1662 &self,
1663 interface_name: &str,
1664 rust_instruction_name: &str,
1665 captured_fields: &[(String, Option<String>)],
1666 ) -> Option<String> {
1667 if captured_fields.is_empty() {
1668 return Some(format!("export interface {} {{}}", interface_name));
1669 }
1670
1671 let idl_value = self.idl.as_ref()?;
1672 let instructions = idl_value.get("instructions")?.as_array()?;
1673
1674 let instruction = self.find_instruction_in_idl(instructions, rust_instruction_name)?;
1675 let args = instruction.get("args")?.as_array()?;
1676
1677 let mut fields = Vec::new();
1678 for (field_name, transform) in captured_fields {
1679 for arg in args {
1680 if let Some(arg_name) = arg.get("name").and_then(|n| n.as_str()) {
1681 if arg_name == field_name {
1682 if let Some(arg_type) = arg.get("type") {
1683 let ts_type =
1684 self.idl_type_to_typescript(arg_type, transform.as_deref());
1685 fields.push(TypeScriptField::patch(
1686 field_name.to_string(),
1687 ts_type,
1688 false,
1689 ));
1690 }
1691 break;
1692 }
1693 }
1694 }
1695 }
1696
1697 if !fields.is_empty() {
1698 return Some(render_interface_from_ts_fields(
1699 interface_name,
1700 &fields,
1701 true,
1702 ));
1703 }
1704
1705 None
1706 }
1707
1708 fn idl_type_to_typescript(
1710 &self,
1711 idl_type: &serde_json::Value,
1712 transform: Option<&str>,
1713 ) -> String {
1714 #![allow(clippy::only_used_in_recursion)]
1715 if transform == Some("HexEncode") {
1717 return "string".to_string();
1718 }
1719
1720 if let Some(type_str) = idl_type.as_str() {
1722 return match type_str {
1723 "u64" | "u128" | "i64" | "i128" => "bigint".to_string(),
1724 "u8" | "u16" | "u32" | "i8" | "i16" | "i32" => "number".to_string(),
1725 "f32" | "f64" => "number".to_string(),
1726 "bool" => "boolean".to_string(),
1727 "string" => "string".to_string(),
1728 "pubkey" | "publicKey" => "string".to_string(),
1729 "bytes" => "string".to_string(),
1730 _ => "any".to_string(),
1731 };
1732 }
1733
1734 if let Some(type_obj) = idl_type.as_object() {
1736 if let Some(option_type) = type_obj.get("option") {
1737 let inner = self.idl_type_to_typescript(option_type, None);
1738 return format!("{} | null", inner);
1739 }
1740 if let Some(vec_type) = type_obj.get("vec") {
1741 let inner = self.idl_type_to_typescript(vec_type, None);
1742 return format!("{}[]", inner);
1743 }
1744 }
1745
1746 "any".to_string()
1747 }
1748
1749 fn generate_interface_for_resolved_type(&self, resolved: &ResolvedStructType) -> String {
1751 let interface_name = self.resolved_type_to_interface_name(resolved);
1752
1753 if resolved.is_enum {
1755 let variants: Vec<String> = resolved
1756 .enum_variants
1757 .iter()
1758 .map(|v| format!("\"{}\"", to_pascal_case(v)))
1759 .collect();
1760
1761 return format!("export type {} = {};", interface_name, variants.join(" | "));
1762 }
1763
1764 render_interface_from_ts_fields(
1765 &interface_name,
1766 &self.resolved_fields_to_typescript_fields(&resolved.fields),
1767 true,
1768 )
1769 }
1770
1771 fn resolved_field_to_typescript(&self, field: &ResolvedField) -> String {
1773 if let Some(ts_type) =
1774 typescript_integer_type(field.effective_integer_kind(), Some(&field.field_type))
1775 {
1776 return if field.is_array {
1777 format!("{}[]", ts_type)
1778 } else {
1779 ts_type.to_string()
1780 };
1781 }
1782 let base_ts =
1783 self.base_type_to_typescript(&field.base_type, field.effective_integer_kind(), false);
1784
1785 if field.is_array {
1786 format!("{}[]", base_ts)
1787 } else {
1788 base_ts
1789 }
1790 }
1791
1792 fn resolved_fields_to_typescript_fields(
1793 &self,
1794 fields: &[ResolvedField],
1795 ) -> Vec<TypeScriptField> {
1796 fields
1797 .iter()
1798 .map(|field| {
1799 TypeScriptField::from_names(
1800 field.raw_field_name().to_string(),
1801 field.canonical_field_name(),
1802 self.resolved_field_to_typescript(field),
1803 field.is_optional,
1804 FieldPresence::Patch,
1805 )
1806 })
1807 .collect()
1808 }
1809
1810 fn has_event_types(&self) -> bool {
1812 for section in &self.spec.sections {
1813 for field_info in §ion.fields {
1814 if let Some(resolved) = &field_info.resolved_type {
1815 if resolved.is_event || (resolved.is_instruction && field_info.is_array) {
1816 return true;
1817 }
1818 }
1819 }
1820 }
1821 false
1822 }
1823
1824 fn has_capture_types(&self) -> bool {
1825 self.spec
1826 .sections
1827 .iter()
1828 .flat_map(|section| §ion.fields)
1829 .any(|field| self.is_capture_field(field))
1830 }
1831
1832 fn should_emit_capture_wrapper(&self) -> bool {
1833 self.has_capture_types() && !self.already_emitted_types.contains("CaptureWrapper")
1834 }
1835
1836 fn is_capture_field(&self, field_info: &FieldTypeInfo) -> bool {
1837 let raw_name = field_info.raw_field_name();
1838 self.spec.handlers.iter().any(|handler| {
1839 handler.mappings.iter().any(|mapping| {
1840 matches!(&mapping.source, MappingSource::AsCapture { .. })
1841 && (mapping.target_path == field_info.field_name
1842 || mapping.target_path == raw_name)
1843 })
1844 })
1845 }
1846
1847 fn build_resolved_type_name_map(&self) -> HashMap<String, String> {
1848 self.resolved_type_names().names
1849 }
1850
1851 fn resolved_type_names(&self) -> ResolvedTypeNames {
1852 let mut reserved_names = self.already_emitted_types.clone();
1853 reserved_names.insert(to_pascal_case(&self.entity_name));
1854
1855 for section in &self.spec.sections {
1856 if !is_root_section(§ion.name) && section.fields.iter().any(|field| field.emit) {
1857 reserved_names.insert(self.section_interface_name(§ion.name));
1858 }
1859 }
1860
1861 let namespaces = self.resolved_type_namespaces();
1862 let mut plan = ResolvedTypeNames {
1863 names: HashMap::new(),
1864 shared: HashSet::new(),
1865 };
1866
1867 for section in &self.spec.sections {
1868 for field_info in §ion.fields {
1869 if !field_info.emit {
1870 continue;
1871 }
1872
1873 let Some(resolved) = &field_info.resolved_type else {
1874 continue;
1875 };
1876
1877 if plan.names.contains_key(&resolved.type_name) {
1878 continue;
1879 }
1880
1881 let emitted_name = unique_resolved_type_name_ts(resolved, &mut reserved_names);
1882 let claim = self.stack_types.claim(
1883 resolved,
1884 emitted_name,
1885 &to_pascal_case(&resolved.type_name),
1886 &namespaces,
1887 &mut reserved_names,
1888 );
1889 plan.record(resolved, claim);
1890 }
1891 }
1892
1893 for section in &self.spec.sections {
1896 for field_info in §ion.fields {
1897 let Some(resolved) = &field_info.resolved_type else {
1898 continue;
1899 };
1900 let plain_name = to_pascal_case(&resolved.type_name);
1901 if field_info.emit
1902 || plan.names.contains_key(&resolved.type_name)
1903 || !self.stack_types.is_declared(&plain_name)
1904 {
1905 continue;
1906 }
1907 let claim = self.stack_types.claim(
1908 resolved,
1909 plain_name.clone(),
1910 &plain_name,
1911 &namespaces,
1912 &mut reserved_names,
1913 );
1914 plan.record(resolved, claim);
1915 }
1916 }
1917
1918 plan
1919 }
1920
1921 fn resolved_type_namespaces(&self) -> Vec<String> {
1922 let program_name = self.program_name.as_deref().or_else(|| {
1923 self.idl
1924 .as_ref()
1925 .and_then(|idl| idl.get("name"))
1926 .and_then(|name| name.as_str())
1927 });
1928 resolved_type_namespaces(program_name, &self.entity_name)
1929 }
1930
1931 fn declared_resolved_types(&self) -> Vec<(String, ResolvedStructType)> {
1934 let plan = self.resolved_type_names();
1935 let mut declared = Vec::new();
1936 let mut seen = HashSet::new();
1937 for section in &self.spec.sections {
1938 for field_info in §ion.fields {
1939 let Some(resolved) = &field_info.resolved_type else {
1940 continue;
1941 };
1942 let name = self.resolved_type_to_interface_name_with_map(resolved, &plan.names);
1943 if !plan.shared.contains(&name) && seen.insert(name.clone()) {
1944 declared.push((name, resolved.clone()));
1945 }
1946 }
1947 }
1948 declared
1949 }
1950
1951 fn resolved_type_to_interface_name_with_map(
1952 &self,
1953 resolved: &ResolvedStructType,
1954 resolved_name_map: &HashMap<String, String>,
1955 ) -> String {
1956 resolved_name_map
1957 .get(&resolved.type_name)
1958 .cloned()
1959 .unwrap_or_else(|| to_pascal_case(&resolved.type_name))
1960 }
1961
1962 fn generate_event_wrapper_interface(&self) -> String {
1964 r#"/**
1965 * Wrapper for event data that includes context metadata.
1966 * Events are automatically wrapped in this structure at runtime.
1967 */
1968export interface EventWrapper<T> {
1969 /** Unix timestamp when the event was processed */
1970 timestamp: number;
1971 /** The event-specific data */
1972 data: T;
1973 /** Optional blockchain slot number */
1974 slot?: number;
1975 /** Optional transaction signature */
1976 signature?: string;
1977 /** Absolute log-line index; set only for events decoded from a log */
1978 event_index?: number;
1979 /** 0-based instruction path within the transaction (e.g. "0.1") */
1980 ix_path?: string;
1981}"#
1982 .to_string()
1983 }
1984
1985 fn generate_capture_wrapper_interface(&self) -> String {
1986 r#"/**
1987 * Wrapper for account data captured with context metadata.
1988 */
1989export interface CaptureWrapper<T> {
1990 /** Unix timestamp when the account was captured */
1991 timestamp: number;
1992 /** Base58 account address */
1993 accountAddress: string;
1994 /** Captured account data */
1995 data: T;
1996 /** Optional blockchain slot number */
1997 slot?: number;
1998 /** Optional transaction signature */
1999 signature?: string;
2000}"#
2001 .to_string()
2002 }
2003
2004 fn infer_type_from_field_name(&self, field_name: &str) -> String {
2005 let lower_name = field_name.to_lowercase();
2006
2007 if lower_name.contains("events.") {
2009 return "any".to_string();
2011 }
2012
2013 if lower_name.contains("id")
2015 || lower_name.contains("count")
2016 || lower_name.contains("number")
2017 || lower_name.contains("timestamp")
2018 || lower_name.contains("time")
2019 || lower_name.contains("at")
2020 || lower_name.contains("volume")
2021 || lower_name.contains("amount")
2022 || lower_name.contains("ev")
2023 || lower_name.contains("fee")
2024 || lower_name.contains("payout")
2025 || lower_name.contains("distributed")
2026 || lower_name.contains("claimable")
2027 || lower_name.contains("total")
2028 || lower_name.contains("rate")
2029 || lower_name.contains("ratio")
2030 || lower_name.contains("current")
2031 || lower_name.contains("state")
2032 {
2033 "number".to_string()
2034 } else if lower_name.contains("status")
2035 || lower_name.contains("hash")
2036 || lower_name.contains("address")
2037 || lower_name.contains("key")
2038 {
2039 "string".to_string()
2040 } else {
2041 "any".to_string()
2042 }
2043 }
2044
2045 fn is_field_nullable(&self, mapping: &TypedFieldMapping<S>) -> bool {
2046 match &mapping.source {
2049 MappingSource::Constant(_) => false,
2051 MappingSource::AsEvent { .. } => true,
2053 MappingSource::FromSource { .. } => true,
2055 _ => true,
2057 }
2058 }
2059
2060 fn base_type_to_typescript(
2062 &self,
2063 base_type: &BaseType,
2064 integer_kind: Option<IntegerKind>,
2065 is_array: bool,
2066 ) -> String {
2067 let base_ts_type = match base_type {
2068 BaseType::Integer => integer_kind
2069 .map(integer_kind_to_typescript)
2070 .unwrap_or("number"),
2071 BaseType::Float => "number",
2072 BaseType::String => "string",
2073 BaseType::Boolean => "boolean",
2074 BaseType::Timestamp => integer_kind
2075 .map(integer_kind_to_typescript)
2076 .unwrap_or("number"),
2077 BaseType::Binary => "string", BaseType::Pubkey => "string", BaseType::Array => "any[]", BaseType::Object => "Record<string, any>", BaseType::Any => "any",
2082 };
2083
2084 if is_array && !matches!(base_type, BaseType::Array) {
2085 format!("{}[]", base_ts_type)
2086 } else {
2087 base_ts_type.to_string()
2088 }
2089 }
2090}
2091
2092#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2093enum FieldPresence {
2094 Patch,
2095 Required,
2096}
2097
2098#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2099enum SchemaMode {
2100 Canonical,
2101 Patch,
2102}
2103
2104fn schema_constant_name(name: &str, mode: SchemaMode) -> String {
2105 match mode {
2106 SchemaMode::Canonical => format!("{}Schema", name),
2107 SchemaMode::Patch => format!("{}PatchSchema", name),
2108 }
2109}
2110
2111fn localize_section_field_names(
2112 section_name: &str,
2113 field_info: &FieldTypeInfo,
2114) -> (String, String) {
2115 let raw_name = field_info.raw_field_name();
2116 if is_root_section(section_name) {
2117 return (raw_name.to_string(), field_info.canonical_field_name());
2118 }
2119
2120 let prefix = format!("{}.", section_name);
2121 if let Some(local_raw_name) = raw_name.strip_prefix(&prefix) {
2122 return (local_raw_name.to_string(), to_camel_case(local_raw_name));
2123 }
2124
2125 (raw_name.to_string(), field_info.canonical_field_name())
2126}
2127
2128const BIGINT_FIELD_DOC: &str =
2130 "`bigint` (64-bit or wider): convert with `Number()`/`String()` before `JSON.stringify`.";
2131
2132#[derive(Debug, Clone)]
2134struct TypeScriptField {
2135 name: String,
2136 raw_name: String,
2137 ts_type: String,
2138 nullable: bool,
2139 presence: FieldPresence,
2140 zod_schema: Option<String>,
2141 description: Option<String>,
2143}
2144
2145impl TypeScriptField {
2146 fn patch(raw_name: String, ts_type: String, nullable: bool) -> Self {
2147 Self::from_names(
2148 raw_name.clone(),
2149 to_camel_case(&raw_name),
2150 ts_type,
2151 nullable,
2152 FieldPresence::Patch,
2153 )
2154 }
2155
2156 fn required_with_schema(
2157 raw_name: String,
2158 canonical_name: String,
2159 ts_type: String,
2160 nullable: bool,
2161 zod_schema: String,
2162 ) -> Self {
2163 let mut field = Self::from_names(
2164 raw_name,
2165 canonical_name,
2166 ts_type,
2167 nullable,
2168 FieldPresence::Required,
2169 );
2170 field.zod_schema = Some(zod_schema);
2171 field
2172 }
2173
2174 fn from_names(
2175 raw_name: String,
2176 canonical_name: String,
2177 ts_type: String,
2178 nullable: bool,
2179 presence: FieldPresence,
2180 ) -> Self {
2181 Self {
2182 name: canonical_name,
2183 raw_name,
2184 ts_type,
2185 nullable,
2186 presence,
2187 zod_schema: None,
2188 description: None,
2189 }
2190 }
2191
2192 fn rendered_ts_type(&self) -> String {
2193 if self.nullable {
2194 format!("{} | null", self.ts_type)
2195 } else {
2196 self.ts_type.clone()
2197 }
2198 }
2199}
2200
2201#[derive(Debug, Clone)]
2202struct SchemaOutput {
2203 definitions: String,
2204 names: Vec<String>,
2205}
2206
2207#[derive(Debug, Default)]
2208struct IdlAccountArtifacts {
2209 code: String,
2210 schema_names: Vec<String>,
2211 type_names: HashSet<String>,
2212 account_type_names: BTreeMap<(String, String), String>,
2213}
2214
2215fn value_to_typescript_type(value: &serde_json::Value) -> String {
2217 match value {
2218 serde_json::Value::Number(_) => "number".to_string(),
2219 serde_json::Value::String(_) => "string".to_string(),
2220 serde_json::Value::Bool(_) => "boolean".to_string(),
2221 serde_json::Value::Array(_) => "any[]".to_string(),
2222 serde_json::Value::Object(_) => "Record<string, any>".to_string(),
2223 serde_json::Value::Null => "null".to_string(),
2224 }
2225}
2226
2227fn extract_builtin_resolver_type_names(spec: &SerializableStreamSpec) -> HashSet<String> {
2228 let mut names = HashSet::new();
2229 let registry = crate::resolvers::builtin_resolver_registry();
2230 for resolver in registry.definitions() {
2231 let output_type = resolver.output_type();
2232 for section in &spec.sections {
2233 for field in §ion.fields {
2234 if field.inner_type.as_deref() == Some(output_type) {
2235 names.insert(output_type.to_string());
2236 }
2237 }
2238 }
2239 }
2240 names
2241}
2242
2243fn generate_idl_account_artifacts(
2244 idls: &[IdlSnapshot],
2245 reserved_type_names: &HashSet<String>,
2246) -> IdlAccountArtifacts {
2247 let mut used_type_names = IdlTypeNames::new(reserved_type_names);
2248 let mut emitted_type_names = HashSet::new();
2249 let mut declared_type_names = HashSet::new();
2250 let mut seen_schema_names = HashSet::new();
2251 let mut interface_blocks = Vec::new();
2252 let mut schema_blocks = Vec::new();
2253 let mut schema_names = Vec::new();
2254 let mut account_type_names = BTreeMap::new();
2255
2256 for idl in idls {
2257 let program_key = to_camel_case(&idl.name);
2258 let program_prefix = ts_ident::identifier_stem(&idl.name, IdentifierCase::Pascal);
2259 let type_defs: BTreeMap<String, &IdlTypeDefSnapshot> = idl
2260 .types
2261 .iter()
2262 .map(|type_def| (type_def.name.clone(), type_def))
2263 .collect();
2264 let account_names: HashSet<String> = idl
2265 .accounts
2266 .iter()
2267 .map(|account| account.name.clone())
2268 .collect();
2269 let mut local_name_map = BTreeMap::new();
2270
2271 for account in &idl.accounts {
2272 let unique_name = unique_idl_type_name(
2273 &account.name,
2274 &program_prefix,
2275 IDL_ACCOUNT_ROLE,
2276 &mut used_type_names,
2277 );
2278 emitted_type_names.insert(unique_name.clone());
2279 local_name_map.insert(account.name.clone(), unique_name.clone());
2280 account_type_names.insert((program_key.clone(), account.name.clone()), unique_name);
2281 }
2282
2283 let mut required_defined_types = BTreeSet::new();
2284 for account in &idl.accounts {
2285 for field in resolve_idl_account_fields(account, &type_defs) {
2286 collect_required_defined_types(
2287 &field.type_,
2288 &type_defs,
2289 &account_names,
2290 &mut required_defined_types,
2291 );
2292 }
2293 }
2294
2295 for type_name in &required_defined_types {
2296 if local_name_map.contains_key(type_name) {
2297 continue;
2298 }
2299 let unique_name = unique_idl_type_name(
2300 type_name,
2301 &program_prefix,
2302 IDL_TYPE_ROLE,
2303 &mut used_type_names,
2304 );
2305 emitted_type_names.insert(unique_name.clone());
2306 local_name_map.insert(type_name.clone(), unique_name);
2307 }
2308
2309 for type_name in &required_defined_types {
2310 if account_names.contains(type_name) {
2311 continue;
2312 }
2313 if let Some(type_def) = type_defs.get(type_name) {
2314 if let Some((interface_def, schema_name, schema_def)) =
2315 generate_type_defs_from_idl_type(type_def, &local_name_map)
2316 {
2317 declared_type_names.extend(local_name_map.get(type_name).cloned());
2318 interface_blocks.push(interface_def);
2319 if seen_schema_names.insert(schema_name.clone()) {
2320 schema_names.push(schema_name.clone());
2321 schema_blocks.push(schema_def);
2322 }
2323 }
2324 }
2325 }
2326
2327 for account in &idl.accounts {
2328 let Some(type_name) = local_name_map.get(&account.name) else {
2329 continue;
2330 };
2331 let account_fields = resolve_idl_account_fields(account, &type_defs);
2332 declared_type_names.insert(type_name.clone());
2333 interface_blocks.push(generate_interface_from_idl_fields(
2334 type_name,
2335 account_fields,
2336 &local_name_map,
2337 ));
2338 let schema_name = format!("{}Schema", type_name);
2339 if seen_schema_names.insert(schema_name.clone()) {
2340 schema_names.push(schema_name.clone());
2341 schema_blocks.push(generate_schema_from_idl_fields(
2342 type_name,
2343 account_fields,
2344 &local_name_map,
2345 ));
2346 }
2347 }
2348 }
2349
2350 let mut alias_blocks = Vec::new();
2354 for (legacy, canonical) in &used_type_names.aliases {
2355 if !declared_type_names.contains(canonical) {
2356 continue;
2357 }
2358 let mut block =
2359 format!("/** @deprecated Use {canonical}. */\nexport type {legacy} = {canonical};");
2360 let canonical_schema = format!("{canonical}Schema");
2361 let legacy_schema = format!("{legacy}Schema");
2362 if seen_schema_names.contains(&canonical_schema)
2363 && seen_schema_names.insert(legacy_schema.clone())
2364 {
2365 block.push_str(&format!(
2366 "\n/** @deprecated Use {canonical_schema}. */\nexport const {legacy_schema} = {canonical_schema};"
2367 ));
2368 schema_names.push(legacy_schema);
2369 }
2370 emitted_type_names.insert(legacy.clone());
2371 alias_blocks.push(block);
2372 }
2373 schema_blocks.extend(alias_blocks);
2374
2375 let code = if interface_blocks.is_empty() && schema_blocks.is_empty() {
2376 String::new()
2377 } else if schema_blocks.is_empty() {
2378 interface_blocks.join("\n\n")
2379 } else if interface_blocks.is_empty() {
2380 schema_blocks.join("\n\n")
2381 } else {
2382 format!(
2383 "{}\n\n{}",
2384 interface_blocks.join("\n\n"),
2385 schema_blocks.join("\n\n")
2386 )
2387 };
2388
2389 IdlAccountArtifacts {
2390 code,
2391 schema_names,
2392 type_names: emitted_type_names,
2393 account_type_names,
2394 }
2395}
2396
2397fn resolve_idl_account_fields<'a>(
2398 account: &'a IdlAccountSnapshot,
2399 type_defs: &'a BTreeMap<String, &'a IdlTypeDefSnapshot>,
2400) -> &'a [IdlFieldSnapshot] {
2401 if !account.fields.is_empty() {
2402 return &account.fields;
2403 }
2404
2405 let Some(type_def) = type_defs.get(&account.name) else {
2406 return &account.fields;
2407 };
2408
2409 match &type_def.type_def {
2410 IdlTypeDefKindSnapshot::Struct { fields, .. } => fields,
2411 _ => &account.fields,
2412 }
2413}
2414
2415const IDL_ACCOUNT_ROLE: &str = "Account";
2418const IDL_TYPE_ROLE: &str = "Type";
2419
2420struct IdlTypeNames {
2424 used: HashSet<String>,
2425 legacy: HashSet<String>,
2426 aliases: Vec<(String, String)>,
2428}
2429
2430impl IdlTypeNames {
2431 fn new(reserved: &HashSet<String>) -> Self {
2432 Self {
2433 used: reserved.clone(),
2434 legacy: reserved.clone(),
2435 aliases: Vec::new(),
2436 }
2437 }
2438}
2439
2440fn unique_idl_type_name(
2446 raw_name: &str,
2447 program_prefix: &str,
2448 role: &str,
2449 names: &mut IdlTypeNames,
2450) -> String {
2451 let legacy = numbered_idl_type_name(raw_name, program_prefix, &mut names.legacy);
2452 let base_name = to_pascal_case(raw_name);
2453 let prefixed = format!("{}{}", program_prefix, base_name);
2454 let role_named = format!("{prefixed}{role}");
2455 let canonical = [base_name, prefixed]
2456 .into_iter()
2457 .find(|candidate| names.used.insert(candidate.clone()))
2458 .unwrap_or_else(|| {
2459 (1..)
2460 .map(|index| match index {
2461 1 => role_named.clone(),
2462 index => format!("{role_named}{index}"),
2463 })
2464 .find(|candidate| names.used.insert(candidate.clone()))
2465 .expect("an unused numbered name exists")
2466 });
2467 if legacy != canonical && names.used.insert(legacy.clone()) {
2468 names.aliases.push((legacy, canonical.clone()));
2469 }
2470 canonical
2471}
2472
2473fn numbered_idl_type_name(
2475 raw_name: &str,
2476 program_prefix: &str,
2477 used_type_names: &mut HashSet<String>,
2478) -> String {
2479 let base_name = to_pascal_case(raw_name);
2480 if used_type_names.insert(base_name.clone()) {
2481 return base_name;
2482 }
2483
2484 let prefixed = format!("{}{}", program_prefix, base_name);
2485 if used_type_names.insert(prefixed.clone()) {
2486 return prefixed;
2487 }
2488
2489 let mut index = 2;
2490 loop {
2491 let candidate = format!("{}{}", prefixed, index);
2492 if used_type_names.insert(candidate.clone()) {
2493 return candidate;
2494 }
2495 index += 1;
2496 }
2497}
2498
2499fn collect_required_defined_types(
2500 idl_type: &IdlTypeSnapshot,
2501 type_defs: &BTreeMap<String, &IdlTypeDefSnapshot>,
2502 account_names: &HashSet<String>,
2503 output: &mut BTreeSet<String>,
2504) {
2505 match idl_type {
2506 IdlTypeSnapshot::Simple(_) => {}
2507 IdlTypeSnapshot::Array(array_type) => {
2508 for element in &array_type.array {
2509 if let IdlArrayElementSnapshot::Type(inner) = element {
2510 collect_required_defined_types(inner, type_defs, account_names, output);
2511 }
2512 }
2513 }
2514 IdlTypeSnapshot::Option(option_type) => {
2515 collect_required_defined_types(&option_type.option, type_defs, account_names, output);
2516 }
2517 IdlTypeSnapshot::Vec(vec_type) => {
2518 collect_required_defined_types(&vec_type.vec, type_defs, account_names, output);
2519 }
2520 IdlTypeSnapshot::HashMap(hash_map_type) => {
2521 collect_required_defined_types(
2522 &hash_map_type.hash_map.0,
2523 type_defs,
2524 account_names,
2525 output,
2526 );
2527 collect_required_defined_types(
2528 &hash_map_type.hash_map.1,
2529 type_defs,
2530 account_names,
2531 output,
2532 );
2533 }
2534 IdlTypeSnapshot::Tuple(tuple_type) => {
2535 for element in &tuple_type.tuple {
2536 collect_required_defined_types(element, type_defs, account_names, output);
2537 }
2538 }
2539 IdlTypeSnapshot::Defined(defined_type) => {
2540 let type_name = match &defined_type.defined {
2541 IdlDefinedInnerSnapshot::Named { name } => name,
2542 IdlDefinedInnerSnapshot::Simple(name) => name,
2543 };
2544
2545 if !output.insert(type_name.clone()) {
2546 return;
2547 }
2548
2549 if account_names.contains(type_name) {
2550 return;
2551 }
2552
2553 let Some(type_def) = type_defs.get(type_name) else {
2554 return;
2555 };
2556
2557 match &type_def.type_def {
2558 IdlTypeDefKindSnapshot::Struct { fields, .. } => {
2559 for field in fields {
2560 collect_required_defined_types(
2561 &field.type_,
2562 type_defs,
2563 account_names,
2564 output,
2565 );
2566 }
2567 }
2568 IdlTypeDefKindSnapshot::TupleStruct { fields, .. } => {
2569 for field in fields {
2570 collect_required_defined_types(field, type_defs, account_names, output);
2571 }
2572 }
2573 IdlTypeDefKindSnapshot::Enum { variants, .. } => {
2574 for variant in variants {
2575 for field in &variant.fields {
2576 match field {
2577 IdlEnumVariantFieldSnapshot::Named(named) => {
2578 collect_required_defined_types(
2579 &named.type_,
2580 type_defs,
2581 account_names,
2582 output,
2583 );
2584 }
2585 IdlEnumVariantFieldSnapshot::Tuple(tuple) => {
2586 collect_required_defined_types(
2587 tuple,
2588 type_defs,
2589 account_names,
2590 output,
2591 );
2592 }
2593 }
2594 }
2595 }
2596 }
2597 }
2598 }
2599 }
2600}
2601
2602fn generate_type_defs_from_idl_type(
2603 type_def: &IdlTypeDefSnapshot,
2604 local_name_map: &BTreeMap<String, String>,
2605) -> Option<(String, String, String)> {
2606 let type_name = local_name_map
2607 .get(&type_def.name)
2608 .cloned()
2609 .unwrap_or_else(|| to_pascal_case(&type_def.name));
2610 let schema_name = format!("{}Schema", type_name);
2611
2612 match &type_def.type_def {
2613 IdlTypeDefKindSnapshot::Struct { fields, .. } => Some((
2614 generate_interface_from_idl_fields(&type_name, fields, local_name_map),
2615 schema_name,
2616 generate_schema_from_idl_fields(&type_name, fields, local_name_map),
2617 )),
2618 IdlTypeDefKindSnapshot::TupleStruct { fields, .. } => {
2619 let interface = format!(
2620 "export type {} = [{}];",
2621 type_name,
2622 fields
2623 .iter()
2624 .map(|field| idl_snapshot_type_to_typescript(field, local_name_map))
2625 .collect::<Vec<_>>()
2626 .join(", ")
2627 );
2628 let schema = format!(
2629 "export const {} = z.tuple([{}]);",
2630 schema_name,
2631 fields
2632 .iter()
2633 .map(|field| idl_snapshot_type_to_zod(field, local_name_map))
2634 .collect::<Vec<_>>()
2635 .join(", ")
2636 );
2637 Some((interface, schema_name, schema))
2638 }
2639 IdlTypeDefKindSnapshot::Enum { variants, .. } => {
2640 if !variants.is_empty() && variants.iter().all(|variant| variant.fields.is_empty()) {
2641 let labels = variants
2642 .iter()
2643 .map(|variant| serde_json::to_string(&variant.name).expect("variant label"))
2644 .collect::<Vec<_>>();
2645 return Some((
2646 format!("export type {type_name} = {};", labels.join(" | ")),
2647 schema_name.clone(),
2648 format!(
2649 "export const {schema_name} = z.enum([{}]);",
2650 labels.join(", ")
2651 ),
2652 ));
2653 }
2654 let mut type_variants = Vec::new();
2655 let mut schema_variants = Vec::new();
2656 for variant in variants {
2657 let label = serde_json::to_string(&variant.name).expect("variant label");
2658 if variant.fields.is_empty() {
2659 type_variants.push(label.clone());
2660 schema_variants.push(format!("z.literal({label})"));
2661 continue;
2662 }
2663 let named: Vec<_> = variant
2664 .fields
2665 .iter()
2666 .filter_map(|field| match field {
2667 IdlEnumVariantFieldSnapshot::Named(field) => Some(field),
2668 _ => None,
2669 })
2670 .collect();
2671 let (payload_type, payload_schema) = if named.len() == variant.fields.len() {
2672 let fields: Vec<_> = named.into_iter().cloned().collect();
2673 let fields = normalize_idl_fields(&fields, local_name_map);
2674 let ty = fields
2675 .iter()
2676 .map(|field| {
2677 format!(
2678 "{}: {}",
2679 field.name,
2680 if field.nullable {
2681 format!("{} | null", field.ts_type)
2682 } else {
2683 field.ts_type.clone()
2684 }
2685 )
2686 })
2687 .collect::<Vec<_>>()
2688 .join("; ");
2689 let declaration = render_schema_from_ts_fields("EnumPayload", &fields, true);
2690 let schema = declaration
2691 .strip_prefix("export const EnumPayloadSchema = ")
2692 .expect("schema declaration")
2693 .trim_end_matches(';')
2694 .to_string();
2695 (format!("{{ {ty} }}"), schema)
2696 } else if named.is_empty() {
2697 let types: Vec<_> = variant
2698 .fields
2699 .iter()
2700 .map(|field| match field {
2701 IdlEnumVariantFieldSnapshot::Tuple(ty) => ty,
2702 _ => unreachable!(),
2703 })
2704 .collect();
2705 if types.len() == 1 {
2706 (
2707 idl_snapshot_type_to_typescript(types[0], local_name_map),
2708 idl_snapshot_type_to_zod(types[0], local_name_map),
2709 )
2710 } else {
2711 (
2712 format!(
2713 "[{}]",
2714 types
2715 .iter()
2716 .map(|ty| idl_snapshot_type_to_typescript(ty, local_name_map))
2717 .collect::<Vec<_>>()
2718 .join(", ")
2719 ),
2720 format!(
2721 "z.tuple([{}])",
2722 types
2723 .iter()
2724 .map(|ty| idl_snapshot_type_to_zod(ty, local_name_map))
2725 .collect::<Vec<_>>()
2726 .join(", ")
2727 ),
2728 )
2729 }
2730 } else {
2731 type_variants.push("never /* unsupported mixed enum payload */".into());
2733 schema_variants.push("z.never()".into());
2734 continue;
2735 };
2736 type_variants.push(format!("{{ {label}: {payload_type} }}"));
2737 schema_variants.push(format!(
2738 "z.object({{ {label}: z.lazy(() => {payload_schema}) }})"
2739 ));
2740 }
2741 let interface = format!(
2742 "export type {} = {};",
2743 type_name,
2744 if type_variants.is_empty() {
2745 "never".into()
2746 } else {
2747 type_variants.join(" | ")
2748 }
2749 );
2750 let schema_expr = match schema_variants.len() {
2751 0 => "z.never()".into(),
2752 1 => schema_variants.remove(0),
2753 _ => format!("z.union([{}])", schema_variants.join(", ")),
2754 };
2755 Some((
2756 interface,
2757 schema_name.clone(),
2758 format!("export const {schema_name} = {schema_expr};"),
2759 ))
2760 }
2761 }
2762}
2763
2764fn generate_interface_from_idl_fields(
2765 name: &str,
2766 fields: &[IdlFieldSnapshot],
2767 local_name_map: &BTreeMap<String, String>,
2768) -> String {
2769 render_interface_from_ts_fields(name, &normalize_idl_fields(fields, local_name_map), true)
2770}
2771
2772fn generate_schema_from_idl_fields(
2773 name: &str,
2774 fields: &[IdlFieldSnapshot],
2775 local_name_map: &BTreeMap<String, String>,
2776) -> String {
2777 render_schema_from_ts_fields(name, &normalize_idl_fields(fields, local_name_map), true)
2778}
2779
2780fn normalize_idl_fields(
2781 fields: &[IdlFieldSnapshot],
2782 local_name_map: &BTreeMap<String, String>,
2783) -> Vec<TypeScriptField> {
2784 let canonical_names = canonical_idl_field_names(fields);
2785 let mut normalized = Vec::with_capacity(fields.len());
2786
2787 for (field, canonical_name) in fields.iter().zip(canonical_names) {
2788 let (normalized_type, nullable) = strip_nullable_idl_type(&field.type_);
2789
2790 normalized.push(TypeScriptField::required_with_schema(
2791 idl_field_wire_name(&field.name),
2792 canonical_name,
2793 idl_snapshot_type_to_typescript(normalized_type, local_name_map),
2794 nullable,
2795 idl_snapshot_type_to_zod(normalized_type, local_name_map),
2796 ));
2797 }
2798
2799 normalized
2800}
2801
2802fn canonical_idl_field_names(fields: &[IdlFieldSnapshot]) -> Vec<String> {
2810 let base_names = fields
2811 .iter()
2812 .map(|field| to_camel_case(&field.name))
2813 .collect::<Vec<_>>();
2814 let mut base_counts = BTreeMap::<String, usize>::new();
2815 for base_name in &base_names {
2816 *base_counts.entry(base_name.clone()).or_default() += 1;
2817 }
2818
2819 let mut used_names = HashSet::new();
2820 fields
2821 .iter()
2822 .zip(base_names)
2823 .map(|(field, base_name)| {
2824 let leading_underscores = field.name.chars().take_while(|ch| *ch == '_').count();
2825 let preferred = if base_counts.get(base_name.as_str()).copied().unwrap_or(0) > 1
2826 && leading_underscores > 0
2827 {
2828 format!("{}{}", "_".repeat(leading_underscores), base_name)
2829 } else {
2830 base_name
2831 };
2832
2833 if used_names.insert(preferred.clone()) {
2834 return preferred;
2835 }
2836
2837 let mut suffix = 2;
2838 loop {
2839 let candidate = format!("{}{}", preferred, suffix);
2840 if used_names.insert(candidate.clone()) {
2841 return candidate;
2842 }
2843 suffix += 1;
2844 }
2845 })
2846 .collect()
2847}
2848
2849fn idl_field_wire_name(field_name: &str) -> String {
2850 idl_to_snake_case(field_name)
2851}
2852
2853fn idl_snapshot_type_to_typescript(
2854 idl_type: &IdlTypeSnapshot,
2855 local_name_map: &BTreeMap<String, String>,
2856) -> String {
2857 match idl_type {
2858 IdlTypeSnapshot::Simple(type_name) => match type_name.as_str() {
2859 "u64" | "u128" | "i64" | "i128" => "bigint".to_string(),
2860 "u8" | "u16" | "u32" | "i8" | "i16" | "i32" | "f32" | "f64" => "number".to_string(),
2861 "bool" => "boolean".to_string(),
2862 "string" => "string".to_string(),
2863 "pubkey" | "publicKey" => "string".to_string(),
2864 "bytes" => "number[]".to_string(),
2865 _ => "any".to_string(),
2866 },
2867 IdlTypeSnapshot::Array(array_type) => {
2868 let (inner_ts, size) = match array_type.array.as_slice() {
2869 [IdlArrayElementSnapshot::Type(inner), IdlArrayElementSnapshot::Size(size)] => (
2870 Some(idl_snapshot_type_to_typescript(inner, local_name_map)),
2871 Some(*size),
2872 ),
2873 [IdlArrayElementSnapshot::TypeName(inner), IdlArrayElementSnapshot::Size(size)] => {
2874 (
2875 Some(idl_snapshot_type_to_typescript(
2876 &IdlTypeSnapshot::Simple(inner.clone()),
2877 local_name_map,
2878 )),
2879 Some(*size),
2880 )
2881 }
2882 _ => (None, None),
2883 };
2884 let inner_ts = inner_ts.unwrap_or_else(|| "any".to_string());
2885 if let Some(size) = size {
2886 format!(
2887 "{}[]",
2888 if size == 0 {
2889 "never".to_string()
2890 } else {
2891 inner_ts
2892 }
2893 )
2894 } else {
2895 format!("{}[]", inner_ts)
2896 }
2897 }
2898 IdlTypeSnapshot::Option(option_type) => {
2899 format!(
2900 "{} | null",
2901 idl_snapshot_type_to_typescript(&option_type.option, local_name_map)
2902 )
2903 }
2904 IdlTypeSnapshot::Vec(vec_type) => {
2905 format!(
2906 "{}[]",
2907 idl_snapshot_type_to_typescript(&vec_type.vec, local_name_map)
2908 )
2909 }
2910 IdlTypeSnapshot::HashMap(hash_map_type) => {
2911 format!(
2912 "Record<string, {}>",
2913 idl_snapshot_type_to_typescript(&hash_map_type.hash_map.1, local_name_map)
2914 )
2915 }
2916 IdlTypeSnapshot::Tuple(tuple_type) => {
2917 format!(
2918 "[{}]",
2919 tuple_type
2920 .tuple
2921 .iter()
2922 .map(|element| idl_snapshot_type_to_typescript(element, local_name_map))
2923 .collect::<Vec<_>>()
2924 .join(", ")
2925 )
2926 }
2927 IdlTypeSnapshot::Defined(defined_type) => {
2928 let type_name = match &defined_type.defined {
2929 IdlDefinedInnerSnapshot::Named { name } => name,
2930 IdlDefinedInnerSnapshot::Simple(name) => name,
2931 };
2932 local_name_map
2933 .get(type_name)
2934 .cloned()
2935 .unwrap_or_else(|| to_pascal_case(type_name))
2936 }
2937 }
2938}
2939
2940fn idl_snapshot_type_to_zod(
2941 idl_type: &IdlTypeSnapshot,
2942 local_name_map: &BTreeMap<String, String>,
2943) -> String {
2944 match idl_type {
2945 IdlTypeSnapshot::Simple(type_name) => match type_name.as_str() {
2946 "u64" | "u128" | "i64" | "i128" => bigint_zod(),
2947 "u8" | "u16" | "u32" | "i8" | "i16" | "i32" | "f32" | "f64" => "z.number()".to_string(),
2948 "bool" => "z.boolean()".to_string(),
2949 "string" => "z.string()".to_string(),
2950 "pubkey" | "publicKey" => "z.string()".to_string(),
2951 "bytes" => "z.array(z.number())".to_string(),
2952 _ => "z.any()".to_string(),
2953 },
2954 IdlTypeSnapshot::Array(array_type) => match array_type.array.as_slice() {
2955 [IdlArrayElementSnapshot::Type(inner), IdlArrayElementSnapshot::Size(size)] => {
2956 format!(
2957 "z.array({}).length({})",
2958 idl_snapshot_type_to_zod(inner, local_name_map),
2959 size
2960 )
2961 }
2962 [IdlArrayElementSnapshot::TypeName(inner), IdlArrayElementSnapshot::Size(size)] => {
2963 format!(
2964 "z.array({}).length({})",
2965 idl_snapshot_type_to_zod(
2966 &IdlTypeSnapshot::Simple(inner.clone()),
2967 local_name_map
2968 ),
2969 size
2970 )
2971 }
2972 _ => "z.array(z.any())".to_string(),
2973 },
2974 IdlTypeSnapshot::Option(option_type) => {
2975 format!(
2976 "{}.nullable()",
2977 idl_snapshot_type_to_zod(&option_type.option, local_name_map)
2978 )
2979 }
2980 IdlTypeSnapshot::Vec(vec_type) => {
2981 format!(
2982 "z.array({})",
2983 idl_snapshot_type_to_zod(&vec_type.vec, local_name_map)
2984 )
2985 }
2986 IdlTypeSnapshot::HashMap(hash_map_type) => {
2987 format!(
2988 "z.record({})",
2989 idl_snapshot_type_to_zod(&hash_map_type.hash_map.1, local_name_map)
2990 )
2991 }
2992 IdlTypeSnapshot::Tuple(tuple_type) => {
2993 format!(
2994 "z.tuple([{}])",
2995 tuple_type
2996 .tuple
2997 .iter()
2998 .map(|element| idl_snapshot_type_to_zod(element, local_name_map))
2999 .collect::<Vec<_>>()
3000 .join(", ")
3001 )
3002 }
3003 IdlTypeSnapshot::Defined(defined_type) => {
3004 let type_name = match &defined_type.defined {
3005 IdlDefinedInnerSnapshot::Named { name } => name,
3006 IdlDefinedInnerSnapshot::Simple(name) => name,
3007 };
3008 let resolved_name = local_name_map
3009 .get(type_name)
3010 .cloned()
3011 .unwrap_or_else(|| to_pascal_case(type_name));
3012 format!("z.lazy(() => {}Schema)", resolved_name)
3013 }
3014 }
3015}
3016
3017fn typescript_integer_type_from_rust(rust_type: &str) -> Option<&'static str> {
3018 IntegerKind::from_rust_type(rust_type).map(integer_kind_to_typescript)
3019}
3020
3021fn typescript_integer_type(
3022 integer_kind: Option<IntegerKind>,
3023 rust_type: Option<&str>,
3024) -> Option<&'static str> {
3025 integer_kind
3026 .map(integer_kind_to_typescript)
3027 .or_else(|| rust_type.and_then(typescript_integer_type_from_rust))
3028}
3029
3030fn typescript_scalar_array_element(inner_type: &str) -> Option<&'static str> {
3034 let trimmed = inner_type.trim();
3035 let element = trimmed
3036 .strip_prefix("Vec <")
3037 .and_then(|rest| rest.strip_suffix('>'))
3038 .or_else(|| {
3039 trimmed
3040 .strip_prefix("Vec<")
3041 .and_then(|rest| rest.strip_suffix('>'))
3042 })
3043 .map(str::trim)
3044 .unwrap_or(trimmed);
3045 match element {
3046 "f32" | "f64" => Some("number"),
3047 "bool" => Some("boolean"),
3048 "String" | "&str" | "str" => Some("string"),
3049 _ => None,
3050 }
3051}
3052
3053fn integer_kind_to_typescript(integer_kind: IntegerKind) -> &'static str {
3054 if integer_kind.is_bigint() {
3055 "bigint"
3056 } else {
3057 "number"
3058 }
3059}
3060
3061fn strip_nullable_idl_type(mut idl_type: &IdlTypeSnapshot) -> (&IdlTypeSnapshot, bool) {
3062 let mut nullable = false;
3063 while let IdlTypeSnapshot::Option(option_type) = idl_type {
3064 nullable = true;
3065 idl_type = &option_type.option;
3066 }
3067 (idl_type, nullable)
3068}
3069
3070fn typescript_type_to_zod_static(ts_type: &str) -> String {
3071 let trimmed = ts_type.trim();
3072
3073 if let Some(inner) = trimmed.strip_suffix("[]") {
3074 return format!("z.array({})", typescript_type_to_zod_static(inner));
3075 }
3076
3077 if let Some(inner) = trimmed.strip_prefix("EventWrapper<") {
3078 if let Some(inner) = inner.strip_suffix('>') {
3079 return format!(
3080 "EventWrapperSchema({})",
3081 typescript_type_to_zod_static(inner)
3082 );
3083 }
3084 }
3085
3086 if let Some(inner) = trimmed.strip_prefix("CaptureWrapper<") {
3087 if let Some(inner) = inner.strip_suffix('>') {
3088 return format!(
3089 "CaptureWrapperSchema({})",
3090 typescript_type_to_zod_static(inner)
3091 );
3092 }
3093 }
3094
3095 match trimmed {
3096 "string" => "z.string()".to_string(),
3097 "number" => "z.number()".to_string(),
3098 "bigint" => bigint_zod(),
3099 "boolean" => "z.boolean()".to_string(),
3100 "any" => "z.any()".to_string(),
3101 "Record<string, any>" => "z.record(z.any())".to_string(),
3102 _ => format!("{}Schema", trimmed),
3103 }
3104}
3105
3106fn typescript_type_to_zod_for_schema_static(
3107 ts_type: &str,
3108 mode: SchemaMode,
3109 patch_schema_types: &HashSet<String>,
3110) -> String {
3111 let trimmed = ts_type.trim();
3112
3113 if let Some(inner) = trimmed.strip_suffix("[]") {
3114 return format!(
3115 "z.array({})",
3116 typescript_type_to_zod_for_schema_static(inner, mode, patch_schema_types)
3117 );
3118 }
3119
3120 if let Some(inner) = trimmed.strip_prefix("EventWrapper<") {
3121 if let Some(inner) = inner.strip_suffix('>') {
3122 return format!(
3123 "EventWrapperSchema({})",
3124 typescript_type_to_zod_for_schema_static(inner, mode, patch_schema_types)
3125 );
3126 }
3127 }
3128
3129 if let Some(inner) = trimmed.strip_prefix("CaptureWrapper<") {
3130 if let Some(inner) = inner.strip_suffix('>') {
3131 return format!(
3132 "CaptureWrapperSchema({})",
3133 typescript_type_to_zod_for_schema_static(
3134 inner,
3135 SchemaMode::Canonical,
3136 patch_schema_types,
3137 )
3138 );
3139 }
3140 }
3141
3142 match trimmed {
3143 "string" => "z.string()".to_string(),
3144 "number" => "z.number()".to_string(),
3145 "bigint" => bigint_zod(),
3146 "boolean" => "z.boolean()".to_string(),
3147 "any" => "z.any()".to_string(),
3148 "Record<string, any>" => "z.record(z.any())".to_string(),
3149 _ => {
3150 if mode == SchemaMode::Patch && patch_schema_types.contains(trimmed) {
3151 format!("{}PatchSchema", trimmed)
3152 } else {
3153 format!("{}Schema", trimmed)
3154 }
3155 }
3156 }
3157}
3158
3159fn render_interface_from_ts_fields(
3160 name: &str,
3161 fields: &[TypeScriptField],
3162 force_required: bool,
3163) -> String {
3164 if fields.is_empty() {
3165 return format!("export interface {} {{\n}}", name);
3166 }
3167
3168 let field_definitions = fields
3169 .iter()
3170 .map(|field| {
3171 let optional = if force_required || matches!(field.presence, FieldPresence::Required) {
3172 ""
3173 } else {
3174 "?"
3175 };
3176 let doc = field
3177 .description
3178 .as_deref()
3179 .map(|doc| format!(" /** {} */\n", doc.replace("*/", "*\\/")))
3180 .unwrap_or_default();
3181 format!(
3182 "{doc} {}{}: {};",
3183 field.name,
3184 optional,
3185 field.rendered_ts_type()
3186 )
3187 })
3188 .collect::<Vec<_>>();
3189
3190 format!(
3191 "export interface {} {{\n{}\n}}",
3192 name,
3193 field_definitions.join("\n")
3194 )
3195}
3196
3197fn render_schema_from_ts_fields(
3198 name: &str,
3199 fields: &[TypeScriptField],
3200 force_required: bool,
3201) -> String {
3202 if fields.is_empty() {
3203 return format!("export const {}Schema = z.object({{}});", name);
3204 }
3205
3206 let field_definitions = fields
3207 .iter()
3208 .map(|field| {
3209 let base_schema = field
3210 .zod_schema
3211 .clone()
3212 .unwrap_or_else(|| typescript_type_to_zod_static(&field.ts_type));
3213 let with_nullable = if field.nullable {
3214 format!("{}.nullable()", base_schema)
3215 } else {
3216 base_schema
3217 };
3218 let schema = if force_required || matches!(field.presence, FieldPresence::Required) {
3219 with_nullable
3220 } else {
3221 format!("{}.optional()", with_nullable)
3222 };
3223 format!(" {}: {},", field.raw_name, schema)
3224 })
3225 .collect::<Vec<_>>();
3226
3227 let transform_fields = fields
3228 .iter()
3229 .map(|field| format!(" {}: value.{},", field.name, field.raw_name))
3230 .collect::<Vec<_>>();
3231
3232 format!(
3233 "export const {}Schema = z.object({{\n{}\n}}).transform((value) => ({{\n{}\n}}));",
3234 name,
3235 field_definitions.join("\n"),
3236 transform_fields.join("\n")
3237 )
3238}
3239
3240fn bigint_zod() -> String {
3241 "z.union([z.bigint(), z.string(), z.number().int()]).transform((value) => BigInt(value))"
3242 .to_string()
3243}
3244
3245fn extract_idl_enum_type_names(idl: &serde_json::Value) -> HashSet<String> {
3246 let mut names = HashSet::new();
3247 if let Some(types_array) = idl.get("types").and_then(|v| v.as_array()) {
3248 for type_def in types_array {
3249 if let (Some(type_name), Some(type_obj)) = (
3250 type_def.get("name").and_then(|v| v.as_str()),
3251 type_def.get("type").and_then(|v| v.as_object()),
3252 ) {
3253 if type_obj.get("kind").and_then(|v| v.as_str()) == Some("enum") {
3254 names.insert(to_pascal_case(type_name));
3255 }
3256 }
3257 }
3258 }
3259 names
3260}
3261
3262fn extract_emitted_enum_type_names(interfaces: &str, idl: Option<&IdlSnapshot>) -> HashSet<String> {
3265 let mut names = HashSet::new();
3266
3267 let idl_enum_names: HashSet<String> = idl
3269 .and_then(|idl| serde_json::to_value(idl).ok())
3270 .map(|v| extract_idl_enum_type_names(&v))
3271 .unwrap_or_default();
3272
3273 for line in interfaces.lines() {
3276 if let Some(start) = line.find("export const ") {
3277 let end = line
3278 .find("Schema = z.enum")
3279 .or_else(|| line.find("Schema = z.string()"))
3280 .or_else(|| line.find("Schema = "));
3281 if let Some(end) = end {
3282 let schema_name = line[start + 13..end].trim();
3283 if idl_enum_names.contains(schema_name) {
3285 names.insert(schema_name.to_string());
3286 }
3287 }
3288 }
3289 }
3290
3291 names
3292}
3293
3294fn unique_resolved_type_name_ts(
3295 resolved: &ResolvedStructType,
3296 reserved_names: &mut HashSet<String>,
3297) -> String {
3298 let base_name = to_pascal_case(&resolved.type_name);
3299 if reserved_names.insert(base_name.clone()) {
3300 return base_name;
3301 }
3302
3303 let suffix = if resolved.is_account {
3304 "Account"
3305 } else if resolved.is_event {
3306 "Event"
3307 } else if resolved.is_instruction {
3308 "Instruction"
3309 } else {
3310 "Type"
3311 };
3312
3313 let preferred = format!("{}{}", base_name, suffix);
3314 if reserved_names.insert(preferred.clone()) {
3315 return preferred;
3316 }
3317
3318 let mut index = 2;
3319 loop {
3320 let candidate = format!("{}{}{}", base_name, suffix, index);
3321 if reserved_names.insert(candidate.clone()) {
3322 return candidate;
3323 }
3324 index += 1;
3325 }
3326}
3327
3328fn section_interface_name(interface_prefix: &str, entity_name: &str, name: &str) -> String {
3329 if name == "Root" {
3330 format!("{}{}", interface_prefix, to_pascal_case(entity_name))
3331 } else {
3332 let base_name = if entity_name.contains("Game") {
3335 "Game"
3336 } else {
3337 entity_name
3338 };
3339 format!("{}{}{}", interface_prefix, base_name, to_pascal_case(name))
3340 }
3341}
3342
3343pub(crate) fn to_pascal_case(s: &str) -> String {
3345 s.split(['_', '-', '.', ':'])
3346 .map(|word| {
3347 let mut chars = word.chars();
3348 match chars.next() {
3349 None => String::new(),
3350 Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
3351 }
3352 })
3353 .collect()
3354}
3355
3356fn to_camel_case(s: &str) -> String {
3357 let pascal = to_pascal_case(s);
3358 let mut chars = pascal.chars();
3359 match chars.next() {
3360 Some(first) => first.to_lowercase().collect::<String>() + chars.as_str(),
3361 None => pascal,
3362 }
3363}
3364
3365fn normalize_for_comparison(s: &str) -> String {
3368 s.chars()
3369 .filter(|c| *c != '_')
3370 .flat_map(|c| c.to_lowercase())
3371 .collect()
3372}
3373
3374fn is_root_section(name: &str) -> bool {
3375 name.eq_ignore_ascii_case("root")
3376}
3377
3378fn state_view_key_definition(
3379 entity_name: &str,
3380 identity: &IdentitySpec,
3381 field_mappings: &BTreeMap<String, FieldTypeInfo>,
3382 sections: &[EntitySection],
3383) -> Result<StateViewKeyDefinition, String> {
3384 let mut seen = HashSet::new();
3385 let distinct_keys: Vec<&str> = identity
3386 .primary_keys
3387 .iter()
3388 .map(String::as_str)
3389 .filter(|key| seen.insert(*key))
3390 .collect();
3391
3392 if distinct_keys.len() > 1 {
3393 return Err(format!(
3394 "TypeScript SDK generation does not support composite state keys for entity '{}': distinct identity.primary_keys are [{}]",
3395 entity_name,
3396 distinct_keys.join(", ")
3397 ));
3398 }
3399
3400 let key_path = distinct_keys.first().copied().ok_or_else(|| {
3401 format!(
3402 "TypeScript SDK generation requires a primary key for entity '{}'",
3403 entity_name
3404 )
3405 })?;
3406 let key_leaf = key_path.rsplit('.').next().unwrap_or(key_path);
3407 let field_info = field_mappings.get(key_path).or_else(|| {
3408 sections.iter().find_map(|section| {
3409 section.fields.iter().find(|field| {
3410 field.raw_field_name() == key_path
3411 || field.raw_field_name() == key_leaf
3412 || field.field_name == key_path
3413 || field.field_name == key_leaf
3414 })
3415 })
3416 });
3417
3418 let field_name = field_info
3419 .map(FieldTypeInfo::canonical_field_name)
3420 .unwrap_or_else(|| to_camel_case(key_leaf));
3421 let typescript_type = match field_info {
3422 Some(field) if field.is_array => {
3423 return Err(format!(
3424 "TypeScript SDK generation does not support array state key '{}' for entity '{}'",
3425 key_path, entity_name
3426 ));
3427 }
3428 Some(field) => match field.base_type {
3429 BaseType::String | BaseType::Binary | BaseType::Pubkey => "string".to_string(),
3430 BaseType::Integer | BaseType::Timestamp => field
3431 .effective_integer_kind()
3432 .map(integer_kind_to_typescript)
3433 .unwrap_or("number")
3434 .to_string(),
3435 _ => {
3436 return Err(format!(
3437 "TypeScript SDK generation does not support state key '{}' with type '{}' for entity '{}'",
3438 key_path, field.rust_type_name, entity_name
3439 ));
3440 }
3441 },
3442 None => "string".to_string(),
3444 };
3445
3446 Ok(StateViewKeyDefinition {
3447 field_name,
3448 typescript_type,
3449 })
3450}
3451
3452fn is_builtin_resolver_type(type_name: &str) -> bool {
3453 crate::resolvers::is_resolver_output_type(type_name)
3454}
3455
3456fn to_kebab_case(s: &str) -> String {
3458 let mut result = String::new();
3459
3460 for ch in s.chars() {
3461 if ch.is_uppercase() && !result.is_empty() {
3462 result.push('-');
3463 }
3464 result.push(ch.to_lowercase().next().unwrap());
3465 }
3466
3467 result
3468}
3469
3470pub fn generate_typescript_from_spec_fn<F, S>(
3473 spec_fn: F,
3474 entity_name: String,
3475 config: Option<TypeScriptConfig>,
3476) -> Result<TypeScriptOutput, String>
3477where
3478 F: Fn() -> TypedStreamSpec<S>,
3479{
3480 let spec = spec_fn();
3481 let compiler =
3482 TypeScriptCompiler::new(spec, entity_name).with_config(config.unwrap_or_default());
3483
3484 compiler.try_compile()
3485}
3486
3487pub fn write_typescript_to_file(
3489 output: &TypeScriptOutput,
3490 path: &std::path::Path,
3491) -> Result<(), std::io::Error> {
3492 std::fs::write(path, output.full_file())
3493}
3494
3495pub fn compile_serializable_spec(
3498 spec: SerializableStreamSpec,
3499 entity_name: String,
3500 config: Option<TypeScriptConfig>,
3501) -> Result<TypeScriptOutput, String> {
3502 entity_compiler(spec, entity_name, config, HashSet::new()).try_compile()
3503}
3504
3505fn entity_compiler(
3506 spec: SerializableStreamSpec,
3507 entity_name: String,
3508 config: Option<TypeScriptConfig>,
3509 already_emitted_types: HashSet<String>,
3510) -> TypeScriptCompiler<()> {
3511 let idl = spec
3512 .idl
3513 .as_ref()
3514 .and_then(|idl_snapshot| serde_json::to_value(idl_snapshot).ok());
3515
3516 let handlers = serde_json::to_value(&spec.handlers).ok();
3517 let views = spec.views.clone();
3518
3519 let typed_spec: TypedStreamSpec<()> = TypedStreamSpec::from_serializable(spec);
3520
3521 TypeScriptCompiler::new(typed_spec, entity_name)
3522 .with_idl(idl)
3523 .with_handlers_json(handlers)
3524 .with_views(views)
3525 .with_config(config.unwrap_or_default())
3526 .with_already_emitted_types(already_emitted_types)
3527}
3528
3529fn stack_entity_type_names(entities: &[SerializableStreamSpec]) -> Vec<String> {
3533 let mut names = vec!["EventWrapper".to_string(), "CaptureWrapper".to_string()];
3534 names.extend(
3535 crate::resolvers::builtin_resolver_registry()
3536 .definitions()
3537 .map(|resolver| resolver.output_type().to_string()),
3538 );
3539 for entity in entities {
3540 let entity_name = to_pascal_case(&entity.state_name);
3541 names.push(format!("{entity_name}Completed"));
3542 names.push(format!("{entity_name}Patch"));
3543 let section_names = entity
3544 .sections
3545 .iter()
3546 .map(|section| section.name.clone())
3547 .chain(entity.handlers.iter().flat_map(|handler| {
3548 handler
3549 .mappings
3550 .iter()
3551 .filter_map(|mapping| mapping.target_path.split_once('.'))
3552 .map(|(section, _)| section.to_string())
3553 }))
3554 .filter(|section| !is_root_section(section))
3555 .collect::<BTreeSet<_>>();
3556 for section in section_names {
3557 let section_name = section_interface_name("", &entity.state_name, §ion);
3558 names.push(format!("{section_name}Patch"));
3559 names.push(section_name);
3560 }
3561 names.push(entity_name);
3562 }
3563 names
3564}
3565
3566#[derive(Debug, Clone, PartialEq, Eq)]
3567pub struct TypeScriptProgramDefinitionMetadata {
3568 pub program_id: String,
3569 pub sdk_definition_hash: Option<String>,
3571 pub program_spec_hash: String,
3572 pub idl_content_hash: String,
3573 pub normalized_idl_hash: String,
3574 pub package_release_hash: Option<String>,
3582}
3583
3584#[derive(Debug, Clone, PartialEq, Eq)]
3585pub struct TypeScriptProgramReleaseReference {
3586 pub program_release_hash: String,
3587 pub program_spec_hash: String,
3588}
3589
3590#[derive(Debug, Clone, PartialEq)]
3591pub struct TypeScriptProgramReadBinding {
3592 pub endpoint: String,
3593 pub program_read_binding_id: String,
3594 pub auth: serde_json::Value,
3595}
3596
3597#[derive(Debug, Clone, PartialEq)]
3598pub enum TypeScriptProgramReadTransport {
3599 LocalHttp,
3600 HostedBinding(TypeScriptProgramReadBinding),
3601}
3602
3603#[derive(Debug, Clone, PartialEq)]
3604pub struct TypeScriptProgramConfig {
3605 pub definition: TypeScriptProgramDefinitionMetadata,
3606 pub release: TypeScriptProgramReleaseReference,
3607 pub transport: TypeScriptProgramReadTransport,
3608 pub gateway: Option<serde_json::Value>,
3610}
3611
3612impl From<&arete_hash::OssProgramIdentityV1> for TypeScriptProgramConfig {
3613 fn from(identity: &arete_hash::OssProgramIdentityV1) -> Self {
3614 Self {
3615 definition: TypeScriptProgramDefinitionMetadata {
3616 program_id: identity.program_spec.program_id.clone(),
3617 sdk_definition_hash: None,
3618 program_spec_hash: identity.program_spec_hash.to_string(),
3619 idl_content_hash: identity.program_spec.idl_content_hash.to_string(),
3620 normalized_idl_hash: identity.program_spec.normalized_idl_hash.to_string(),
3621 package_release_hash: None,
3622 },
3623 release: TypeScriptProgramReleaseReference {
3624 program_release_hash: identity.release_hash.to_string(),
3625 program_spec_hash: identity.program_spec_hash.to_string(),
3626 },
3627 transport: TypeScriptProgramReadTransport::LocalHttp,
3628 gateway: None,
3629 }
3630 }
3631}
3632
3633#[derive(Debug, Clone)]
3634pub struct TypeScriptStackConfig {
3635 pub package_name: String,
3636 pub generate_helpers: bool,
3637 pub export_const_name: String,
3638 pub websocket_url: Option<String>,
3639 pub http_url: Option<String>,
3640 pub extension_import: Option<String>,
3641 pub programs: Option<Vec<TypeScriptProgramConfig>>,
3644 pub gateway: Option<serde_json::Value>,
3646 pub release: Option<crate::public_artifacts::StackRelease>,
3650 pub field_notes: BTreeMap<String, BTreeMap<String, String>>,
3653}
3654
3655impl Default for TypeScriptStackConfig {
3656 fn default() -> Self {
3657 Self {
3658 package_name: "@usearete/react".to_string(),
3659 generate_helpers: true,
3660 export_const_name: "STACK".to_string(),
3661 websocket_url: None,
3662 http_url: None,
3663 extension_import: None,
3664 programs: None,
3665 gateway: None,
3666 release: None,
3667 field_notes: BTreeMap::new(),
3668 }
3669 }
3670}
3671
3672#[derive(Debug, Clone)]
3673pub struct TypeScriptStackOutput {
3674 pub interfaces: String,
3675 pub stack_definition: String,
3676 pub imports: String,
3677 pub warnings: Vec<String>,
3680 pub pda_degradations: Vec<crate::typescript_instructions::PdaDegradation>,
3682}
3683
3684#[derive(Debug, Clone, Default)]
3685pub struct TypeScriptLiveEndpoints {
3686 pub websocket_url: Option<String>,
3687 pub http_url: Option<String>,
3688}
3689
3690#[derive(Debug, Clone, Default)]
3691pub struct TypeScriptCompositionConfig {
3692 pub stack: TypeScriptStackConfig,
3693 pub live_endpoints: BTreeMap<String, TypeScriptLiveEndpoints>,
3694 pub live_releases: BTreeMap<String, crate::public_artifacts::StackRelease>,
3699 pub live_module_imports: BTreeMap<String, String>,
3700 pub program_module_imports: BTreeMap<String, String>,
3701 pub program_entry_imports: BTreeMap<String, String>,
3707 pub extensions: Option<TypeScriptCompositionExtensions>,
3709}
3710
3711#[derive(Debug, Clone, Default)]
3717pub struct TypeScriptCompositionExtensions {
3718 pub import_path: String,
3720 pub stack_bindings: Vec<(String, String)>,
3722 pub program_bindings: Vec<(String, String)>,
3725}
3726
3727#[derive(Debug, Clone)]
3728pub struct TypeScriptAliasedStackOutput {
3729 pub alias: String,
3730 pub module_name: String,
3731 pub output: TypeScriptStackOutput,
3732}
3733
3734#[derive(Debug, Clone)]
3735pub struct TypeScriptProgramCollectionOutput {
3736 pub module_name: String,
3737 pub output: TypeScriptStackOutput,
3738 pub members: Vec<(String, String)>,
3739}
3740
3741#[derive(Debug, Clone)]
3742pub struct TypeScriptCompositionOutput {
3743 pub name: String,
3744 pub live_stacks: Vec<TypeScriptAliasedStackOutput>,
3745 pub program_collection: Option<TypeScriptProgramCollectionOutput>,
3746 pub session_definition: String,
3747 pub warnings: Vec<String>,
3748 pub pda_degradations: Vec<crate::typescript_instructions::PdaDegradation>,
3749}
3750
3751impl TypeScriptStackOutput {
3752 pub fn full_file(&self) -> String {
3753 let mut parts = Vec::new();
3754 if !self.imports.is_empty() {
3755 parts.push(self.imports.as_str());
3756 }
3757 if !self.interfaces.is_empty() {
3758 parts.push(self.interfaces.as_str());
3759 }
3760 if !self.stack_definition.is_empty() {
3761 parts.push(self.stack_definition.as_str());
3762 }
3763 parts.join("\n\n")
3764 }
3765}
3766
3767fn resolve_program_configs(
3768 stack_spec: &SerializableStackSpec,
3769 configured: Option<&[TypeScriptProgramConfig]>,
3770 allow_view_only: bool,
3771) -> Result<Vec<TypeScriptProgramConfig>, String> {
3772 if stack_spec.idls.is_empty() {
3773 if stack_spec.program_specs.is_empty()
3774 && configured.is_none_or(|programs| programs.is_empty())
3775 {
3776 return Ok(Vec::new());
3777 }
3778 return Err(format!(
3779 "Stack '{}' has program metadata but no ordered IDL list",
3780 stack_spec.stack_name
3781 ));
3782 }
3783
3784 if stack_spec.program_specs.is_empty() {
3785 let view_only = stack_spec.program_ids.is_empty()
3786 && stack_spec.instructions.is_empty()
3787 && stack_spec.pdas.is_empty()
3788 && stack_spec
3789 .idls
3790 .iter()
3791 .all(|idl| idl.accounts.is_empty() && idl.instructions.is_empty());
3792 if allow_view_only && view_only && configured.is_none_or(|programs| programs.is_empty()) {
3793 return Ok(Vec::new());
3794 }
3795 return Err(format!(
3796 "Stack '{}' uses program SDK/account definitions but its compiled input has no exact public ProgramSpecV1 values. Rebuild the ProgramSpec and StackManifest artifact closure with the current arete-macros before generating an SDK.",
3797 stack_spec.stack_name
3798 ));
3799 }
3800
3801 let expected = stack_spec.idls.len();
3802 if stack_spec.program_ids.len() != expected || stack_spec.program_specs.len() != expected {
3803 return Err(format!(
3804 "Stack '{}' program metadata is not aligned: program_ids={}, idls={}, program_specs={}. Rebuild the ProgramSpec and StackManifest artifact closure with the current arete-macros.",
3805 stack_spec.stack_name,
3806 stack_spec.program_ids.len(),
3807 stack_spec.idls.len(),
3808 stack_spec.program_specs.len(),
3809 ));
3810 }
3811
3812 let mut exact_definitions = Vec::with_capacity(expected);
3813 let mut program_keys = BTreeMap::new();
3814 for (index, ((program_id, idl), program_spec)) in stack_spec
3815 .program_ids
3816 .iter()
3817 .zip(&stack_spec.idls)
3818 .zip(&stack_spec.program_specs)
3819 .enumerate()
3820 {
3821 program_spec.validate().map_err(|error| {
3822 format!(
3823 "Stack '{}' program_specs[{index}] is invalid: {error}",
3824 stack_spec.stack_name
3825 )
3826 })?;
3827 if program_id != &program_spec.program_id {
3828 return Err(format!(
3829 "Stack '{}' program ID mismatch at index {index}: program_ids has '{}', ProgramSpecV1 has '{}'",
3830 stack_spec.stack_name, program_id, program_spec.program_id
3831 ));
3832 }
3833 if idl.program_id.as_deref() != Some(program_spec.program_id.as_str()) {
3834 return Err(format!(
3835 "Stack '{}' IDL program ID mismatch at index {index}: expected '{}'",
3836 stack_spec.stack_name, program_spec.program_id
3837 ));
3838 }
3839 if idl.name != program_spec.idl_snapshot.snapshot.name {
3840 return Err(format!(
3841 "Stack '{}' IDL/ProgramSpec name mismatch at index {index}: '{}' != '{}'",
3842 stack_spec.stack_name, idl.name, program_spec.idl_snapshot.snapshot.name
3843 ));
3844 }
3845 let program_key = to_camel_case(&idl.name);
3846 if let Some(existing) = program_keys.insert(program_key.clone(), idl.name.as_str()) {
3847 return Err(format!(
3848 "Stack '{}' has an ambiguous duplicate generated program key '{}': programs '{}' and '{}' both generate it; rename one of them",
3849 stack_spec.stack_name, program_key, existing, idl.name
3850 ));
3851 }
3852 exact_definitions.push(TypeScriptProgramDefinitionMetadata {
3853 program_id: program_spec.program_id.clone(),
3854 sdk_definition_hash: None,
3855 program_spec_hash: program_spec
3856 .hash()
3857 .map_err(|error| {
3858 format!(
3859 "Stack '{}' could not hash ProgramSpecV1 at index {index}: {error}",
3860 stack_spec.stack_name
3861 )
3862 })?
3863 .to_string(),
3864 idl_content_hash: program_spec.idl_content_hash.to_string(),
3865 normalized_idl_hash: program_spec.normalized_idl_hash.to_string(),
3866 package_release_hash: None,
3867 });
3868 }
3869
3870 let Some(configured) = configured else {
3871 return stack_spec
3872 .program_specs
3873 .iter()
3874 .enumerate()
3875 .map(|(index, program_spec)| {
3876 arete_hash::OssProgramIdentityV1::new(program_spec.clone())
3877 .map(|identity| TypeScriptProgramConfig::from(&identity))
3878 .map_err(|error| {
3879 format!(
3880 "Stack '{}' could not derive the OSS release for program index {index}: {error}",
3881 stack_spec.stack_name
3882 )
3883 })
3884 })
3885 .collect();
3886 };
3887 if configured.len() != expected {
3888 return Err(format!(
3889 "Stack '{}' hosted descriptor count mismatch: expected {expected}, received {}",
3890 stack_spec.stack_name,
3891 configured.len()
3892 ));
3893 }
3894
3895 for (index, (hosted, exact)) in configured.iter().zip(&exact_definitions).enumerate() {
3896 if hosted.definition.program_id != exact.program_id {
3897 return Err(format!(
3898 "Stack '{}' hosted descriptor program ID mismatch at index {index}: expected '{}', received '{}'",
3899 stack_spec.stack_name,
3900 exact.program_id,
3901 hosted.definition.program_id
3902 ));
3903 }
3904 if hosted.definition.program_spec_hash != exact.program_spec_hash {
3905 return Err(format!(
3906 "Stack '{}' hosted descriptor programSpecHash mismatch at index {index}: expected '{}', received '{}'",
3907 stack_spec.stack_name,
3908 exact.program_spec_hash,
3909 hosted.definition.program_spec_hash
3910 ));
3911 }
3912 if hosted.definition.idl_content_hash != exact.idl_content_hash
3913 || hosted.definition.normalized_idl_hash != exact.normalized_idl_hash
3914 {
3915 return Err(format!(
3916 "Stack '{}' hosted descriptor definition hashes mismatch at index {index}",
3917 stack_spec.stack_name
3918 ));
3919 }
3920 if hosted.release.program_spec_hash != hosted.definition.program_spec_hash {
3921 return Err(format!(
3922 "Stack '{}' hosted descriptor release programSpecHash mismatch at index {index}",
3923 stack_spec.stack_name
3924 ));
3925 }
3926 if let TypeScriptProgramReadTransport::HostedBinding(binding) = &hosted.transport {
3927 let target_kind = binding
3928 .auth
3929 .get("targetKind")
3930 .and_then(serde_json::Value::as_str);
3931 let session_endpoint = binding
3932 .auth
3933 .get("sessionEndpoint")
3934 .and_then(serde_json::Value::as_str);
3935 if binding.endpoint.trim().is_empty()
3936 || binding.program_read_binding_id.trim().is_empty()
3937 || target_kind != Some("program-read-binding")
3938 || session_endpoint.is_none_or(|value| value.trim().is_empty())
3939 {
3940 return Err(format!(
3941 "Stack '{}' hosted descriptor binding is incomplete at index {index}",
3942 stack_spec.stack_name
3943 ));
3944 }
3945 }
3946 }
3947
3948 Ok(configured.to_vec())
3949}
3950
3951pub fn compile_stack_spec(
3958 stack_spec: SerializableStackSpec,
3959 config: Option<TypeScriptStackConfig>,
3960) -> Result<TypeScriptStackOutput, String> {
3961 compile_stack_spec_with_view_selection(stack_spec, config, false)
3962}
3963
3964fn compile_stack_spec_with_view_selection(
3965 stack_spec: SerializableStackSpec,
3966 config: Option<TypeScriptStackConfig>,
3967 exact_views: bool,
3968) -> Result<TypeScriptStackOutput, String> {
3969 let config = config.unwrap_or_default();
3970 let program_configs = resolve_program_configs(&stack_spec, config.programs.as_deref(), true)?;
3971 let stack_name = &stack_spec.stack_name;
3972 let stack_kebab = to_kebab_case(stack_name);
3973 validate_entity_identifiers(&stack_spec.entities)?;
3974
3975 let mut all_interfaces = Vec::new();
3977 let mut entity_names = Vec::new();
3978 let mut schema_names: Vec<String> = Vec::new();
3979 let mut emitted_types: HashSet<String> = HashSet::new();
3980 let mut stack_types = StackResolvedTypes::default();
3981 stack_types.reserve(stack_entity_type_names(&stack_spec.entities));
3982
3983 for entity_spec in &stack_spec.entities {
3984 let mut spec = entity_spec.clone();
3985 let program_name = entity_program_name(entity_spec, &stack_spec.idls).map(str::to_string);
3986 if spec.idl.is_none() {
3988 spec.idl = entity_idl(entity_spec, &stack_spec.idls).cloned();
3989 }
3990 let entity_name = spec.state_name.clone();
3991 entity_names.push(entity_name.clone());
3992
3993 let per_entity_config = TypeScriptConfig {
3994 package_name: config.package_name.clone(),
3995 generate_helpers: false,
3996 interface_prefix: String::new(),
3997 export_const_name: config.export_const_name.clone(),
3998 url: config.websocket_url.clone(),
3999 };
4000
4001 let builtin_type_names = extract_builtin_resolver_type_names(&spec);
4003 let idl_for_check = spec.idl.clone();
4005
4006 let field_notes = config
4007 .field_notes
4008 .get(&entity_name)
4009 .cloned()
4010 .unwrap_or_default();
4011 let compiler = entity_compiler(
4012 spec,
4013 entity_name,
4014 Some(per_entity_config),
4015 emitted_types.clone(),
4016 )
4017 .with_stack_resolved_types(stack_types.clone(), program_name)
4018 .with_field_notes(field_notes);
4019 let output = compiler.try_compile()?;
4020 for (name, resolved) in compiler.declared_resolved_types() {
4021 stack_types.declare(&name, &resolved);
4022 }
4023
4024 let emitted_enum_names =
4027 extract_emitted_enum_type_names(&output.interfaces, idl_for_check.as_ref());
4028 emitted_types.extend(emitted_enum_names);
4029 emitted_types.extend(
4032 compiler
4033 .generate_idl_enum_artifacts()
4034 .into_iter()
4035 .map(|(_, schema_name, _)| schema_name.strip_suffix("Schema").unwrap().to_string()),
4036 );
4037 emitted_types.extend(builtin_type_names);
4038 if output
4039 .interfaces
4040 .contains("export interface CaptureWrapper<T>")
4041 {
4042 emitted_types.insert("CaptureWrapper".to_string());
4043 }
4044
4045 if !output.interfaces.is_empty() {
4047 all_interfaces.push(output.interfaces);
4048 }
4049
4050 schema_names.extend(output.schema_names);
4051 }
4052
4053 let mut interfaces = all_interfaces.join("\n\n");
4054
4055 let mut reserved_type_names: std::collections::HashSet<String> =
4060 std::collections::HashSet::new();
4061 for line in interfaces.lines() {
4062 for prefix in ["export interface ", "export type "] {
4063 if let Some(rest) = line.strip_prefix(prefix) {
4064 let name: String = rest
4065 .chars()
4066 .take_while(|c| c.is_alphanumeric() || *c == '_')
4067 .collect();
4068 if !name.is_empty() {
4069 reserved_type_names.insert(name);
4070 }
4071 }
4072 }
4073 }
4074 let idl_account_artifacts =
4075 generate_idl_account_artifacts(&stack_spec.idls, &reserved_type_names);
4076 for type_name in &idl_account_artifacts.type_names {
4077 reserved_type_names.insert(type_name.clone());
4078 }
4079 if !idl_account_artifacts.code.is_empty() {
4080 if interfaces.is_empty() {
4081 interfaces = idl_account_artifacts.code.clone();
4082 } else {
4083 interfaces = format!("{}\n\n{}", interfaces, idl_account_artifacts.code);
4084 }
4085 }
4086 schema_names.extend(idl_account_artifacts.schema_names.clone());
4087
4088 let instructions_codegen = crate::typescript_instructions::generate_instructions_code(
4089 stack_name,
4090 &stack_spec.instructions,
4091 &stack_spec.idls,
4092 &stack_spec.pdas,
4093 &stack_spec.program_ids,
4094 &reserved_type_names,
4095 );
4096 if !instructions_codegen.code.is_empty() {
4097 if interfaces.is_empty() {
4098 interfaces = instructions_codegen.code.clone();
4099 } else {
4100 interfaces = format!("{}\n\n{}", interfaces, instructions_codegen.code);
4101 }
4102 }
4103
4104 let imports = assemble_sdk_imports(
4105 collect_emitted_pda_imports(&stack_spec.idls, &stack_spec.pdas),
4106 !idl_account_artifacts.account_type_names.is_empty(),
4107 &instructions_codegen,
4108 );
4109 let program_configs = program_content_identities(
4110 &ProgramGenerationContext {
4111 pdas: &stack_spec.pdas,
4112 program_ids: &stack_spec.program_ids,
4113 instruction_entries: &instructions_codegen.stack_entries,
4114 schema_names: &schema_names.iter().cloned().collect(),
4115 account_type_names: &idl_account_artifacts.account_type_names,
4116 programs: &program_configs,
4117 },
4118 &stack_spec.idls,
4119 &imports,
4120 &interfaces,
4121 )?;
4122
4123 let stack_definition = generate_stack_definition_multi(
4125 stack_name,
4126 &stack_kebab,
4127 &stack_spec.entities,
4128 &entity_names,
4129 &stack_spec.idls,
4130 &stack_spec.pdas,
4131 &stack_spec.program_ids,
4132 &schema_names,
4133 &idl_account_artifacts.account_type_names,
4134 &instructions_codegen.stack_entries,
4135 &program_configs,
4136 &config,
4137 exact_views,
4138 )?;
4139
4140 let output = TypeScriptStackOutput {
4141 imports,
4142 interfaces,
4143 stack_definition,
4144 warnings: instructions_codegen.warnings,
4145 pda_degradations: instructions_codegen.pda_degradations,
4146 };
4147 ts_ident::check_module_declarations(
4148 &output.full_file(),
4149 &format!("The TypeScript SDK for stack '{stack_name}'"),
4150 )?;
4151 Ok(output)
4152}
4153
4154#[derive(Debug, Clone, PartialEq, Eq)]
4156pub struct TypeScriptFieldReference {
4157 pub path: String,
4159 pub wire: String,
4161 pub ts_type: String,
4163 pub nullable: bool,
4165}
4166
4167#[derive(Debug, Clone, PartialEq, Eq)]
4169pub struct TypeScriptEntityReference {
4170 pub entity: String,
4172 pub type_name: String,
4174 pub state_key: Option<(String, String)>,
4177 pub fields: Vec<TypeScriptFieldReference>,
4178}
4179
4180pub fn typescript_entity_references(
4183 stack_spec: &SerializableStackSpec,
4184) -> Result<Vec<TypeScriptEntityReference>, String> {
4185 validate_entity_identifiers(&stack_spec.entities)?;
4186 let mut stack_types = StackResolvedTypes::default();
4187 stack_types.reserve(stack_entity_type_names(&stack_spec.entities));
4188 let mut references = Vec::new();
4189 for entity_spec in &stack_spec.entities {
4190 let mut spec = entity_spec.clone();
4191 let program_name = entity_program_name(entity_spec, &stack_spec.idls).map(str::to_string);
4192 if spec.idl.is_none() {
4193 spec.idl = entity_idl(entity_spec, &stack_spec.idls).cloned();
4194 }
4195 let entity_name = spec.state_name.clone();
4196 let state_key = state_view_key_definition(
4197 &entity_name,
4198 &spec.identity,
4199 &spec.field_mappings,
4200 &spec.sections,
4201 )
4202 .ok()
4203 .map(|key| (key.field_name, key.typescript_type));
4204 let compiler = entity_compiler(spec, entity_name.clone(), None, HashSet::new())
4205 .with_stack_resolved_types(stack_types.clone(), program_name);
4206 references.push(TypeScriptEntityReference {
4207 type_name: to_pascal_case(&entity_name),
4208 entity: entity_name,
4209 state_key,
4210 fields: compiler.entity_field_references(),
4211 });
4212 for (name, resolved) in compiler.declared_resolved_types() {
4213 stack_types.declare(&name, &resolved);
4214 }
4215 }
4216 Ok(references)
4217}
4218
4219fn validate_entity_identifiers(entities: &[SerializableStreamSpec]) -> Result<(), String> {
4226 match entities
4227 .iter()
4228 .find(|entity| !ts_ident::is_identifier(&to_pascal_case(&entity.state_name)))
4229 {
4230 Some(entity) => Err(format!(
4231 "entity '{}' cannot be used as a TypeScript type name; entity names must be identifiers such as `TokenAccount`",
4232 entity.state_name
4233 )),
4234 None => Ok(()),
4235 }
4236}
4237
4238pub fn compile_stack_spec_with_exact_views(
4241 stack_spec: SerializableStackSpec,
4242 config: Option<TypeScriptStackConfig>,
4243) -> Result<TypeScriptStackOutput, String> {
4244 compile_stack_spec_with_view_selection(stack_spec, config, true)
4245}
4246
4247pub fn compile_public_artifacts(
4249 programs: &[arete_artifacts::ProgramSpecArtifact],
4250 live_spec: &arete_artifacts::LiveSpecArtifact,
4251 manifest: &arete_artifacts::StackManifestArtifact,
4252 config: Option<TypeScriptStackConfig>,
4253) -> Result<TypeScriptStackOutput, String> {
4254 let stack_spec =
4255 crate::public_artifacts::stack_spec_from_artifacts(programs, live_spec, manifest)?;
4256 compile_stack_spec(stack_spec, config)
4257}
4258
4259pub fn compile_public_artifacts_v2(
4261 programs: &[arete_artifacts::ProgramSpecArtifact],
4262 live_spec: &arete_artifacts::LiveSpecArtifactV2,
4263 manifest: &arete_artifacts::StackManifestArtifactV2,
4264 config: Option<TypeScriptStackConfig>,
4265) -> Result<TypeScriptStackOutput, String> {
4266 let stack_spec =
4267 crate::public_artifacts::stack_spec_from_artifacts_v2(programs, live_spec, manifest)?;
4268 let mut config = config.unwrap_or_default();
4269 if config.release.is_none() && config.websocket_url.is_some() {
4271 config.release = crate::public_artifacts::StackRelease::for_single_live(manifest);
4272 }
4273 compile_stack_spec_with_view_selection(stack_spec, Some(config), true)
4274}
4275
4276pub fn compile_composed_public_artifacts_v2(
4283 programs: &[arete_artifacts::ProgramSpecArtifact],
4284 live_specs: &[(String, arete_artifacts::LiveSpecArtifactV2)],
4285 manifest: &arete_artifacts::StackManifestArtifactV2,
4286 config: Option<TypeScriptCompositionConfig>,
4287) -> Result<TypeScriptCompositionOutput, String> {
4288 let composed =
4289 crate::public_artifacts::stack_specs_from_artifacts_v2(programs, live_specs, manifest)?;
4290 if composed.live_specs.is_empty() {
4291 return Err(
4292 "TypeScript session generation requires at least one aliased LiveSpec".to_string(),
4293 );
4294 }
4295 let config = config.unwrap_or_default();
4296 let live_aliases = composed
4297 .live_specs
4298 .iter()
4299 .map(|live| live.alias.as_str())
4300 .collect::<BTreeSet<_>>();
4301 if let Some(alias) = config
4302 .live_module_imports
4303 .keys()
4304 .find(|alias| !live_aliases.contains(alias.as_str()))
4305 {
4306 return Err(format!(
4307 "composition module import references unknown LiveSpec alias '{alias}'"
4308 ));
4309 }
4310 let mut outputs = Vec::with_capacity(composed.live_specs.len());
4311 let mut warnings = Vec::new();
4312 let mut pda_degradations = Vec::new();
4313
4314 let live_program_hashes = live_specs
4315 .iter()
4316 .flat_map(|(_, live)| &live.payload.programs)
4317 .map(|requirement| requirement.program_spec_hash.to_string())
4318 .collect::<BTreeSet<_>>();
4319 let independent_programs = programs
4320 .iter()
4321 .filter(|program| !live_program_hashes.contains(&program.artifact_hash.to_string()))
4322 .cloned()
4323 .collect::<Vec<_>>();
4324 let mut program_key_owners = crate::identifiers::IdentifierScope::new("TypeScript");
4327 let mut independent_program_keys = BTreeSet::new();
4328 for program in &independent_programs {
4329 let name = &program.payload.idl_snapshot.snapshot.name;
4330 let public = composition_program_key(program, &to_camel_case(name));
4331 program_key_owners.claim(
4332 &public,
4333 &format!("program '{name}' ({})", program.payload.program_id),
4334 )?;
4335 independent_program_keys.insert(public);
4336 }
4337 if let Some(alias) = config
4338 .program_module_imports
4339 .keys()
4340 .find(|alias| !independent_program_keys.contains(alias.as_str()))
4341 {
4342 return Err(format!(
4343 "composition program module import references unknown independent program alias '{alias}'"
4344 ));
4345 }
4346 let program_collection = if independent_programs.is_empty() {
4347 None
4348 } else {
4349 let program_stack = crate::public_artifacts::stack_spec_from_program_artifacts(
4350 format!("{}Programs", composed.name),
4351 &independent_programs,
4352 )?;
4353 let mut program_config = config.stack.clone();
4354 program_config.websocket_url = None;
4355 program_config.http_url = None;
4356 program_config.release = None;
4357 program_config.programs =
4358 subset_program_configs(&program_stack, config.stack.programs.as_deref())?;
4359 let output =
4360 compile_stack_spec_with_view_selection(program_stack, Some(program_config), true)?;
4361 warnings.extend(output.warnings.iter().cloned());
4362 pda_degradations.extend(output.pda_degradations.iter().cloned());
4363 Some(TypeScriptProgramCollectionOutput {
4364 module_name: format!("{}-programs", to_kebab_case(&composed.name)),
4365 output,
4366 members: independent_programs
4367 .iter()
4368 .map(|program| {
4369 let source = to_camel_case(&program.payload.idl_snapshot.snapshot.name);
4370 let public = composition_program_key(program, &source);
4371 (public, source)
4372 })
4373 .collect(),
4374 })
4375 };
4376
4377 let mut promoted_programs = Vec::new();
4378 let mut promoted_hashes = BTreeMap::<String, String>::new();
4379 let mut session_program_hashes = independent_programs
4381 .iter()
4382 .map(|program| {
4383 let source = to_camel_case(&program.payload.idl_snapshot.snapshot.name);
4384 (
4385 composition_program_key(program, &source),
4386 program.artifact_hash.to_string(),
4387 )
4388 })
4389 .collect::<BTreeMap<_, _>>();
4390 let mut stack_program_overrides = BTreeMap::<String, Vec<(String, String)>>::new();
4392 for live in composed.live_specs {
4393 let mut stack_config = config.stack.clone();
4394 if let Some(endpoints) = config.live_endpoints.get(&live.alias) {
4395 stack_config.websocket_url = endpoints.websocket_url.clone();
4396 stack_config.http_url = endpoints.http_url.clone();
4397 } else {
4398 stack_config.websocket_url = None;
4399 stack_config.http_url = None;
4400 }
4401 stack_config.release = stack_config.websocket_url.is_some().then(|| {
4402 config
4403 .live_releases
4404 .get(&live.alias)
4405 .cloned()
4406 .unwrap_or_else(|| {
4407 crate::public_artifacts::StackRelease::for_alias(manifest, &live.alias)
4408 })
4409 });
4410 stack_config.programs =
4411 subset_program_configs(&live.stack_spec, config.stack.programs.as_deref())?;
4412 for program in &live.stack_spec.program_specs {
4413 let source = to_camel_case(&program.idl_snapshot.snapshot.name);
4414 let hash = program
4415 .hash()
4416 .map_err(|error| error.to_string())?
4417 .to_string();
4418 if config.program_entry_imports.contains_key(&hash) {
4419 stack_program_overrides
4420 .entry(live.alias.clone())
4421 .or_default()
4422 .push((source.clone(), hash.clone()));
4423 }
4424 if let Some(existing_hash) = promoted_hashes.get(&source) {
4425 if existing_hash != &hash {
4426 return Err(format!(
4427 "composition programs use duplicate generated key '{source}' for different ProgramSpecs"
4428 ));
4429 }
4430 continue;
4431 }
4432 program_key_owners.claim(
4433 &source,
4434 &format!(
4435 "program '{}' ({})",
4436 program.idl_snapshot.snapshot.name, program.program_id
4437 ),
4438 )?;
4439 session_program_hashes.insert(source.clone(), hash.clone());
4440 promoted_hashes.insert(source.clone(), hash);
4441 promoted_programs.push((source.clone(), live.alias.clone(), source));
4442 }
4443 let module_name = typescript_module_name(&live.alias);
4444 let output =
4445 compile_stack_spec_with_view_selection(live.stack_spec, Some(stack_config), true)?;
4446 warnings.extend(output.warnings.iter().cloned());
4447 pda_degradations.extend(output.pda_degradations.iter().cloned());
4448 outputs.push(TypeScriptAliasedStackOutput {
4449 alias: live.alias,
4450 module_name,
4451 output,
4452 });
4453 }
4454
4455 let program_entries = resolve_program_entry_imports(
4456 &config.program_entry_imports,
4457 &session_program_hashes,
4458 &config.program_module_imports,
4459 )?;
4460 if let Some(extensions) = &config.extensions {
4461 validate_composition_extensions(
4462 extensions,
4463 &outputs
4464 .iter()
4465 .map(|live| live.alias.clone())
4466 .collect::<BTreeSet<_>>(),
4467 &session_program_hashes,
4468 )?;
4469 }
4470 let session_definition = generate_session_definition(SessionDefinitionInput {
4471 manifest_name: &composed.name,
4472 live_stacks: &outputs,
4473 promoted_programs: &promoted_programs,
4474 program_collection: program_collection.as_ref(),
4475 live_module_imports: &config.live_module_imports,
4476 program_module_imports: &config.program_module_imports,
4477 gateway: config.stack.gateway.as_ref(),
4478 program_entries: &program_entries,
4479 stack_program_overrides: &stack_program_overrides,
4480 extensions: config.extensions.as_ref(),
4481 });
4482 ts_ident::check_module_declarations(
4483 &session_definition,
4484 &format!("The TypeScript session for stack '{}'", composed.name),
4485 )?;
4486 Ok(TypeScriptCompositionOutput {
4487 name: composed.name,
4488 live_stacks: outputs,
4489 program_collection,
4490 session_definition,
4491 warnings,
4492 pda_degradations,
4493 })
4494}
4495
4496fn resolve_program_entry_imports(
4500 entry_imports: &BTreeMap<String, String>,
4501 session_program_hashes: &BTreeMap<String, String>,
4502 program_module_imports: &BTreeMap<String, String>,
4503) -> Result<BTreeMap<String, String>, String> {
4504 let mut entries = BTreeMap::new();
4505 for (hash, import) in entry_imports {
4506 let keys = session_program_hashes
4507 .iter()
4508 .filter(|(_, program_hash)| *program_hash == hash)
4509 .map(|(key, _)| key.clone())
4510 .collect::<Vec<_>>();
4511 if keys.is_empty() {
4512 return Err(format!(
4513 "composition program entry module '{import}' references unknown ProgramSpec {hash}"
4514 ));
4515 }
4516 for key in keys {
4517 if program_module_imports.contains_key(&key) {
4518 return Err(format!(
4519 "composition program '{key}' has both a program module import and a program SDK entry module"
4520 ));
4521 }
4522 entries.insert(key, import.clone());
4523 }
4524 }
4525 Ok(entries)
4526}
4527
4528fn validate_composition_extensions(
4529 extensions: &TypeScriptCompositionExtensions,
4530 live_aliases: &BTreeSet<String>,
4531 session_program_hashes: &BTreeMap<String, String>,
4532) -> Result<(), String> {
4533 let mut stacks = BTreeSet::new();
4534 for (alias, export) in &extensions.stack_bindings {
4535 if !live_aliases.contains(alias) {
4536 return Err(format!(
4537 "composition stack extension '{export}' extends unknown live alias '{alias}'"
4538 ));
4539 }
4540 if !stacks.insert(alias) {
4541 return Err(format!(
4542 "composition stack extensions bind live alias '{alias}' more than once"
4543 ));
4544 }
4545 }
4546 let mut programs = BTreeSet::new();
4547 for (key, export) in &extensions.program_bindings {
4548 if !session_program_hashes.contains_key(key) {
4549 return Err(format!(
4550 "composition program extension '{export}' extends unknown program '{key}'"
4551 ));
4552 }
4553 if !programs.insert(key) {
4554 return Err(format!(
4555 "composition stack extensions bind program '{key}' more than once"
4556 ));
4557 }
4558 }
4559 if stacks.is_empty() && programs.is_empty() {
4560 return Err(
4561 "composition stack extension declares no per-alias stack or per-program bindings"
4562 .to_string(),
4563 );
4564 }
4565 Ok(())
4566}
4567
4568fn typescript_member_access(key: &str) -> String {
4570 let property = typescript_property_key(key);
4571 if property == key {
4572 format!(".{key}")
4573 } else {
4574 format!("[{property}]")
4575 }
4576}
4577
4578fn subset_program_configs(
4579 stack_spec: &SerializableStackSpec,
4580 configured: Option<&[TypeScriptProgramConfig]>,
4581) -> Result<Option<Vec<TypeScriptProgramConfig>>, String> {
4582 let Some(configured) = configured else {
4583 return Ok(None);
4584 };
4585 let by_hash = configured
4586 .iter()
4587 .map(|program| {
4588 (
4589 program.definition.program_spec_hash.as_str(),
4590 program.clone(),
4591 )
4592 })
4593 .collect::<BTreeMap<_, _>>();
4594 stack_spec
4595 .program_specs
4596 .iter()
4597 .map(|program| {
4598 let hash = program
4599 .hash()
4600 .map_err(|error| error.to_string())?
4601 .to_string();
4602 by_hash.get(hash.as_str()).cloned().ok_or_else(|| {
4603 format!("missing configured program descriptor for ProgramSpec {hash}")
4604 })
4605 })
4606 .collect::<Result<Vec<_>, _>>()
4607 .map(Some)
4608}
4609
4610struct SessionDefinitionInput<'a> {
4611 manifest_name: &'a str,
4612 live_stacks: &'a [TypeScriptAliasedStackOutput],
4613 promoted_programs: &'a [(String, String, String)],
4614 program_collection: Option<&'a TypeScriptProgramCollectionOutput>,
4615 live_module_imports: &'a BTreeMap<String, String>,
4616 program_module_imports: &'a BTreeMap<String, String>,
4617 gateway: Option<&'a serde_json::Value>,
4618 program_entries: &'a BTreeMap<String, String>,
4620 stack_program_overrides: &'a BTreeMap<String, Vec<(String, String)>>,
4623 extensions: Option<&'a TypeScriptCompositionExtensions>,
4624}
4625
4626fn program_entry_binding(key: &str) -> String {
4627 format!("{}ProgramSdk", safe_pascal_identifier(key))
4628}
4629
4630fn generate_session_definition(input: SessionDefinitionInput<'_>) -> String {
4631 let SessionDefinitionInput {
4632 manifest_name,
4633 live_stacks,
4634 promoted_programs,
4635 program_collection,
4636 live_module_imports,
4637 program_module_imports,
4638 gateway,
4639 program_entries,
4640 stack_program_overrides,
4641 extensions,
4642 } = input;
4643 let manifest_pascal = safe_pascal_identifier(manifest_name);
4644 let manifest_screaming =
4645 ts_ident::identifier_stem(&manifest_pascal, IdentifierCase::ScreamingSnake);
4646 let definition_name = format!("{manifest_screaming}_SESSION_DEFINITION");
4647 let base_name = if extensions.is_some() {
4650 format!("{definition_name}_CORE")
4651 } else {
4652 definition_name.clone()
4653 };
4654 let imports = live_stacks
4655 .iter()
4656 .map(|live| {
4657 let import = live_module_imports
4658 .get(&live.alias)
4659 .cloned()
4660 .unwrap_or_else(|| format!("./{}.js", live.module_name));
4661 format!(
4662 "import {}Stack from '{}';",
4663 safe_pascal_identifier(&live.alias),
4664 import
4665 )
4666 })
4667 .collect::<Vec<_>>()
4668 .join("\n");
4669 let program_import = program_collection
4670 .map(|programs| {
4671 format!(
4672 "import {manifest_pascal}Programs from './{}.js';",
4673 programs.module_name
4674 )
4675 })
4676 .unwrap_or_default();
4677 let program_module_import_lines =
4678 program_module_imports
4679 .iter()
4680 .map(|(alias, import)| {
4681 format!(
4682 "import {}Program from '{}';",
4683 safe_pascal_identifier(alias),
4684 import
4685 )
4686 })
4687 .chain(program_entries.iter().map(|(key, import)| {
4688 format!("import {} from '{import}';", program_entry_binding(key))
4689 }))
4690 .chain(extensions.map(|extensions| {
4691 let names = extensions
4692 .stack_bindings
4693 .iter()
4694 .chain(&extensions.program_bindings)
4695 .map(|(_, export)| export.as_str())
4696 .collect::<BTreeSet<_>>()
4697 .into_iter()
4698 .collect::<Vec<_>>()
4699 .join(", ");
4700 format!("import {{ {names} }} from '{}';", extensions.import_path)
4701 }))
4702 .collect::<Vec<_>>()
4703 .join("\n");
4704 let program_members = program_collection
4705 .map(|programs| {
4706 let definitions = programs
4707 .members
4708 .iter()
4709 .map(|(public, source)| {
4710 let value = if program_module_imports.contains_key(public) {
4711 format!("{}Program", safe_pascal_identifier(public))
4712 } else if program_entries.contains_key(public) {
4713 program_entry_binding(public)
4714 } else {
4715 format!(
4716 "{manifest_pascal}Programs.programs.{}",
4717 typescript_property_key(source)
4718 )
4719 };
4720 format!(" {}: {value},", typescript_property_key(public))
4721 })
4722 .collect::<Vec<_>>()
4723 .join("\n");
4724 let reads = programs
4725 .members
4726 .iter()
4727 .map(|(public, source)| {
4728 format!(
4729 " {}: {manifest_pascal}Programs.programReads.{},",
4730 typescript_property_key(public),
4731 typescript_property_key(source)
4732 )
4733 })
4734 .collect::<Vec<_>>()
4735 .join("\n");
4736 (definitions, reads)
4737 })
4738 .unwrap_or_default();
4739 let promoted_definitions = promoted_programs
4740 .iter()
4741 .map(|(public, live_alias, source)| {
4742 let value = if program_entries.contains_key(public) {
4743 program_entry_binding(public)
4744 } else {
4745 format!(
4746 "{}Stack.programs.{}",
4747 safe_pascal_identifier(live_alias),
4748 typescript_property_key(source)
4749 )
4750 };
4751 format!(" {}: {value},", typescript_property_key(public))
4752 })
4753 .collect::<Vec<_>>()
4754 .join("\n");
4755 let promoted_reads = promoted_programs
4756 .iter()
4757 .map(|(public, live_alias, source)| {
4758 format!(
4759 " {}: {}Stack.programReads.{},",
4760 typescript_property_key(public),
4761 safe_pascal_identifier(live_alias),
4762 typescript_property_key(source)
4763 )
4764 })
4765 .collect::<Vec<_>>()
4766 .join("\n");
4767 let definitions = [promoted_definitions, program_members.0]
4768 .into_iter()
4769 .filter(|members| !members.is_empty())
4770 .collect::<Vec<_>>()
4771 .join("\n");
4772 let reads = [promoted_reads, program_members.1]
4773 .into_iter()
4774 .filter(|members| !members.is_empty())
4775 .collect::<Vec<_>>()
4776 .join("\n");
4777 let program_members =
4778 format!(" programs: {{\n{definitions}\n }},\n programReads: {{\n{reads}\n }},");
4779 let members = live_stacks
4780 .iter()
4781 .map(|live| {
4782 let stack = format!("{}Stack", safe_pascal_identifier(&live.alias));
4783 let value = match stack_program_overrides.get(&live.alias) {
4784 Some(overrides) if !overrides.is_empty() => {
4785 let programs = overrides
4786 .iter()
4787 .map(|(key, _)| {
4788 format!(
4789 "{}: {}",
4790 typescript_property_key(key),
4791 program_entry_binding(key)
4792 )
4793 })
4794 .collect::<Vec<_>>()
4795 .join(", ");
4796 format!("{{ ...{stack}, programs: {{ ...{stack}.programs, {programs} }} }}")
4797 }
4798 _ => stack,
4799 };
4800 format!(" {}: {value},", typescript_property_key(&live.alias))
4801 })
4802 .collect::<Vec<_>>()
4803 .join("\n");
4804 let gateway_member = gateway
4805 .map(|gateway| format!(" gateway: {},", gateway))
4806 .unwrap_or_default();
4807 let mut sdk_imports = vec!["createSession"];
4808 let extended_definition = extensions
4809 .map(|extensions| {
4810 let mut layers = Vec::new();
4811 if !extensions.stack_bindings.is_empty() {
4812 sdk_imports.push("extendStack");
4813 let stacks = extensions
4814 .stack_bindings
4815 .iter()
4816 .map(|(alias, export)| {
4817 format!(
4818 " {}: extendStack({base_name}.stacks{}, {export}),",
4819 typescript_property_key(alias),
4820 typescript_member_access(alias)
4821 )
4822 })
4823 .collect::<Vec<_>>()
4824 .join("\n");
4825 layers.push(format!(
4826 " stacks: {{\n ...{base_name}.stacks,\n{stacks}\n }},"
4827 ));
4828 }
4829 if !extensions.program_bindings.is_empty() {
4830 sdk_imports.push("extendPrograms");
4831 let programs = extensions
4832 .program_bindings
4833 .iter()
4834 .map(|(key, export)| format!(" {}: {export},", typescript_property_key(key)))
4835 .collect::<Vec<_>>()
4836 .join("\n");
4837 layers.push(format!(
4838 " programs: extendPrograms({base_name}.programs, {{\n{programs}\n }}),"
4839 ));
4840 }
4841 format!(
4842 "\nexport const {definition_name} = {{\n ...{base_name},\n{}\n}} as const;\n",
4843 layers.join("\n")
4844 )
4845 })
4846 .unwrap_or_default();
4847 sdk_imports.sort_unstable();
4848 format!(
4849 r#"import {{ {sdk_imports}, type CompositionSessionOptions }} from '@usearete/sdk';
4850{imports}
4851{program_import}
4852{program_module_import_lines}
4853
4854export const {base_name} = {{
4855 mode: 'composition',
4856{gateway_member}
4857 stacks: {{
4858{members}
4859 }},
4860{program_members}
4861}} as const;
4862{extended_definition}
4863export type {manifest_pascal}SessionDefinition = typeof {definition_name};
4864export const {manifest_screaming}_SDK = {definition_name};
4865export type {manifest_pascal}Sdk = {manifest_pascal}SessionDefinition;
4866
4867export function create{manifest_pascal}Session(
4868 options: CompositionSessionOptions<{manifest_pascal}SessionDefinition>
4869) {{
4870 return createSession({definition_name}, options);
4871}}
4872"#,
4873 sdk_imports = sdk_imports.join(", "),
4874 imports = imports,
4875 program_import = program_import,
4876 program_module_import_lines = program_module_import_lines,
4877 definition_name = definition_name,
4878 base_name = base_name,
4879 gateway_member = gateway_member,
4880 members = members,
4881 program_members = program_members,
4882 extended_definition = extended_definition,
4883 manifest_pascal = manifest_pascal,
4884 manifest_screaming = manifest_screaming,
4885 )
4886}
4887
4888pub fn safe_pascal_identifier(value: &str) -> String {
4891 if !value
4892 .chars()
4893 .any(|character| character.is_ascii_alphanumeric())
4894 {
4895 return "Manifest".to_string();
4896 }
4897 ts_ident::identifier_stem(value, IdentifierCase::Pascal)
4898}
4899
4900fn typescript_module_name(alias: &str) -> String {
4901 let module = alias
4902 .chars()
4903 .map(|character| {
4904 if character.is_ascii_alphanumeric() {
4905 character.to_ascii_lowercase()
4906 } else {
4907 '-'
4908 }
4909 })
4910 .collect::<String>();
4911 format!("{module}-stack")
4912}
4913
4914fn composition_program_key(
4915 program: &arete_artifacts::ProgramSpecArtifact,
4916 generated_key: &str,
4917) -> String {
4918 match program.payload.program_id.as_str() {
4919 "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA" => "splToken".to_string(),
4920 "ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL" => "splAta".to_string(),
4921 _ => generated_key.to_string(),
4922 }
4923}
4924
4925fn assemble_sdk_imports(
4928 pda_imports: PdaImportUsage,
4929 has_account_reads: bool,
4930 instructions_codegen: &crate::typescript_instructions::InstructionsCodegen,
4931) -> String {
4932 let mut sdk_named: Vec<String> = Vec::new();
4933 for (needed, helper) in [
4934 (pda_imports.pda, "pda"),
4935 (pda_imports.literal, "literal"),
4936 (pda_imports.account, "account"),
4937 (pda_imports.arg, "arg"),
4938 (pda_imports.bytes, "bytes"),
4939 ] {
4940 if needed {
4941 sdk_named.push(helper.to_string());
4942 }
4943 }
4944 if has_account_reads {
4945 sdk_named.push("programAccountRead".to_string());
4946 }
4947 if instructions_codegen.needs_runtime_import {
4948 sdk_named.push("createInstructionHandler".to_string());
4949 sdk_named.push("type ErrorMetadata".to_string());
4950 }
4951 if !instructions_codegen.stack_entries.is_empty() {
4952 sdk_named.push("buildInstruction".to_string());
4953 }
4954 if instructions_codegen.needs_build_options {
4955 sdk_named.push("type BuildOptions".to_string());
4956 }
4957 if instructions_codegen.needs_program_runtime_extensions {
4958 sdk_named.push("PROGRAM_OPERATION_EXTENSIONS".to_string());
4959 sdk_named.push("instructionOperation".to_string());
4960 sdk_named.push("createPreparedInstruction".to_string());
4961 }
4962 if instructions_codegen.needs_operation_context {
4963 sdk_named.push("type ProgramOperationContext".to_string());
4964 }
4965 if instructions_codegen.needs_amount_input {
4966 sdk_named.push("type AmountInput".to_string());
4967 }
4968 if instructions_codegen.needs_resolve_amount_to_raw {
4969 sdk_named.push("resolveAmountToRaw".to_string());
4970 }
4971 if instructions_codegen.needs_to_raw_amount {
4972 sdk_named.push("toRawAmount".to_string());
4973 }
4974 if sdk_named.is_empty() {
4975 "import { z } from 'zod';".to_string()
4976 } else {
4977 format!(
4978 "import {{ z }} from 'zod';\nimport {{ {} }} from '@usearete/sdk';",
4979 sdk_named.join(", ")
4980 )
4981 }
4982}
4983
4984#[derive(Debug, Clone, Copy, Default)]
4985struct PdaImportUsage {
4986 pda: bool,
4987 literal: bool,
4988 account: bool,
4989 arg: bool,
4990 bytes: bool,
4991}
4992
4993fn collect_emitted_pda_imports(
4994 idls: &[IdlSnapshot],
4995 pdas: &BTreeMap<String, BTreeMap<String, PdaDefinition>>,
4996) -> PdaImportUsage {
4997 let mut usage = PdaImportUsage::default();
4998
4999 for idl in idls {
5000 let Some(program_pdas) = pdas
5001 .get(&idl.name)
5002 .or_else(|| pdas.get(&to_camel_case(&idl.name)))
5003 .filter(|program_pdas| !program_pdas.is_empty())
5004 else {
5005 continue;
5006 };
5007
5008 usage.pda = true;
5009 for seed in program_pdas.values().flat_map(|pda| &pda.seeds) {
5010 match seed {
5011 PdaSeedDef::Literal { .. } => usage.literal = true,
5012 PdaSeedDef::AccountRef { .. } => usage.account = true,
5013 PdaSeedDef::ArgRef { .. } => usage.arg = true,
5014 PdaSeedDef::Bytes { .. } => usage.bytes = true,
5015 }
5016 }
5017 }
5018
5019 usage
5020}
5021
5022pub fn compile_program_modules(
5027 stack_spec: SerializableStackSpec,
5028 config: Option<TypeScriptStackConfig>,
5029) -> Result<TypeScriptStackOutput, String> {
5030 let config = config.unwrap_or_default();
5031 let program_configs = resolve_program_configs(&stack_spec, config.programs.as_deref(), false)?;
5032 let stack_name = &stack_spec.stack_name;
5033
5034 if stack_spec.idls.is_empty() {
5035 return Err(format!(
5036 "Stack '{}' carries no IDLs; a program-only SDK has nothing to emit",
5037 stack_name
5038 ));
5039 }
5040
5041 let mut reserved_type_names: std::collections::HashSet<String> =
5042 std::collections::HashSet::new();
5043 let idl_account_artifacts =
5044 generate_idl_account_artifacts(&stack_spec.idls, &reserved_type_names);
5045 for type_name in &idl_account_artifacts.type_names {
5046 reserved_type_names.insert(type_name.clone());
5047 }
5048
5049 let mut interfaces = idl_account_artifacts.code.clone();
5050
5051 let instructions_codegen = crate::typescript_instructions::generate_instructions_code(
5052 stack_name,
5053 &stack_spec.instructions,
5054 &stack_spec.idls,
5055 &stack_spec.pdas,
5056 &stack_spec.program_ids,
5057 &reserved_type_names,
5058 );
5059 if !instructions_codegen.code.is_empty() {
5060 if interfaces.is_empty() {
5061 interfaces = instructions_codegen.code.clone();
5062 } else {
5063 interfaces = format!("{}\n\n{}", interfaces, instructions_codegen.code);
5064 }
5065 }
5066
5067 let unique_schemas: BTreeSet<String> =
5068 idl_account_artifacts.schema_names.iter().cloned().collect();
5069
5070 let imports = assemble_sdk_imports(
5071 collect_emitted_pda_imports(&stack_spec.idls, &stack_spec.pdas),
5072 !idl_account_artifacts.account_type_names.is_empty(),
5073 &instructions_codegen,
5074 );
5075
5076 let program_context = ProgramGenerationContext {
5077 pdas: &stack_spec.pdas,
5078 program_ids: &stack_spec.program_ids,
5079 instruction_entries: &instructions_codegen.stack_entries,
5080 schema_names: &unique_schemas,
5081 account_type_names: &idl_account_artifacts.account_type_names,
5082 programs: &program_configs,
5083 };
5084 let program_configs =
5085 program_content_identities(&program_context, &stack_spec.idls, &imports, &interfaces)?;
5086 let program_context = ProgramGenerationContext {
5087 programs: &program_configs,
5088 ..program_context
5089 };
5090 let stack_definition =
5091 generate_program_definitions(stack_name, &stack_spec.idls, &program_context);
5092
5093 let output = TypeScriptStackOutput {
5094 imports,
5095 interfaces,
5096 stack_definition,
5097 warnings: instructions_codegen.warnings,
5098 pda_degradations: instructions_codegen.pda_degradations,
5099 };
5100 ts_ident::check_module_declarations(
5101 &output.full_file(),
5102 &format!("The TypeScript program SDK for '{stack_name}'"),
5103 )?;
5104 Ok(output)
5105}
5106
5107pub fn compile_program_artifacts(
5109 name: impl Into<String>,
5110 programs: &[arete_artifacts::ProgramSpecArtifact],
5111 config: Option<TypeScriptStackConfig>,
5112) -> Result<TypeScriptStackOutput, String> {
5113 let stack_spec = crate::public_artifacts::stack_spec_from_program_artifacts(name, programs)?;
5114 compile_program_modules(stack_spec, config)
5115}
5116
5117pub fn write_stack_typescript_to_file(
5119 output: &TypeScriptStackOutput,
5120 path: &std::path::Path,
5121) -> Result<(), std::io::Error> {
5122 std::fs::write(path, output.full_file())
5123}
5124
5125#[allow(clippy::too_many_arguments)]
5149fn generate_stack_definition_multi(
5150 stack_name: &str,
5151 stack_kebab: &str,
5152 entities: &[SerializableStreamSpec],
5153 entity_names: &[String],
5154 idls: &[IdlSnapshot],
5155 pdas: &BTreeMap<String, BTreeMap<String, PdaDefinition>>,
5156 program_ids: &[String],
5157 schema_names: &[String],
5158 account_type_names: &BTreeMap<(String, String), String>,
5159 instruction_entries: &[crate::typescript_instructions::StackInstructionEntry],
5160 program_configs: &[TypeScriptProgramConfig],
5161 config: &TypeScriptStackConfig,
5162 exact_views: bool,
5163) -> Result<String, String> {
5164 let export_name = format!(
5165 "{}_{}",
5166 ts_ident::identifier_stem(stack_name, IdentifierCase::ScreamingSnake),
5167 config.export_const_name
5168 );
5169 let core_export_name = format!("{}_CORE", export_name);
5170 let stack_type_prefix = ts_ident::stack_type_stem(stack_name);
5171
5172 let view_helpers = generate_view_helpers_static();
5173
5174 let websocket_endpoint = match &config.websocket_url {
5175 Some(url) => format!(" ws: '{}',", url),
5176 None => " ws: '', // TODO: Set after first deployment or pass useArete(..., { url })"
5177 .to_string(),
5178 };
5179 let http_endpoint = match &config.http_url {
5180 Some(url) => format!(" http: '{}',", url),
5181 None => {
5182 " http: '', // TODO: Set after first deployment or pass useArete(..., { httpUrl })"
5183 .to_string()
5184 }
5185 };
5186 let endpoints_block = format!(
5187 " endpoints: {{\n{}\n{}\n }},",
5188 websocket_endpoint, http_endpoint
5189 );
5190 let release_block = config
5191 .release
5192 .as_ref()
5193 .map(|release| {
5194 format!(
5195 "\n release: {{\n stackManifestHash: {},\n liveAlias: {},\n }},",
5196 ts_ident::single_quoted(&release.stack_manifest_hash),
5197 ts_ident::single_quoted(&release.live_alias),
5198 )
5199 })
5200 .unwrap_or_default();
5201 let gateway_block = config
5202 .gateway
5203 .as_ref()
5204 .map(|gateway| {
5205 format!(
5206 "\n gateway: {},",
5207 serde_json::to_string(gateway).expect("gateway descriptor must serialize")
5208 )
5209 })
5210 .unwrap_or_default();
5211
5212 let mut entity_view_blocks = Vec::new();
5214 for (i, entity_spec) in entities.iter().enumerate() {
5215 let entity_name = &entity_names[i];
5216 let entity_pascal = to_pascal_case(entity_name);
5217 let mut view_entries = Vec::new();
5218
5219 if !exact_views
5220 || entity_spec
5221 .views
5222 .iter()
5223 .any(|view| view.id == format!("{entity_name}/state"))
5224 {
5225 let state_view_key = state_view_key_definition(
5226 entity_name,
5227 &entity_spec.identity,
5228 &entity_spec.field_mappings,
5229 &entity_spec.sections,
5230 )?;
5231 view_entries.push(format!(
5232 " state: stateView<{entity}, {key_type}>('{entity_name}/state', {key_fields}),",
5233 entity = entity_pascal,
5234 entity_name = entity_name,
5235 key_type = state_view_key.object_type(),
5236 key_fields = state_view_key.fields_literal(),
5237 ));
5238 }
5239
5240 if !exact_views
5241 || entity_spec
5242 .views
5243 .iter()
5244 .any(|view| view.id == format!("{entity_name}/list"))
5245 {
5246 view_entries.push(format!(
5247 " list: listView<{entity}>('{entity_name}/list'),",
5248 entity = entity_pascal,
5249 entity_name = entity_name
5250 ));
5251 }
5252
5253 for view in &entity_spec.views {
5254 if !view.id.ends_with("/state")
5255 && !view.id.ends_with("/list")
5256 && view.id.starts_with(entity_name)
5257 {
5258 let view_name = view.id.split('/').nth(1).unwrap_or("unknown");
5259 view_entries.push(format!(
5260 " {}: listView<{entity}>('{}'),",
5261 typescript_property_key(view_name),
5262 view.id,
5263 entity = entity_pascal
5264 ));
5265 }
5266 }
5267
5268 if !view_entries.is_empty() {
5269 entity_view_blocks.push(format!(
5270 " {}: {{\n{}\n }},",
5271 typescript_property_key(entity_name),
5272 view_entries.join("\n")
5273 ));
5274 }
5275 }
5276
5277 let views_body = entity_view_blocks.join("\n");
5278
5279 let mut unique_schemas: BTreeSet<String> = BTreeSet::new();
5280 for name in schema_names {
5281 unique_schemas.insert(name.clone());
5282 }
5283 let schemas_block = if unique_schemas.is_empty() {
5284 String::new()
5285 } else {
5286 let schema_entries: Vec<String> = unique_schemas
5287 .iter()
5288 .filter(|name| name.ends_with("Schema") && !name.ends_with("PatchSchema"))
5289 .map(|name| format!(" {}: {},", name.trim_end_matches("Schema"), name))
5290 .collect();
5291 if schema_entries.is_empty() {
5292 String::new()
5293 } else {
5294 format!("\n schemas: {{\n{}\n }},", schema_entries.join("\n"))
5295 }
5296 };
5297 let patch_schema_entries: Vec<String> = entity_names
5298 .iter()
5299 .map(|entity_name| {
5300 let entity_pascal = to_pascal_case(entity_name);
5301 format!(" {}: {}PatchSchema,", entity_pascal, entity_pascal)
5302 })
5303 .collect();
5304 let patch_schemas_block = if patch_schema_entries.is_empty() {
5305 String::new()
5306 } else {
5307 format!(
5308 "\n patchSchemas: {{\n{}\n }},",
5309 patch_schema_entries.join("\n")
5310 )
5311 };
5312
5313 let program_context = ProgramGenerationContext {
5314 pdas,
5315 program_ids,
5316 instruction_entries,
5317 schema_names: &unique_schemas,
5318 account_type_names,
5319 programs: program_configs,
5320 };
5321 let programs_block = generate_programs_block(idls, &program_context);
5322 let program_reads_block = generate_program_reads_block(idls, &program_context);
5323 let addresses_block = generate_stack_addresses_block(idls, pdas, program_ids);
5324
5325 let entity_types: Vec<String> = entity_names.iter().map(|n| to_pascal_case(n)).collect();
5326
5327 let stack_export = format!(
5328 r#"export const {core_export_name} = {{
5329 name: {stack_kebab},
5330{endpoints_block}{release_block}{gateway_block}
5331 views: {{
5332{views_body}
5333 }},{schemas_section}{patch_schemas_section}{programs_section}{program_reads_section}{addresses_section}
5334}} as const;"#,
5335 core_export_name = core_export_name,
5336 stack_kebab = ts_ident::single_quoted(stack_kebab),
5337 endpoints_block = endpoints_block,
5338 release_block = release_block,
5339 gateway_block = gateway_block,
5340 views_body = views_body,
5341 schemas_section = schemas_block,
5342 patch_schemas_section = patch_schemas_block,
5343 programs_section = programs_block,
5344 program_reads_section = program_reads_block,
5345 addresses_section = addresses_block,
5346 );
5347
5348 Ok(format!(
5349 r#"{view_helpers}
5350
5351// ============================================================================
5352// Stack Definition
5353// ============================================================================
5354
5355/** Stack definition for {stack_comment} with {entity_count} entities */
5356{stack_export}
5357
5358/** Type alias for the core stack */
5359export type {stack_type_prefix}CoreStack = typeof {core_export_name};
5360
5361/** Entity types in this stack */
5362export type {stack_type_prefix}Entity = {entity_union};
5363
5364/** Default export for convenience */
5365export default {core_export_name};"#,
5366 view_helpers = view_helpers,
5367 stack_comment = ts_ident::comment_text(stack_name),
5368 stack_type_prefix = stack_type_prefix,
5369 entity_count = entities.len(),
5370 core_export_name = core_export_name,
5371 stack_export = stack_export,
5372 entity_union = if entity_types.is_empty() {
5373 "never".to_string()
5374 } else {
5375 entity_types.join(" | ")
5376 },
5377 ))
5378}
5379
5380pub fn typescript_property_key(value: &str) -> String {
5383 let mut characters = value.chars();
5384 let valid = characters
5385 .next()
5386 .is_some_and(|character| character.is_ascii_alphabetic() || matches!(character, '_' | '$'))
5387 && characters
5388 .all(|character| character.is_ascii_alphanumeric() || matches!(character, '_' | '$'));
5389 if valid {
5390 value.to_string()
5391 } else {
5392 serde_json::to_string(value).expect("string property serialization cannot fail")
5393 }
5394}
5395
5396struct ProgramGenerationContext<'a> {
5397 pdas: &'a BTreeMap<String, BTreeMap<String, PdaDefinition>>,
5398 program_ids: &'a [String],
5399 instruction_entries: &'a [crate::typescript_instructions::StackInstructionEntry],
5400 schema_names: &'a BTreeSet<String>,
5401 account_type_names: &'a BTreeMap<(String, String), String>,
5402 programs: &'a [TypeScriptProgramConfig],
5403}
5404
5405const TYPESCRIPT_PROGRAM_RUNTIME_CONTRACT: &str = "@usearete/sdk/program-definition-v1";
5411
5412fn program_content_identities(
5413 context: &ProgramGenerationContext<'_>,
5414 idls: &[IdlSnapshot],
5415 imports: &str,
5416 declarations: &str,
5417) -> Result<Vec<TypeScriptProgramConfig>, String> {
5418 use arete_hash::{hash_artifact_tree, ArtifactTreeEntry, SdkDefinitionV2, SdkOutputTree};
5419
5420 let mut unhashed = context.programs.to_vec();
5421 for program in &mut unhashed {
5422 program.definition.sdk_definition_hash = None;
5423 }
5424 let unhashed_context = ProgramGenerationContext {
5425 programs: &unhashed,
5426 ..*context
5427 };
5428 let mut identified = context.programs.to_vec();
5429 for (index, program) in identified.iter_mut().enumerate() {
5430 let (_, sections) =
5431 generate_single_program_sections(&idls[index], index, &unhashed_context);
5432 let definition = sections.join("\n");
5433 let output_tree_hash = hash_artifact_tree::<SdkOutputTree>(&[
5434 ArtifactTreeEntry::file("imports.ts", imports.as_bytes()),
5435 ArtifactTreeEntry::file("declarations.ts", declarations.as_bytes()),
5436 ArtifactTreeEntry::file("definition.ts", definition.as_bytes()),
5437 ])
5438 .map_err(|error| error.to_string())?;
5439 let input_hash = program
5440 .definition
5441 .program_spec_hash
5442 .parse()
5443 .map_err(|error: arete_hash::HashError| error.to_string())?;
5444 program.definition.sdk_definition_hash = Some(
5445 SdkDefinitionV2::new(
5446 input_hash,
5447 "typescript",
5448 TYPESCRIPT_PROGRAM_RUNTIME_CONTRACT,
5449 output_tree_hash,
5450 )
5451 .hash()
5452 .map_err(|error| error.to_string())?
5453 .to_string(),
5454 );
5455 }
5456 Ok(identified)
5457}
5458
5459fn generate_single_program_sections(
5464 idl: &IdlSnapshot,
5465 index: usize,
5466 context: &ProgramGenerationContext<'_>,
5467) -> (String, Vec<String>) {
5468 let program_key = to_camel_case(&idl.name);
5469 let metadata = &context.programs[index];
5470 let multi_program = context.program_ids.len() > 1
5471 || context
5472 .instruction_entries
5473 .iter()
5474 .any(|entry| entry.program_key.is_some());
5475 let program_id = context
5476 .program_ids
5477 .get(index)
5478 .cloned()
5479 .or_else(|| idl.program_id.clone())
5480 .unwrap_or_default();
5481
5482 let instruction_entries_for_program: Vec<
5483 &crate::typescript_instructions::StackInstructionEntry,
5484 > = context
5485 .instruction_entries
5486 .iter()
5487 .filter(|entry| {
5488 if multi_program {
5489 entry.program_key.as_deref() == Some(program_key.as_str())
5490 } else {
5491 true
5492 }
5493 })
5494 .collect();
5495 let instruction_entry_literals: Vec<String> = instruction_entries_for_program
5496 .iter()
5497 .map(|entry| {
5498 format!(
5499 " {}: {},",
5500 entry.instruction_name, entry.handler_const
5501 )
5502 })
5503 .collect();
5504
5505 let account_entries: Vec<String> = idl
5506 .accounts
5507 .iter()
5508 .filter_map(|account| {
5509 let type_name = context
5510 .account_type_names
5511 .get(&(program_key.clone(), account.name.clone()))?
5512 .clone();
5513 let schema_name = format!("{}Schema", type_name);
5514 if !context.schema_names.contains(&schema_name) {
5515 return None;
5516 }
5517 Some((account.name.clone(), type_name, schema_name))
5518 })
5519 .map(|account| {
5520 format!(
5521 " {account_name}: programAccountRead<{type_name}>({{ account: '{account_name}', schema: {schema_name} }}),",
5522 account_name = account.0,
5523 type_name = account.1,
5524 schema_name = account.2,
5525 )
5526 })
5527 .collect();
5528
5529 let program_pdas = context
5530 .pdas
5531 .get(&idl.name)
5532 .or_else(|| context.pdas.get(&program_key))
5533 .filter(|program_pdas| !program_pdas.is_empty());
5534
5535 let mut sections: Vec<String> = vec![
5536 format!(" name: '{}',", idl.name),
5537 format!(" programId: '{}',", program_id),
5538 ];
5539 if let Some(definition_hash) = &metadata.definition.sdk_definition_hash {
5540 sections.push(format!(" sdkDefinitionHash: '{}',", definition_hash));
5541 }
5542 sections.extend([
5543 format!(
5544 " programSpecHash: '{}',",
5545 metadata.definition.program_spec_hash
5546 ),
5547 format!(
5548 " idlContentHash: '{}',",
5549 metadata.definition.idl_content_hash
5550 ),
5551 format!(
5552 " normalizedIdlHash: '{}',",
5553 metadata.definition.normalized_idl_hash
5554 ),
5555 ]);
5556 if let Some(gateway) = &metadata.gateway {
5557 sections.push(format!(
5558 " gateway: {},",
5559 serde_json::to_string(gateway).expect("gateway descriptor must serialize")
5560 ));
5561 }
5562
5563 if let Some(program_pdas) = program_pdas {
5564 let pda_entries = generate_program_pda_entries(program_pdas, &program_id, " ");
5565 if !pda_entries.is_empty() {
5566 sections.push(format!(
5567 " pdas: {{\n{}\n }},",
5568 pda_entries.join("\n")
5569 ));
5570 sections.push(format!(
5571 " addresses: {{\n{}\n }},",
5572 pda_entries.join("\n")
5573 ));
5574 }
5575 }
5576
5577 if !account_entries.is_empty() {
5578 sections.push(format!(
5579 " accounts: {{\n{}\n }},",
5580 account_entries.join("\n")
5581 ));
5582 }
5583
5584 if !instruction_entry_literals.is_empty() {
5585 sections.push(format!(
5586 " rawInstructions: {{\n{}\n }},",
5587 instruction_entry_literals.join("\n")
5588 ));
5589 if let Some(semantic_block) =
5590 generate_program_semantic_instructions_block(&instruction_entries_for_program, " ")
5591 {
5592 sections.push(semantic_block);
5593 }
5594 }
5595
5596 (program_key, sections)
5597}
5598
5599fn generate_programs_block(idls: &[IdlSnapshot], context: &ProgramGenerationContext<'_>) -> String {
5600 if idls.is_empty() || context.programs.is_empty() {
5601 return String::new();
5602 }
5603
5604 let mut program_blocks = Vec::new();
5605
5606 for (index, idl) in idls.iter().enumerate() {
5607 let (program_key, sections) = generate_single_program_sections(idl, index, context);
5608
5609 program_blocks.push(format!(
5610 " {}: {{\n{}\n }},",
5611 typescript_property_key(&program_key),
5612 sections.join("\n")
5613 ));
5614 }
5615
5616 if program_blocks.is_empty() {
5617 return String::new();
5618 }
5619
5620 format!("\n programs: {{\n{}\n }},", program_blocks.join("\n"))
5621}
5622
5623fn generate_program_read_sections(metadata: &TypeScriptProgramConfig, indent: &str) -> Vec<String> {
5624 let mut sections = vec![format!(
5625 "{indent}release: {{ programReleaseHash: {release_hash}, programSpecHash: {spec_hash} }},",
5626 release_hash = serde_json::to_string(&metadata.release.program_release_hash)
5627 .expect("program release hash must serialize"),
5628 spec_hash = serde_json::to_string(&metadata.release.program_spec_hash)
5629 .expect("program spec hash must serialize"),
5630 )];
5631 sections.push(match &metadata.transport {
5632 TypeScriptProgramReadTransport::LocalHttp => format!(
5633 "{indent}transport: {{ kind: 'local-http', endpointSource: 'connect-http-url' }},"
5634 ),
5635 TypeScriptProgramReadTransport::HostedBinding(binding) => format!(
5636 "{indent}transport: {{ kind: 'hosted-binding', binding: {{ endpoint: {endpoint}, programReadBindingId: {binding_id}, auth: {auth} }} }},",
5637 endpoint = serde_json::to_string(&binding.endpoint)
5638 .expect("program endpoint must serialize"),
5639 binding_id = serde_json::to_string(&binding.program_read_binding_id)
5640 .expect("program binding ID must serialize"),
5641 auth = serde_json::to_string(&binding.auth)
5642 .expect("program auth metadata must serialize"),
5643 ),
5644 });
5645 sections
5646}
5647
5648fn generate_program_reads_block(
5649 idls: &[IdlSnapshot],
5650 context: &ProgramGenerationContext<'_>,
5651) -> String {
5652 if idls.is_empty() || context.programs.is_empty() {
5653 return String::new();
5654 }
5655
5656 let entries = idls
5657 .iter()
5658 .zip(context.programs)
5659 .map(|(idl, metadata)| {
5660 format!(
5661 " {}: {{\n{}\n }},",
5662 typescript_property_key(&to_camel_case(&idl.name)),
5663 generate_program_read_sections(metadata, " ").join("\n")
5664 )
5665 })
5666 .collect::<Vec<_>>();
5667 format!("\n programReads: {{\n{}\n }},", entries.join("\n"))
5668}
5669
5670fn dedent_lines(text: &str, spaces: usize) -> String {
5672 text.lines()
5673 .map(|line| {
5674 let strip = line
5675 .char_indices()
5676 .take_while(|(i, c)| *i < spaces && *c == ' ')
5677 .count();
5678 &line[strip..]
5679 })
5680 .collect::<Vec<_>>()
5681 .join("\n")
5682}
5683
5684fn generate_program_definitions(
5694 stack_name: &str,
5695 idls: &[IdlSnapshot],
5696 context: &ProgramGenerationContext<'_>,
5697) -> String {
5698 let mut program_consts = Vec::new();
5699 let mut map_entries = Vec::new();
5700 let mut program_read_consts = Vec::new();
5701 let mut read_map_entries = Vec::new();
5702
5703 for (index, idl) in idls.iter().enumerate() {
5704 let (program_key, sections) = generate_single_program_sections(idl, index, context);
5705 let const_name = program_const_name(&idl.name);
5706 let program_key = typescript_property_key(&program_key);
5707 let body = dedent_lines(§ions.join("\n"), 4);
5708 program_consts.push(format!(
5709 "/** Standalone program SDK for '{name}' */\nexport const {const_name} = {{\n{body}\n}} as const;",
5710 name = ts_ident::comment_text(&idl.name),
5711 const_name = const_name,
5712 body = body,
5713 ));
5714 map_entries.push(format!(" {}: {},", program_key, const_name));
5715 let read_const_name = format!("{}_READ", const_name);
5716 let read_body = dedent_lines(
5717 &generate_program_read_sections(&context.programs[index], " ").join("\n"),
5718 4,
5719 );
5720 program_read_consts.push(format!(
5721 "/** Release and explicit read transport for '{name}' */\nexport const {read_const_name} = {{\n{read_body}\n}} as const;",
5722 name = ts_ident::comment_text(&idl.name),
5723 ));
5724 read_map_entries.push(format!(" {}: {},", program_key, read_const_name));
5725 }
5726
5727 let stack_screaming = ts_ident::identifier_stem(stack_name, IdentifierCase::ScreamingSnake);
5728 let map_name = format!("{stack_screaming}_PROGRAMS");
5729 let reads_map_name = format!("{stack_screaming}_PROGRAM_READS");
5730 let type_name = format!(
5731 "{}Programs",
5732 ts_ident::identifier_stem(stack_name, IdentifierCase::Pascal)
5733 );
5734
5735 format!(
5736 r#"// ============================================================================
5737// Program Definitions
5738// ============================================================================
5739
5740{program_consts}
5741
5742{program_read_consts}
5743
5744/** All portable programs from the {stack_comment} stack */
5745export const {map_name} = {{
5746{map_entries}
5747}} as const;
5748
5749/** Parallel release/read metadata keyed identically to {map_name} */
5750export const {reads_map_name} = {{
5751{read_map_entries}
5752}} as const;
5753
5754export type {type_name} = typeof {map_name};
5755
5756export default {map_name};"#,
5757 program_consts = program_consts.join("\n\n"),
5758 program_read_consts = program_read_consts.join("\n\n"),
5759 stack_comment = ts_ident::comment_text(stack_name),
5760 map_name = map_name,
5761 map_entries = map_entries.join("\n"),
5762 reads_map_name = reads_map_name,
5763 read_map_entries = read_map_entries.join("\n"),
5764 type_name = type_name,
5765 )
5766}
5767
5768fn generate_program_pda_entries(
5769 program_pdas: &BTreeMap<String, PdaDefinition>,
5770 default_program_id: &str,
5771 indent: &str,
5772) -> Vec<String> {
5773 if program_pdas.is_empty() {
5774 return Vec::new();
5775 }
5776
5777 program_pdas
5778 .iter()
5779 .map(|(pda_name, pda_def)| {
5780 let seeds_str = pda_def
5781 .seeds
5782 .iter()
5783 .map(|seed| match seed {
5784 PdaSeedDef::Literal { value } => format!("literal('{}')", value),
5785 PdaSeedDef::AccountRef { account_name } => {
5786 format!("account('{}')", account_name)
5787 }
5788 PdaSeedDef::ArgRef { arg_name, arg_type } => {
5789 if let Some(t) = arg_type {
5790 format!("arg('{}', '{}')", arg_name, t)
5791 } else {
5792 format!("arg('{}')", arg_name)
5793 }
5794 }
5795 PdaSeedDef::Bytes { value } => {
5796 let bytes_arr: Vec<String> = value.iter().map(|b| b.to_string()).collect();
5797 format!("bytes(new Uint8Array([{}]))", bytes_arr.join(", "))
5798 }
5799 })
5800 .collect::<Vec<_>>()
5801 .join(", ");
5802
5803 let program = match (&pda_def.program_id, &pda_def.program) {
5804 (Some(pid), _) => format!("'{}'", pid),
5805 (None, Some(PdaProgramDef::AccountRef { account_name })) => {
5806 format!("{{ type: 'accountRef', accountName: '{}' }}", account_name)
5807 }
5808 (None, Some(PdaProgramDef::ArgRef { arg_name })) => {
5809 format!("{{ type: 'argRef', argName: '{}' }}", arg_name)
5810 }
5811 (None, None) => format!("'{}'", default_program_id),
5812 };
5813 let rendered_seeds = if seeds_str.is_empty() {
5814 String::new()
5815 } else {
5816 format!(", {}", seeds_str)
5817 };
5818 format!(
5819 "{}{}: pda({}{}),",
5820 indent, pda_name, program, rendered_seeds
5821 )
5822 })
5823 .collect()
5824}
5825
5826fn generate_stack_addresses_block(
5827 idls: &[IdlSnapshot],
5828 pdas: &BTreeMap<String, BTreeMap<String, PdaDefinition>>,
5829 program_ids: &[String],
5830) -> String {
5831 if idls.is_empty() {
5832 return String::new();
5833 }
5834
5835 if idls.len() == 1 {
5836 let idl = &idls[0];
5837 let program_id = program_ids
5838 .first()
5839 .cloned()
5840 .or_else(|| idl.program_id.clone())
5841 .unwrap_or_default();
5842 let Some(program_pdas) = pdas
5843 .get(&idl.name)
5844 .or_else(|| pdas.get(&to_camel_case(&idl.name)))
5845 else {
5846 return String::new();
5847 };
5848 let entries = generate_program_pda_entries(program_pdas, &program_id, " ");
5849 if entries.is_empty() {
5850 return String::new();
5851 }
5852 return format!("\n addresses: {{\n{}\n }},", entries.join("\n"));
5853 }
5854
5855 let mut blocks = Vec::new();
5856 for (index, idl) in idls.iter().enumerate() {
5857 let program_id = program_ids
5858 .get(index)
5859 .cloned()
5860 .or_else(|| idl.program_id.clone())
5861 .unwrap_or_default();
5862 let Some(program_pdas) = pdas
5863 .get(&idl.name)
5864 .or_else(|| pdas.get(&to_camel_case(&idl.name)))
5865 else {
5866 continue;
5867 };
5868 let entries = generate_program_pda_entries(program_pdas, &program_id, " ");
5869 if entries.is_empty() {
5870 continue;
5871 }
5872 blocks.push(format!(
5873 " {}: {{\n{}\n }},",
5874 typescript_property_key(&to_camel_case(&idl.name)),
5875 entries.join("\n")
5876 ));
5877 }
5878
5879 if blocks.is_empty() {
5880 String::new()
5881 } else {
5882 format!("\n addresses: {{\n{}\n }},", blocks.join("\n"))
5883 }
5884}
5885
5886fn is_valid_ts_identifier(name: &str) -> bool {
5887 name.chars()
5888 .next()
5889 .map(|c| c.is_ascii_alphabetic() || c == '_' || c == '$')
5890 .unwrap_or(false)
5891 && name
5892 .chars()
5893 .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '$')
5894}
5895
5896fn escape_ts_single_quotes(value: &str) -> String {
5897 value
5898 .replace('\\', "\\\\")
5899 .replace('\'', "\\'")
5900 .replace(['\n', '\r'], " ")
5901}
5902
5903fn render_ts_property_name_literal(name: &str) -> String {
5904 if is_valid_ts_identifier(name) {
5905 name.to_string()
5906 } else {
5907 format!("'{}'", escape_ts_single_quotes(name))
5908 }
5909}
5910
5911fn render_program_semantic_instruction_entry(
5912 entry: &crate::typescript_instructions::StackInstructionEntry,
5913 indent: &str,
5914) -> Option<String> {
5915 let semantic_params_type = entry.semantic_params_type.as_ref()?;
5916 entry.runtime_program_key.as_ref()?;
5917
5918 if entry.semantic_amount_args.is_empty() {
5919 return Some(format!(
5920 "{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}}}),",
5921 indent = indent,
5922 instruction_name = entry.instruction_name,
5923 semantic_params_type = semantic_params_type,
5924 handler_const = entry.handler_const,
5925 ));
5926 }
5927
5928 let raw_params_setup = if entry.semantic_extra_params.is_empty() {
5929 format!(
5930 "{indent} const {{ build, ...rawParams }} = params;",
5931 indent = indent
5932 )
5933 } else {
5934 format!(
5935 "{indent} const {{ build, {extras}, ...rawParams }} = params;",
5936 indent = indent,
5937 extras = entry.semantic_extra_params.join(", "),
5938 )
5939 };
5940 let resolution_lines: Vec<String> = entry
5941 .semantic_amount_args
5942 .iter()
5943 .map(|amount_arg| {
5944 format!(
5945 "{indent} const {binding_name} = {raw_expression};",
5946 indent = indent,
5947 binding_name = amount_arg.binding_name,
5948 raw_expression = amount_arg.raw_expression,
5949 )
5950 })
5951 .collect();
5952 let raw_assignments: Vec<String> = entry
5953 .semantic_amount_args
5954 .iter()
5955 .map(|amount_arg| {
5956 format!(
5957 "{indent} {}: {},",
5958 render_ts_property_name_literal(&amount_arg.arg_name),
5959 amount_arg.binding_name,
5960 indent = indent,
5961 )
5962 })
5963 .collect();
5964
5965 Some(format!(
5966 "{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} }}),",
5967 indent = indent,
5968 instruction_name = entry.instruction_name,
5969 semantic_params_type = semantic_params_type,
5970 raw_params_setup = raw_params_setup,
5971 resolutions = resolution_lines.join("\n"),
5972 handler_const = entry.handler_const,
5973 assignments = raw_assignments.join("\n"),
5974 ))
5975}
5976
5977fn generate_program_semantic_instructions_block(
5978 instruction_entries: &[&crate::typescript_instructions::StackInstructionEntry],
5979 indent: &str,
5980) -> Option<String> {
5981 let entry_indent = format!("{} ", indent);
5982 let entries: Vec<String> = instruction_entries
5983 .iter()
5984 .filter_map(|entry| render_program_semantic_instruction_entry(entry, &entry_indent))
5985 .collect();
5986 if entries.is_empty() {
5987 return None;
5988 }
5989
5990 let context_param = if instruction_entries
5991 .iter()
5992 .any(|entry| entry.uses_operation_context)
5993 {
5994 "context: ProgramOperationContext"
5995 } else {
5996 ""
5997 };
5998
5999 Some(format!(
6000 "{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}}},",
6001 indent = indent,
6002 context_param = context_param,
6003 entries = entries.join("\n"),
6004 ))
6005}
6006
6007fn generate_view_helpers_static() -> String {
6008 r#"// ============================================================================
6009// View Definition Types (framework-agnostic)
6010// ============================================================================
6011
6012export type ViewKeyFields<TKey> = unknown extends TKey
6013 ? readonly string[]
6014 : TKey extends object
6015 ? readonly Extract<keyof TKey, string>[]
6016 : readonly string[];
6017
6018/** View definition with embedded entity and state-key types */
6019export interface ViewDef<T, TMode extends 'state' | 'list', TKey = unknown> {
6020 readonly mode: TMode;
6021 readonly view: string;
6022 readonly keyFields?: ViewKeyFields<TKey>;
6023 /** Phantom field for type inference - not present at runtime */
6024 readonly _entity?: T;
6025 readonly _key?: TKey;
6026}
6027
6028/** Helper to create typed state view definitions (keyed lookups) */
6029function stateView<T, TKey = unknown>(
6030 view: string,
6031 keyFields: ViewKeyFields<TKey>
6032): ViewDef<T, 'state', TKey> {
6033 return { mode: 'state', view, keyFields } as const;
6034}
6035
6036/** Helper to create typed list view definitions (collections) */
6037function listView<T>(view: string): ViewDef<T, 'list'> {
6038 return { mode: 'list', view } as const;
6039}"#
6040 .to_string()
6041}
6042
6043pub fn program_key(idl_name: &str) -> String {
6046 to_camel_case(idl_name)
6047}
6048
6049pub fn program_const_name(idl_name: &str) -> String {
6052 ts_ident::identifier(idl_name, IdentifierCase::ScreamingSnake)
6053}
6054
6055#[cfg(test)]
6056mod tests {
6057 use super::*;
6058
6059 fn demo_program_spec() -> arete_hash::ProgramSpecV1 {
6060 arete_hash::build_program_spec_v1_from_bytes(
6061 br#"{
6062 "address":"Prog111",
6063 "version":"0.1.0",
6064 "name":"demo",
6065 "instructions":[],
6066 "accounts":[],
6067 "types":[],
6068 "events":[],
6069 "errors":[]
6070 }"#,
6071 None,
6072 )
6073 .expect("test ProgramSpecV1")
6074 }
6075
6076 fn named_program_spec(name: &str, program_id: &str) -> arete_hash::ProgramSpecV1 {
6077 arete_hash::build_program_spec_v1_from_bytes(
6078 format!(
6079 r#"{{
6080 "address":"{program_id}",
6081 "version":"0.1.0",
6082 "name":"{name}",
6083 "instructions":[],
6084 "accounts":[],
6085 "types":[],
6086 "events":[],
6087 "errors":[]
6088 }}"#
6089 )
6090 .as_bytes(),
6091 None,
6092 )
6093 .expect("named test ProgramSpecV1")
6094 }
6095
6096 fn two_program_test_spec() -> SerializableStackSpec {
6097 let specs = vec![
6098 named_program_spec("second_program", "Program222"),
6099 named_program_spec("first_program", "Program111"),
6100 ];
6101 SerializableStackSpec {
6102 ast_version: CURRENT_AST_VERSION.to_string(),
6103 stack_name: "OrderedStream".to_string(),
6104 program_ids: specs.iter().map(|spec| spec.program_id.clone()).collect(),
6105 idls: specs
6106 .iter()
6107 .map(|spec| spec.idl_snapshot.snapshot.clone())
6108 .collect(),
6109 program_specs: specs,
6110 entities: vec![],
6111 pdas: BTreeMap::new(),
6112 instructions: vec![],
6113 content_hash: None,
6114 }
6115 }
6116
6117 fn program_only_test_spec(
6118 pdas: BTreeMap<String, BTreeMap<String, PdaDefinition>>,
6119 instructions: Vec<InstructionDef>,
6120 ) -> SerializableStackSpec {
6121 let program_spec = demo_program_spec();
6122 SerializableStackSpec {
6123 ast_version: CURRENT_AST_VERSION.to_string(),
6124 stack_name: "DemoStream".to_string(),
6125 program_ids: vec!["Prog111".to_string()],
6126 idls: vec![program_spec.idl_snapshot.snapshot.clone()],
6127 program_specs: vec![program_spec],
6128 entities: vec![],
6129 pdas,
6130 instructions,
6131 content_hash: None,
6132 }
6133 }
6134
6135 fn test_instruction(amount_hint: Option<InstructionAmountHint>) -> InstructionDef {
6136 InstructionDef {
6137 name: "deposit".to_string(),
6138 discriminator: vec![9],
6139 discriminator_size: 1,
6140 accounts: vec![],
6141 args: amount_hint
6142 .map(|amount_hint| InstructionArgDef {
6143 name: "amount".to_string(),
6144 arg_type: "u64".to_string(),
6145 docs: vec![],
6146 amount_hint: Some(amount_hint),
6147 })
6148 .into_iter()
6149 .collect(),
6150 errors: vec![],
6151 program_id: Some("Prog111".to_string()),
6152 docs: vec![],
6153 }
6154 }
6155
6156 fn state_key_test_spec(primary_keys: Vec<&str>) -> SerializableStreamSpec {
6157 let round_id = FieldTypeInfo::new("round_id".to_string(), "u64".to_string());
6158 SerializableStreamSpec {
6159 ast_version: CURRENT_AST_VERSION.to_string(),
6160 state_name: "OreRound".to_string(),
6161 program_id: None,
6162 idl: None,
6163 identity: IdentitySpec {
6164 primary_keys: primary_keys.into_iter().map(str::to_string).collect(),
6165 lookup_indexes: vec![],
6166 },
6167 handlers: vec![],
6168 sections: vec![EntitySection {
6169 name: "id".to_string(),
6170 fields: vec![round_id.clone()],
6171 is_nested_struct: false,
6172 parent_field: None,
6173 }],
6174 field_mappings: BTreeMap::from([("id.round_id".to_string(), round_id)]),
6175 resolver_hooks: vec![],
6176 instruction_hooks: vec![],
6177 resolver_specs: vec![],
6178 computed_fields: vec![],
6179 computed_field_specs: vec![],
6180 content_hash: None,
6181 views: vec![],
6182 }
6183 }
6184
6185 fn endpoint_test_spec() -> SerializableStackSpec {
6186 SerializableStackSpec {
6187 ast_version: CURRENT_AST_VERSION.to_string(),
6188 stack_name: "EndpointStream".to_string(),
6189 program_ids: vec![],
6190 idls: vec![],
6191 program_specs: vec![],
6192 entities: vec![state_key_test_spec(vec!["id.round_id"])],
6193 pdas: BTreeMap::new(),
6194 instructions: vec![],
6195 content_hash: None,
6196 }
6197 }
6198
6199 #[test]
6200 fn test_case_conversions() {
6201 assert_eq!(to_pascal_case("settlement_game"), "SettlementGame");
6202 assert_eq!(
6203 to_pascal_case("sb_on_demand::actions::Submission"),
6204 "SbOnDemandActionsSubmission"
6205 );
6206 assert_eq!(to_kebab_case("SettlementGame"), "settlement-game");
6207 }
6208
6209 #[test]
6210 fn idl_field_normalization_disambiguates_leading_underscore_collisions() {
6211 let fields = vec![
6212 IdlFieldSnapshot {
6213 name: "_padding_0".to_string(),
6214 type_: IdlTypeSnapshot::Simple("u8".to_string()),
6215 amount_hint: None,
6216 },
6217 IdlFieldSnapshot {
6218 name: "padding_0".to_string(),
6219 type_: IdlTypeSnapshot::Simple("u8".to_string()),
6220 amount_hint: None,
6221 },
6222 ];
6223
6224 let normalized = normalize_idl_fields(&fields, &BTreeMap::new());
6225 assert_eq!(
6226 normalized
6227 .iter()
6228 .map(|field| field.name.as_str())
6229 .collect::<Vec<_>>(),
6230 vec!["_padding0", "padding0"]
6231 );
6232
6233 let interface =
6234 generate_interface_from_idl_fields("CollisionLayout", &fields, &BTreeMap::new());
6235 assert!(interface.contains("_padding0: number;"));
6236 assert!(interface.contains("padding0: number;"));
6237
6238 let schema = generate_schema_from_idl_fields("CollisionLayout", &fields, &BTreeMap::new());
6239 assert!(schema.contains("_padding_0: z.number(),"));
6240 assert!(schema.contains("padding_0: z.number(),"));
6241 assert!(schema.contains("_padding0: value._padding_0,"));
6242 assert!(schema.contains("padding0: value.padding_0,"));
6243 }
6244
6245 #[test]
6246 fn local_stack_codegen_is_endpointless_by_default() {
6247 let output = compile_stack_spec(endpoint_test_spec(), None)
6248 .expect("local stack generation should succeed");
6249
6250 assert!(output.stack_definition.contains(
6251 " endpoints: {\n ws: '', // TODO: Set after first deployment or pass useArete(..., { url })\n http: '', // TODO: Set after first deployment or pass useArete(..., { httpUrl })\n },"
6252 ));
6253 }
6254
6255 #[test]
6256 fn stack_codegen_emits_independent_endpoints_exactly() {
6257 let websocket_url = "wss://stream.example.test/ws/v2?tenant=endpoint";
6258 let http_url = "https://reads.unrelated.test/api/arete/v3";
6259 let output = compile_stack_spec(
6260 endpoint_test_spec(),
6261 Some(TypeScriptStackConfig {
6262 websocket_url: Some(websocket_url.to_string()),
6263 http_url: Some(http_url.to_string()),
6264 ..TypeScriptStackConfig::default()
6265 }),
6266 )
6267 .expect("configured stack generation should succeed");
6268
6269 assert!(output.stack_definition.contains(&format!(
6270 " endpoints: {{\n ws: '{}',\n http: '{}',\n }},",
6271 websocket_url, http_url
6272 )));
6273 assert!(!output
6274 .stack_definition
6275 .contains("https://stream.example.test/ws/v2"));
6276 }
6277
6278 #[test]
6279 fn explicit_local_websocket_does_not_derive_http() {
6280 let output = compile_stack_spec(
6281 endpoint_test_spec(),
6282 Some(TypeScriptStackConfig {
6283 websocket_url: Some("ws://127.0.0.1:8878/socket".to_string()),
6284 ..TypeScriptStackConfig::default()
6285 }),
6286 )
6287 .expect("configured local stack generation should succeed");
6288
6289 assert!(output
6290 .stack_definition
6291 .contains(" ws: 'ws://127.0.0.1:8878/socket',"));
6292 assert!(output.stack_definition.contains(
6293 " http: '', // TODO: Set after first deployment or pass useArete(..., { httpUrl })"
6294 ));
6295 assert!(!output
6296 .stack_definition
6297 .contains("http://127.0.0.1:8878/socket"));
6298 }
6299
6300 #[test]
6301 fn test_normalize_for_comparison() {
6302 assert_eq!(normalize_for_comparison("claim_sol"), "claimsol");
6303 assert_eq!(normalize_for_comparison("claimSol"), "claimsol");
6304 assert_eq!(normalize_for_comparison("ClaimSol"), "claimsol");
6305 assert_eq!(
6306 normalize_for_comparison("admin_set_creator"),
6307 "adminsetcreator"
6308 );
6309 assert_eq!(
6310 normalize_for_comparison("AdminSetCreator"),
6311 "adminsetcreator"
6312 );
6313 }
6314
6315 #[test]
6316 fn test_value_to_typescript_type() {
6317 assert_eq!(value_to_typescript_type(&serde_json::json!(42)), "number");
6318 assert_eq!(
6319 value_to_typescript_type(&serde_json::json!("hello")),
6320 "string"
6321 );
6322 assert_eq!(
6323 value_to_typescript_type(&serde_json::json!(true)),
6324 "boolean"
6325 );
6326 assert_eq!(value_to_typescript_type(&serde_json::json!([])), "any[]");
6327 }
6328
6329 #[test]
6330 fn test_typescript_scalar_array_element() {
6331 assert_eq!(
6332 typescript_scalar_array_element("Vec < f64 >"),
6333 Some("number")
6334 );
6335 assert_eq!(typescript_scalar_array_element("Vec<f32>"), Some("number"));
6336 assert_eq!(typescript_scalar_array_element("f64"), Some("number"));
6337 assert_eq!(
6338 typescript_scalar_array_element("Vec < bool >"),
6339 Some("boolean")
6340 );
6341 assert_eq!(
6342 typescript_scalar_array_element("Vec < String >"),
6343 Some("string")
6344 );
6345 assert_eq!(typescript_scalar_array_element("Vec < u64 >"), None);
6346 assert_eq!(typescript_scalar_array_element("Vec < Pubkey >"), None);
6347 }
6348
6349 #[test]
6350 fn state_view_codegen_emits_exact_key_type_and_deduped_runtime_metadata() {
6351 let output = compile_serializable_spec(
6352 state_key_test_spec(vec!["id.round_id", "id.round_id"]),
6353 "OreRound".to_string(),
6354 None,
6355 )
6356 .expect("duplicate identical keys should compile");
6357
6358 assert!(output.stack_definition.contains(
6359 "export interface ViewDef<T, TMode extends 'state' | 'list', TKey = unknown>"
6360 ));
6361 assert!(output.stack_definition.contains(
6362 "state: stateView<OreRound, { roundId: bigint }>('OreRound/state', ['roundId'])"
6363 ));
6364 assert_eq!(output.stack_definition.matches("['roundId']").count(), 1);
6365 }
6366
6367 #[test]
6368 fn state_view_codegen_rejects_distinct_composite_keys() {
6369 let mut spec = state_key_test_spec(vec!["id.round_id", "id.authority"]);
6370 spec.field_mappings.insert(
6371 "id.authority".to_string(),
6372 FieldTypeInfo::new("authority".to_string(), "String".to_string()),
6373 );
6374
6375 let error = compile_serializable_spec(spec, "OreRound".to_string(), None)
6376 .expect_err("distinct composite keys must fail generation");
6377
6378 assert!(error.contains("does not support composite state keys"));
6379 assert!(error.contains("id.round_id, id.authority"));
6380 }
6381
6382 #[test]
6383 fn pda_imports_follow_emitted_seed_variants() {
6384 let empty_seed_pdas = BTreeMap::from([(
6385 "demo".to_string(),
6386 BTreeMap::from([(
6387 "singleton".to_string(),
6388 PdaDefinition {
6389 name: "singleton".to_string(),
6390 seeds: vec![],
6391 program_id: None,
6392 program: None,
6393 },
6394 )]),
6395 )]);
6396 let output = compile_program_modules(program_only_test_spec(empty_seed_pdas, vec![]), None)
6397 .expect("empty-seed PDA generation should succeed");
6398 assert_eq!(
6399 output.imports,
6400 "import { z } from 'zod';\nimport { pda } from '@usearete/sdk';"
6401 );
6402 assert!(output
6403 .stack_definition
6404 .contains("singleton: pda('Prog111'),"));
6405 assert!(!output.stack_definition.contains("pda('Prog111', )"));
6406
6407 let literal_account_pdas = BTreeMap::from([(
6408 "demo".to_string(),
6409 BTreeMap::from([(
6410 "vault".to_string(),
6411 PdaDefinition {
6412 name: "vault".to_string(),
6413 seeds: vec![
6414 PdaSeedDef::Literal {
6415 value: "vault".to_string(),
6416 },
6417 PdaSeedDef::AccountRef {
6418 account_name: "authority".to_string(),
6419 },
6420 ],
6421 program_id: None,
6422 program: None,
6423 },
6424 )]),
6425 )]);
6426 let output =
6427 compile_program_modules(program_only_test_spec(literal_account_pdas, vec![]), None)
6428 .expect("literal/account PDA generation should succeed");
6429 assert_eq!(
6430 output.imports,
6431 "import { z } from 'zod';\nimport { pda, literal, account } from '@usearete/sdk';"
6432 );
6433
6434 let arg_bytes_pdas = BTreeMap::from([(
6435 "demo".to_string(),
6436 BTreeMap::from([(
6437 "position".to_string(),
6438 PdaDefinition {
6439 name: "position".to_string(),
6440 seeds: vec![
6441 PdaSeedDef::ArgRef {
6442 arg_name: "roundId".to_string(),
6443 arg_type: Some("u64".to_string()),
6444 },
6445 PdaSeedDef::Bytes {
6446 value: vec![0, 255],
6447 },
6448 ],
6449 program_id: None,
6450 program: None,
6451 },
6452 )]),
6453 )]);
6454 let output = compile_program_modules(program_only_test_spec(arg_bytes_pdas, vec![]), None)
6455 .expect("arg/bytes PDA generation should succeed");
6456 assert_eq!(
6457 output.imports,
6458 "import { z } from 'zod';\nimport { pda, arg, bytes } from '@usearete/sdk';"
6459 );
6460 }
6461
6462 fn emitted_definition_hash(output: &TypeScriptStackOutput) -> &str {
6463 output
6464 .stack_definition
6465 .lines()
6466 .find(|line| line.trim_start().starts_with("sdkDefinitionHash:"))
6467 .unwrap()
6468 .trim()
6469 }
6470
6471 #[test]
6472 fn program_content_identity_tracks_generated_pda_behavior_with_the_same_input_hash() {
6473 let spec = program_only_test_spec(BTreeMap::new(), vec![]);
6474 let initial = compile_program_modules(spec.clone(), None).unwrap();
6475 let mut changed = spec.clone();
6476 changed.pdas.insert(
6477 "demo".into(),
6478 BTreeMap::from([(
6479 "position".into(),
6480 PdaDefinition {
6481 name: "position".into(),
6482 seeds: vec![PdaSeedDef::Bytes {
6483 value: vec![1, 2, 3],
6484 }],
6485 program_id: None,
6486 program: None,
6487 },
6488 )]),
6489 );
6490 assert_eq!(
6491 spec.program_specs[0].hash().unwrap(),
6492 changed.program_specs[0].hash().unwrap()
6493 );
6494 let changed = compile_program_modules(changed, None).unwrap();
6495 assert_ne!(
6496 emitted_definition_hash(&initial),
6497 emitted_definition_hash(&changed)
6498 );
6499 assert_eq!(
6500 emitted_definition_hash(&initial),
6501 emitted_definition_hash(&compile_program_modules(spec, None).unwrap())
6502 );
6503 }
6504
6505 #[test]
6506 fn program_modules_recompute_content_identity_instead_of_trusting_input() {
6507 let stack_spec = program_only_test_spec(BTreeMap::new(), vec![]);
6508 let mut program = TypeScriptProgramConfig::from(
6509 &arete_hash::OssProgramIdentityV1::new(stack_spec.program_specs[0].clone()).unwrap(),
6510 );
6511 let initial = compile_program_modules(stack_spec.clone(), None).unwrap();
6512 program.definition.sdk_definition_hash = Some("definition-v1".to_string());
6513 program.release.program_release_hash =
6514 "arete:h1:program-release:sha256:different-release".into();
6515 let output = compile_program_modules(
6516 stack_spec,
6517 Some(TypeScriptStackConfig {
6518 programs: Some(vec![program]),
6519 ..TypeScriptStackConfig::default()
6520 }),
6521 )
6522 .expect("program definition generation should succeed");
6523
6524 assert_eq!(
6525 emitted_definition_hash(&initial),
6526 emitted_definition_hash(&output)
6527 );
6528 assert_ne!(initial.full_file(), output.full_file());
6529 assert!(!output.stack_definition.contains("definition-v1"));
6530 assert!(output
6531 .stack_definition
6532 .contains("sdkDefinitionHash: 'arete:h1:sdk-definition:sha256:"));
6533 }
6534
6535 #[test]
6536 fn program_modules_separate_portable_definition_from_release_reference() {
6537 let mut stack_spec = program_only_test_spec(BTreeMap::new(), vec![]);
6538 let mut local = TypeScriptProgramConfig::from(
6539 &arete_hash::OssProgramIdentityV1::new(stack_spec.program_specs[0].clone()).unwrap(),
6540 );
6541 local.definition.sdk_definition_hash = Some("portable-definition".to_string());
6542 let hosted_release = "arete:h1:program-release:sha256:hosted";
6543 let output = compile_program_modules(
6544 stack_spec.clone(),
6545 Some(TypeScriptStackConfig {
6546 programs: Some(vec![TypeScriptProgramConfig {
6547 release: TypeScriptProgramReleaseReference {
6548 program_release_hash: hosted_release.to_string(),
6549 program_spec_hash: local.definition.program_spec_hash.clone(),
6550 },
6551 ..local.clone()
6552 }]),
6553 ..TypeScriptStackConfig::default()
6554 }),
6555 )
6556 .expect("program definition generation should succeed");
6557 let definition = output.stack_definition;
6558
6559 assert!(definition.contains("sdkDefinitionHash: 'arete:h1:sdk-definition:sha256:"));
6560 assert!(definition.contains(&format!(
6561 "programSpecHash: '{}',",
6562 local.definition.program_spec_hash
6563 )));
6564 let programs = definition
6565 .split("/** Release and explicit read transport")
6566 .next()
6567 .unwrap();
6568 assert!(!programs.contains("programReleaseHash"));
6569 assert!(!programs.contains("decoderEngineId"));
6570 assert!(definition.contains(&format!("programReleaseHash: \"{hosted_release}\"")));
6571 assert!(!definition.contains("decoderEngineId"));
6572 stack_spec.program_specs.clear();
6573 assert!(compile_program_modules(stack_spec, None)
6574 .unwrap_err()
6575 .contains("Rebuild the ProgramSpec and StackManifest artifact closure"));
6576 }
6577
6578 #[test]
6579 fn program_package_release_stays_out_of_the_core_definition() {
6580 let stack_spec = program_only_test_spec(BTreeMap::new(), vec![]);
6581 let mut program = TypeScriptProgramConfig::from(
6582 &arete_hash::OssProgramIdentityV1::new(stack_spec.program_specs[0].clone()).unwrap(),
6583 );
6584 let local = compile_program_modules(
6585 stack_spec.clone(),
6586 Some(TypeScriptStackConfig {
6587 programs: Some(vec![program.clone()]),
6588 ..TypeScriptStackConfig::default()
6589 }),
6590 )
6591 .unwrap();
6592 let release = format!(
6593 "arete:registry-package-release:v2:sha256:{}",
6594 "7".repeat(64)
6595 );
6596 program.definition.package_release_hash = Some(release.clone());
6597 let registry = compile_program_modules(
6598 stack_spec,
6599 Some(TypeScriptStackConfig {
6600 programs: Some(vec![program]),
6601 ..TypeScriptStackConfig::default()
6602 }),
6603 )
6604 .unwrap();
6605
6606 assert!(!local.stack_definition.contains("packageReleaseHash"));
6609 assert!(!registry.stack_definition.contains("packageReleaseHash"));
6610 assert_eq!(local.stack_definition, registry.stack_definition);
6611 assert_eq!(
6612 emitted_definition_hash(&local),
6613 emitted_definition_hash(®istry),
6614 "the same generated content keeps one definition hash"
6615 );
6616 }
6617
6618 #[test]
6622 fn address_lookup_table_program_sdk_derives_lookup_table_on_create_only() {
6623 let spec = crate::program_sdk::build_program_only_stack_spec_from_idl_bytes(
6624 include_bytes!("../../arete-idl/tests/fixtures/address-lookup-table.json"),
6625 None,
6626 "AddressLookupTable",
6627 )
6628 .expect("ALT program-only stack spec should build");
6629 let output =
6630 compile_program_modules(spec, None).expect("typescript program SDK generation");
6631 let code = output.interfaces;
6634
6635 for tag in 0u8..5 {
6636 assert!(
6637 code.contains(&format!("discriminator: [{tag}, 0, 0, 0],")),
6638 "instruction tag {tag} must be a u32-LE bincode discriminator:\n{code}"
6639 );
6640 }
6641 assert!(
6642 code.contains("vecU64Len"),
6643 "extend_lookup_table must keep its u64 vector length prefix:\n{code}"
6644 );
6645 assert!(
6646 output.pda_degradations.is_empty(),
6647 "no PDA should degrade: {:?}",
6648 output.warnings
6649 );
6650
6651 let lookup_table_accounts: Vec<&str> = code
6652 .lines()
6653 .filter(|line| {
6654 line.contains("category: '")
6655 && line
6656 .to_ascii_lowercase()
6657 .replace('_', "")
6658 .contains("name: 'lookuptable'")
6659 })
6660 .collect();
6661 assert_eq!(lookup_table_accounts.len(), 5, "{code}");
6662 let derived = lookup_table_accounts
6663 .iter()
6664 .filter(|line| line.contains("category: 'pda', pdaConfig:"))
6665 .count();
6666 let caller_provided = lookup_table_accounts
6667 .iter()
6668 .filter(|line| line.contains("category: 'userProvided'"))
6669 .count();
6670 assert_eq!((derived, caller_provided), (1, 4), "{code}");
6671 }
6672
6673 #[test]
6674 fn program_account_codegen_is_semantic_and_release_lives_in_program_reads() {
6675 let identity = crate::program_sdk::build_oss_program_identity_v1_from_idl_bytes(
6676 include_bytes!("../../arete-macros/tests/fixtures/nested-computed.idl.json"),
6677 None,
6678 )
6679 .expect("fixture identity");
6680 let stack_spec =
6681 crate::program_sdk::build_program_only_stack_spec_from_identity(&identity, "Presale");
6682 let output = compile_program_modules(stack_spec, None).expect("program SDK generation");
6683
6684 assert!(output.stack_definition.contains(
6685 "programAccountRead<Presale>({ account: 'Presale', schema: PresaleSchema })"
6686 ));
6687 assert!(!output.stack_definition.contains("path:"));
6688 assert!(output.stack_definition.contains(&format!(
6689 "programReleaseHash: \"{}\"",
6690 identity.release_hash
6691 )));
6692 assert!(output
6693 .stack_definition
6694 .contains("transport: { kind: 'local-http', endpointSource: 'connect-http-url' }"));
6695 assert!(!output.stack_definition.contains("path:"));
6696 }
6697
6698 #[test]
6699 fn legacy_account_reader_ast_without_exact_program_specs_fails_closed() {
6700 let identity = crate::program_sdk::build_oss_program_identity_v1_from_idl_bytes(
6701 include_bytes!("../../arete-macros/tests/fixtures/nested-computed.idl.json"),
6702 None,
6703 )
6704 .expect("fixture identity");
6705 let mut stack_spec =
6706 crate::program_sdk::build_program_only_stack_spec_from_identity(&identity, "Presale");
6707 assert!(!stack_spec.idls[0].accounts.is_empty());
6708 stack_spec.program_specs.clear();
6709
6710 let error = compile_program_modules(stack_spec, None).unwrap_err();
6711 assert!(error.contains("no exact public ProgramSpecV1 values"));
6712 assert!(error.contains("Rebuild the ProgramSpec and StackManifest artifact closure"));
6713 }
6714
6715 #[test]
6716 fn hosted_program_configs_preserve_order_releases_endpoints_and_auth() {
6717 let stack_spec = two_program_test_spec();
6718 let programs = stack_spec
6719 .program_specs
6720 .iter()
6721 .enumerate()
6722 .map(|(index, spec)| {
6723 let identity = arete_hash::OssProgramIdentityV1::new(spec.clone()).unwrap();
6724 let mut config = TypeScriptProgramConfig::from(&identity);
6725 config.release.program_release_hash = format!("hosted-release-{index}");
6726 let binding_id = format!("prb_{index:032}");
6727 config.transport =
6728 TypeScriptProgramReadTransport::HostedBinding(TypeScriptProgramReadBinding {
6729 endpoint: format!("https://reads.example.test/exact/{index}/"),
6730 program_read_binding_id: binding_id.clone(),
6731 auth: serde_json::json!({
6732 "targetKind": "program-read-binding",
6733 "targetId": binding_id,
6734 "sessionEndpoint": format!("https://auth.example.test/{index}"),
6735 "index": index
6736 }),
6737 });
6738 config
6739 })
6740 .collect::<Vec<_>>();
6741
6742 let output = compile_stack_spec(
6743 stack_spec,
6744 Some(TypeScriptStackConfig {
6745 programs: Some(programs),
6746 ..Default::default()
6747 }),
6748 )
6749 .expect("ordered hosted descriptors should compile");
6750 let generated = output.stack_definition;
6751 let portable = generated
6752 .split(" programs: {")
6753 .nth(1)
6754 .expect("portable programs block")
6755 .split(" programReads: {")
6756 .next()
6757 .expect("portable programs block end");
6758 let reads = generated
6759 .split(" programReads: {")
6760 .nth(1)
6761 .expect("parallel program reads block");
6762
6763 assert!(portable.find("secondProgram:").unwrap() < portable.find("firstProgram:").unwrap());
6764 assert!(!portable.contains("programReleaseHash"));
6765 assert!(!portable.contains("endpoint"));
6766 assert!(reads.find("secondProgram:").unwrap() < reads.find("firstProgram:").unwrap());
6767 assert!(reads.contains("programReleaseHash: \"hosted-release-0\""));
6768 assert!(reads.contains("programReleaseHash: \"hosted-release-1\""));
6769 assert!(reads.contains("kind: 'hosted-binding'"));
6770 assert!(reads.contains("endpoint: \"https://reads.example.test/exact/0/\""));
6771 assert!(reads.contains("programReadBindingId: \"prb_00000000000000000000000000000001\""));
6772 assert!(reads.contains(
6773 "auth: {\"index\":0,\"sessionEndpoint\":\"https://auth.example.test/0\",\"targetId\":\"prb_00000000000000000000000000000000\",\"targetKind\":\"program-read-binding\"}"
6774 ));
6775 }
6776
6777 #[test]
6778 fn hosted_program_config_mismatches_fail_by_index_without_name_fallback() {
6779 let stack_spec = two_program_test_spec();
6780 let local = stack_spec
6781 .program_specs
6782 .iter()
6783 .map(|spec| {
6784 TypeScriptProgramConfig::from(
6785 &arete_hash::OssProgramIdentityV1::new(spec.clone()).unwrap(),
6786 )
6787 })
6788 .collect::<Vec<_>>();
6789
6790 let count_error = compile_program_modules(
6791 stack_spec.clone(),
6792 Some(TypeScriptStackConfig {
6793 programs: Some(vec![local[0].clone()]),
6794 ..Default::default()
6795 }),
6796 )
6797 .unwrap_err();
6798 assert!(count_error.contains("descriptor count mismatch"));
6799
6800 let mut swapped = local.clone();
6801 swapped.swap(0, 1);
6802 let order_error = compile_program_modules(
6803 stack_spec.clone(),
6804 Some(TypeScriptStackConfig {
6805 programs: Some(swapped),
6806 ..Default::default()
6807 }),
6808 )
6809 .unwrap_err();
6810 assert!(order_error.contains("program ID mismatch at index 0"));
6811
6812 let mut bad_hash = local;
6813 bad_hash[1].definition.program_spec_hash = "wrong-spec".to_string();
6814 let hash_error = compile_program_modules(
6815 stack_spec,
6816 Some(TypeScriptStackConfig {
6817 programs: Some(bad_hash),
6818 ..Default::default()
6819 }),
6820 )
6821 .unwrap_err();
6822 assert!(hash_error.contains("programSpecHash mismatch at index 1"));
6823 }
6824
6825 #[test]
6826 fn raw_operations_omit_semantic_only_types_and_context() {
6827 let output = compile_program_modules(
6828 program_only_test_spec(BTreeMap::new(), vec![test_instruction(None)]),
6829 None,
6830 )
6831 .expect("raw operation generation should succeed");
6832 let file = output.full_file();
6833
6834 assert!(!output.imports.contains("BuildOptions"));
6835 assert!(!output.imports.contains("ProgramOperationContext"));
6836 assert!(file.contains("createOperations()"));
6837 assert!(!file.contains("build?: BuildOptions;"));
6838 }
6839
6840 #[test]
6841 fn context_free_amount_operations_import_build_options_only() {
6842 let output = compile_program_modules(
6843 program_only_test_spec(
6844 BTreeMap::new(),
6845 vec![test_instruction(Some(InstructionAmountHint {
6846 decimals_source: AmountDecimalsSource::Constant { decimals: 9 },
6847 }))],
6848 ),
6849 None,
6850 )
6851 .expect("constant amount operation generation should succeed");
6852 let file = output.full_file();
6853
6854 assert!(output.imports.contains("type BuildOptions"));
6855 assert!(output.imports.contains("toRawAmount"));
6856 assert!(!output.imports.contains("ProgramOperationContext"));
6857 assert!(file.contains("build?: BuildOptions;"));
6858 assert!(file.contains("createOperations()"));
6859 assert!(!file.contains("context.chain"));
6860 }
6861
6862 #[test]
6863 fn streamed_entity_codegen_normalizes_canonical_names_and_schemas() {
6864 let spec = SerializableStreamSpec {
6865 ast_version: CURRENT_AST_VERSION.to_string(),
6866 state_name: "TokenPosition".to_string(),
6867 program_id: None,
6868 idl: None,
6869 identity: IdentitySpec {
6870 primary_keys: vec!["id.address".to_string()],
6871 lookup_indexes: vec![],
6872 },
6873 handlers: vec![],
6874 sections: vec![
6875 EntitySection {
6876 name: "root".to_string(),
6877 fields: vec![FieldTypeInfo::new(
6878 "total_deposit".to_string(),
6879 "u64".to_string(),
6880 )],
6881 is_nested_struct: false,
6882 parent_field: None,
6883 },
6884 EntitySection {
6885 name: "metrics".to_string(),
6886 fields: vec![FieldTypeInfo::new(
6887 "last_updated_at".to_string(),
6888 "i64".to_string(),
6889 )],
6890 is_nested_struct: false,
6891 parent_field: None,
6892 },
6893 ],
6894 field_mappings: BTreeMap::new(),
6895 resolver_hooks: vec![],
6896 resolver_specs: vec![],
6897 instruction_hooks: vec![],
6898 computed_fields: vec![],
6899 computed_field_specs: vec![],
6900 content_hash: None,
6901 views: vec![],
6902 };
6903
6904 let output = compile_serializable_spec(spec, "TokenPosition".to_string(), None)
6905 .expect("should compile");
6906 let file = output.full_file();
6907
6908 assert!(
6909 file.contains("export interface TokenPosition {"),
6910 "missing main interface:\n{}",
6911 file
6912 );
6913 assert!(
6914 file.contains("totalDeposit: bigint;"),
6915 "missing root canonical field:\n{}",
6916 file
6917 );
6918 assert!(
6919 file.contains("metrics: TokenPositionMetrics;"),
6920 "missing nested canonical section field:\n{}",
6921 file
6922 );
6923 assert!(
6924 file.contains("export interface TokenPositionMetrics {"),
6925 "missing nested interface:\n{}",
6926 file
6927 );
6928 assert!(
6929 file.contains("lastUpdatedAt: bigint;"),
6930 "missing nested canonical field:\n{}",
6931 file
6932 );
6933
6934 let bigint_schema = bigint_zod();
6935 assert!(
6936 file.contains("export const TokenPositionSchema = z.object({"),
6937 "missing main schema:\n{}",
6938 file
6939 );
6940 assert!(
6941 file.contains(&format!("total_deposit: {},", bigint_schema)),
6942 "missing raw root schema field:\n{}",
6943 file
6944 );
6945 assert!(
6946 file.contains("metrics: TokenPositionMetricsSchema,"),
6947 "missing nested schema ref:\n{}",
6948 file
6949 );
6950 assert!(
6951 file.contains("totalDeposit: value.total_deposit,"),
6952 "missing root transform:\n{}",
6953 file
6954 );
6955 assert!(
6956 file.contains("metrics: value.metrics,"),
6957 "missing nested transform:\n{}",
6958 file
6959 );
6960
6961 assert!(
6962 file.contains("export const TokenPositionMetricsSchema = z.object({"),
6963 "missing nested schema:\n{}",
6964 file
6965 );
6966 assert!(
6967 file.contains(&format!("last_updated_at: {},", bigint_schema)),
6968 "missing raw nested schema field:\n{}",
6969 file
6970 );
6971 assert!(
6972 file.contains("lastUpdatedAt: value.last_updated_at,"),
6973 "missing nested transform:\n{}",
6974 file
6975 );
6976
6977 assert!(
6978 file.contains("export const TokenPositionPatchSchema = z.object({"),
6979 "missing patch schema:\n{}",
6980 file
6981 );
6982 assert!(
6983 file.contains(&format!("total_deposit: {}.optional(),", bigint_schema)),
6984 "missing sparse patch field:\n{}",
6985 file
6986 );
6987 assert!(
6988 file.contains("metrics: TokenPositionMetricsPatchSchema.optional(),"),
6989 "missing nested patch schema ref:\n{}",
6990 file
6991 );
6992 assert!(
6993 file.contains("...(value.total_deposit !== undefined ? { totalDeposit: value.total_deposit } : {}),"),
6994 "missing sparse patch transform:\n{}",
6995 file
6996 );
6997 assert!(
6998 file.contains("export const TokenPositionMetricsPatchSchema = z.object({"),
6999 "missing nested patch schema:\n{}",
7000 file
7001 );
7002 assert!(
7003 file.contains("patchSchemas: {\n TokenPosition: TokenPositionPatchSchema,"),
7004 "missing stack patch schema map:\n{}",
7005 file
7006 );
7007 }
7008
7009 #[test]
7010 fn captured_accounts_keep_the_runtime_envelope_and_full_inner_schema() {
7011 let miner_snapshot = FieldTypeInfo {
7012 field_name: "miner_snapshot".to_string(),
7013 raw_name: Some("miner_snapshot".to_string()),
7014 canonical_name: Some("minerSnapshot".to_string()),
7015 rust_type_name: "Option<Miner>".to_string(),
7016 base_type: BaseType::Object,
7017 integer_kind: None,
7018 is_optional: true,
7019 is_array: false,
7020 inner_type: Some("Miner".to_string()),
7021 source_path: None,
7022 resolved_type: Some(ResolvedStructType {
7023 type_name: "Miner".to_string(),
7024 fields: vec![
7025 ResolvedField {
7026 field_name: "deployed".to_string(),
7027 raw_name: Some("deployed".to_string()),
7028 canonical_name: Some("deployed".to_string()),
7029 field_type: "u64".to_string(),
7030 base_type: BaseType::Integer,
7031 integer_kind: Some(IntegerKind::U64),
7032 is_optional: false,
7033 is_array: true,
7034 },
7035 ResolvedField {
7036 field_name: "round_id".to_string(),
7037 raw_name: Some("round_id".to_string()),
7038 canonical_name: Some("roundId".to_string()),
7039 field_type: "u64".to_string(),
7040 base_type: BaseType::Integer,
7041 integer_kind: Some(IntegerKind::U64),
7042 is_optional: false,
7043 is_array: false,
7044 },
7045 ],
7046 is_instruction: false,
7047 is_account: true,
7048 is_event: false,
7049 is_enum: false,
7050 enum_variants: vec![],
7051 }),
7052 emit: true,
7053 };
7054 let spec = SerializableStreamSpec {
7055 ast_version: CURRENT_AST_VERSION.to_string(),
7056 state_name: "OreMiner".to_string(),
7057 program_id: None,
7058 idl: None,
7059 identity: IdentitySpec {
7060 primary_keys: vec!["id.authority".to_string()],
7061 lookup_indexes: vec![],
7062 },
7063 handlers: vec![SerializableHandlerSpec {
7064 source: SourceSpec::Source {
7065 program_id: None,
7066 discriminator: None,
7067 type_name: "Miner".to_string(),
7068 serialization: None,
7069 is_account: true,
7070 },
7071 key_resolution: KeyResolutionStrategy::Embedded {
7072 primary_field: FieldPath::new(&["authority"]),
7073 },
7074 mappings: vec![SerializableFieldMapping {
7075 target_path: "miner_snapshot".to_string(),
7076 source: MappingSource::AsCapture {
7077 field_transforms: BTreeMap::new(),
7078 },
7079 transform: None,
7080 population: PopulationStrategy::LastWrite,
7081 condition: None,
7082 when: None,
7083 stop: None,
7084 emit: true,
7085 }],
7086 conditions: vec![],
7087 emit: true,
7088 }],
7089 sections: vec![
7090 EntitySection {
7091 name: "id".to_string(),
7092 fields: vec![FieldTypeInfo::new(
7093 "authority".to_string(),
7094 "String".to_string(),
7095 )],
7096 is_nested_struct: false,
7097 parent_field: None,
7098 },
7099 EntitySection {
7100 name: "root".to_string(),
7101 fields: vec![miner_snapshot],
7102 is_nested_struct: false,
7103 parent_field: None,
7104 },
7105 ],
7106 field_mappings: BTreeMap::new(),
7107 resolver_hooks: vec![],
7108 resolver_specs: vec![],
7109 instruction_hooks: vec![],
7110 computed_fields: vec![],
7111 computed_field_specs: vec![],
7112 content_hash: None,
7113 views: vec![],
7114 };
7115
7116 let output = compile_serializable_spec(spec, "OreMiner".to_string(), None)
7117 .expect("captured account generation should succeed");
7118 let file = output.full_file();
7119
7120 assert!(file.contains("minerSnapshot: CaptureWrapper<Miner> | null;"));
7121 assert!(file.contains("export interface CaptureWrapper<T> {"));
7122 assert!(file.contains("accountAddress: string;"));
7123 assert!(file.contains("account_address: z.string(),"));
7124 assert!(file.contains("accountAddress: value.account_address,"));
7125 assert!(file
7126 .contains("miner_snapshot: CaptureWrapperSchema(MinerSchema).nullable().optional(),"));
7127 assert!(file
7128 .contains("miner_snapshot: CaptureWrapperSchema(MinerSchema).nullable().optional(),"));
7129 assert!(!file.contains("CaptureWrapperSchema(MinerPatchSchema)"));
7130 }
7131
7132 #[test]
7133 fn streamed_builtin_token_metadata_codegen_is_canonical_and_sparse() {
7134 let spec = SerializableStreamSpec {
7135 ast_version: CURRENT_AST_VERSION.to_string(),
7136 state_name: "TokenHolder".to_string(),
7137 program_id: None,
7138 idl: None,
7139 identity: IdentitySpec {
7140 primary_keys: vec!["id.address".to_string()],
7141 lookup_indexes: vec![],
7142 },
7143 handlers: vec![],
7144 sections: vec![EntitySection {
7145 name: "root".to_string(),
7146 fields: vec![FieldTypeInfo {
7147 field_name: "base_token_metadata".to_string(),
7148 raw_name: Some("base_token_metadata".to_string()),
7149 canonical_name: Some("baseTokenMetadata".to_string()),
7150 rust_type_name: "Option<TokenMetadata>".to_string(),
7151 base_type: BaseType::Object,
7152 integer_kind: None,
7153 is_optional: true,
7154 is_array: false,
7155 inner_type: Some("TokenMetadata".to_string()),
7156 source_path: None,
7157 resolved_type: None,
7158 emit: true,
7159 }],
7160 is_nested_struct: false,
7161 parent_field: None,
7162 }],
7163 field_mappings: BTreeMap::new(),
7164 resolver_hooks: vec![],
7165 resolver_specs: vec![],
7166 instruction_hooks: vec![],
7167 computed_fields: vec![],
7168 computed_field_specs: vec![],
7169 content_hash: None,
7170 views: vec![],
7171 };
7172
7173 let output = compile_serializable_spec(spec, "TokenHolder".to_string(), None)
7174 .expect("should compile");
7175 let file = output.full_file();
7176
7177 assert!(
7178 file.contains("export interface TokenMetadata {"),
7179 "missing builtin interface:\n{}",
7180 file
7181 );
7182 assert!(
7183 file.contains("logoUri?: string | null;"),
7184 "missing canonical builtin field:\n{}",
7185 file
7186 );
7187 assert!(
7188 file.contains("export const TokenMetadataSchema = z.object({"),
7189 "missing builtin schema:\n{}",
7190 file
7191 );
7192 assert!(
7193 file.contains("logo_uri: z.string().nullable().optional(),"),
7194 "missing raw builtin input field:\n{}",
7195 file
7196 );
7197 assert!(
7198 file.contains("...(value.logo_uri !== undefined ? { logoUri: value.logo_uri } : {}),"),
7199 "missing canonical builtin transform:\n{}",
7200 file
7201 );
7202 assert!(
7203 file.contains("export const TokenMetadataPatchSchema = z.object({"),
7204 "missing builtin patch schema:\n{}",
7205 file
7206 );
7207 assert!(
7208 file.contains("base_token_metadata: TokenMetadataPatchSchema.nullable().optional(),"),
7209 "missing patch schema usage for builtin field:\n{}",
7210 file
7211 );
7212 }
7213
7214 #[test]
7215 fn streamed_section_codegen_localizes_prefixed_raw_field_names() {
7216 let spec = SerializableStreamSpec {
7217 ast_version: CURRENT_AST_VERSION.to_string(),
7218 state_name: "OreRound".to_string(),
7219 program_id: None,
7220 idl: None,
7221 identity: IdentitySpec {
7222 primary_keys: vec!["id.round_id".to_string()],
7223 lookup_indexes: vec![],
7224 },
7225 handlers: vec![],
7226 sections: vec![EntitySection {
7227 name: "results".to_string(),
7228 fields: vec![FieldTypeInfo {
7229 field_name: "results.expires_at_slot_hash".to_string(),
7230 raw_name: Some("results.expires_at_slot_hash".to_string()),
7231 canonical_name: Some("resultsExpiresAtSlotHash".to_string()),
7232 rust_type_name: "Option<String>".to_string(),
7233 base_type: BaseType::String,
7234 integer_kind: None,
7235 is_optional: true,
7236 is_array: false,
7237 inner_type: Some("String".to_string()),
7238 source_path: None,
7239 resolved_type: None,
7240 emit: true,
7241 }],
7242 is_nested_struct: false,
7243 parent_field: None,
7244 }],
7245 field_mappings: BTreeMap::new(),
7246 resolver_hooks: vec![],
7247 resolver_specs: vec![],
7248 instruction_hooks: vec![],
7249 computed_fields: vec![],
7250 computed_field_specs: vec![],
7251 content_hash: None,
7252 views: vec![],
7253 };
7254
7255 let output =
7256 compile_serializable_spec(spec, "OreRound".to_string(), None).expect("should compile");
7257 let file = output.full_file();
7258
7259 assert!(
7260 file.contains("export interface OreRoundResults {"),
7261 "missing section interface:\n{}",
7262 file
7263 );
7264 assert!(
7265 file.contains("expiresAtSlotHash: string | null;"),
7266 "missing localized canonical field:\n{}",
7267 file
7268 );
7269 assert!(
7270 file.contains("expires_at_slot_hash: z.string().nullable().optional(),"),
7271 "missing localized canonical schema field:\n{}",
7272 file
7273 );
7274 assert!(
7275 file.contains("expiresAtSlotHash: value.expires_at_slot_hash,"),
7276 "missing localized transform:\n{}",
7277 file
7278 );
7279 assert!(
7280 file.contains("expires_at_slot_hash: z.string().nullable().optional(),"),
7281 "missing localized patch schema field:\n{}",
7282 file
7283 );
7284 assert!(
7285 file.contains("...(value.expires_at_slot_hash !== undefined ? { expiresAtSlotHash: value.expires_at_slot_hash } : {}),"),
7286 "missing localized sparse transform:\n{}",
7287 file
7288 );
7289 }
7290
7291 #[test]
7292 fn test_streamed_completed_schema_allows_unwritten_nullable_fields() {
7293 let mut optional_count = FieldTypeInfo::new("count".to_string(), "u64".to_string());
7294 optional_count.is_optional = true;
7295 let spec = SerializableStreamSpec {
7296 ast_version: CURRENT_AST_VERSION.to_string(),
7297 state_name: "OreRound".to_string(),
7298 program_id: None,
7299 idl: None,
7300 identity: IdentitySpec {
7301 primary_keys: vec!["id.round_id".to_string()],
7302 lookup_indexes: vec![],
7303 },
7304 handlers: vec![],
7305 sections: vec![EntitySection {
7306 name: "state".to_string(),
7307 fields: vec![optional_count],
7308 is_nested_struct: false,
7309 parent_field: None,
7310 }],
7311 field_mappings: BTreeMap::new(),
7312 resolver_hooks: vec![],
7313 resolver_specs: vec![],
7314 instruction_hooks: vec![],
7315 computed_fields: vec![],
7316 computed_field_specs: vec![],
7317 content_hash: None,
7318 views: vec![],
7319 };
7320
7321 let output =
7322 compile_serializable_spec(spec, "OreRound".to_string(), None).expect("should compile");
7323 let file = output.full_file();
7324 assert!(
7325 file.contains("count: z.union([z.bigint(), z.string(), z.number().int()]).transform((value) => BigInt(value)).nullable().optional(),"),
7326 "completed schema should allow absent nullable fields:\n{}",
7327 file
7328 );
7329 }
7330
7331 #[test]
7332 fn test_derived_view_codegen() {
7333 let spec = SerializableStreamSpec {
7334 ast_version: CURRENT_AST_VERSION.to_string(),
7335 state_name: "OreRound".to_string(),
7336 program_id: None,
7337 idl: None,
7338 identity: IdentitySpec {
7339 primary_keys: vec!["id".to_string()],
7340 lookup_indexes: vec![],
7341 },
7342 handlers: vec![],
7343 sections: vec![],
7344 field_mappings: BTreeMap::new(),
7345 resolver_hooks: vec![],
7346 resolver_specs: vec![],
7347 instruction_hooks: vec![],
7348 computed_fields: vec![],
7349 computed_field_specs: vec![],
7350 content_hash: None,
7351 views: vec![
7352 ViewDef {
7353 id: "OreRound/latest".to_string(),
7354 source: ViewSource::Entity {
7355 name: "OreRound".to_string(),
7356 },
7357 pipeline: vec![ViewTransform::Last],
7358 output: ViewOutput::Single,
7359 },
7360 ViewDef {
7361 id: "OreRound/top10".to_string(),
7362 source: ViewSource::Entity {
7363 name: "OreRound".to_string(),
7364 },
7365 pipeline: vec![ViewTransform::Take { count: 10 }],
7366 output: ViewOutput::Collection,
7367 },
7368 ],
7369 };
7370
7371 let output =
7372 compile_serializable_spec(spec, "OreRound".to_string(), None).expect("should compile");
7373
7374 let stack_def = &output.stack_definition;
7375
7376 assert!(
7377 stack_def.contains("listView<OreRound>('OreRound/latest')"),
7378 "Expected 'latest' derived view using listView, got:\n{}",
7379 stack_def
7380 );
7381 assert!(
7382 stack_def.contains("listView<OreRound>('OreRound/top10')"),
7383 "Expected 'top10' derived view using listView, got:\n{}",
7384 stack_def
7385 );
7386 assert!(
7387 stack_def.contains("latest:"),
7388 "Expected 'latest' key, got:\n{}",
7389 stack_def
7390 );
7391 assert!(
7392 stack_def.contains("top10:"),
7393 "Expected 'top10' key, got:\n{}",
7394 stack_def
7395 );
7396 assert!(
7397 stack_def.contains("function listView<T>(view: string): ViewDef<T, 'list'>"),
7398 "Expected listView helper function, got:\n{}",
7399 stack_def
7400 );
7401 }
7402
7403 #[test]
7404 fn test_account_type_collision_uses_account_suffix() {
7405 let plan_field = FieldTypeInfo {
7406 field_name: "plan".to_string(),
7407 raw_name: Some("plan".to_string()),
7408 canonical_name: Some("plan".to_string()),
7409 rust_type_name: "Option<serde_json::Value>".to_string(),
7410 base_type: BaseType::Object,
7411 integer_kind: None,
7412 is_optional: false,
7413 is_array: false,
7414 inner_type: Some("Value".to_string()),
7415 source_path: None,
7416 resolved_type: Some(ResolvedStructType {
7417 type_name: "plan".to_string(),
7418 fields: vec![],
7419 is_instruction: false,
7420 is_account: true,
7421 is_event: false,
7422 is_enum: false,
7423 enum_variants: vec![],
7424 }),
7425 emit: true,
7426 };
7427
7428 let spec = SerializableStreamSpec {
7429 ast_version: CURRENT_AST_VERSION.to_string(),
7430 state_name: "Plan".to_string(),
7431 program_id: None,
7432 idl: None,
7433 identity: IdentitySpec {
7434 primary_keys: vec!["id.address".to_string()],
7435 lookup_indexes: vec![],
7436 },
7437 handlers: vec![],
7438 sections: vec![
7439 EntitySection {
7440 name: "id".to_string(),
7441 fields: vec![FieldTypeInfo::new(
7442 "address".to_string(),
7443 "String".to_string(),
7444 )],
7445 is_nested_struct: false,
7446 parent_field: None,
7447 },
7448 EntitySection {
7449 name: "plan".to_string(),
7450 fields: vec![plan_field],
7451 is_nested_struct: false,
7452 parent_field: None,
7453 },
7454 ],
7455 field_mappings: BTreeMap::new(),
7456 resolver_hooks: vec![],
7457 instruction_hooks: vec![],
7458 resolver_specs: vec![],
7459 computed_fields: vec![],
7460 computed_field_specs: vec![],
7461 content_hash: None,
7462 views: vec![],
7463 };
7464
7465 let output = compile_serializable_spec(spec, "Plan".to_string(), None)
7466 .expect("typescript sdk generation should succeed");
7467
7468 assert!(
7469 output.interfaces.contains("export interface PlanPlan {"),
7470 "expected PlanPlan section interface, got:\n{}",
7471 output.interfaces
7472 );
7473 assert!(
7474 output.interfaces.contains("plan: PlanAccount;"),
7475 "expected PlanAccount field reference, got:\n{}",
7476 output.interfaces
7477 );
7478 assert!(
7479 output.interfaces.contains("export interface PlanAccount {"),
7480 "expected PlanAccount interface, got:\n{}",
7481 output.interfaces
7482 );
7483 }
7484
7485 #[test]
7486 fn test_multi_entity_enum_dedup_uses_pascal_case_name_matching() {
7487 let shared_idl = serde_json::json!({
7488 "name": "subscriptions",
7489 "version": "0.1.0",
7490 "accounts": [],
7491 "instructions": [],
7492 "types": [
7493 {
7494 "name": "planStatus",
7495 "type": {
7496 "kind": "enum",
7497 "variants": [{ "name": "sunset" }, { "name": "active" }]
7498 }
7499 }
7500 ],
7501 "events": [],
7502 "errors": [],
7503 "discriminant_size": 8
7504 });
7505
7506 let idl_snapshot: IdlSnapshot =
7507 serde_json::from_value(shared_idl).expect("idl snapshot should deserialize");
7508
7509 let make_entity = |name: &str| SerializableStreamSpec {
7510 ast_version: CURRENT_AST_VERSION.to_string(),
7511 state_name: name.to_string(),
7512 program_id: None,
7513 idl: None,
7514 identity: IdentitySpec {
7515 primary_keys: vec!["id.address".to_string()],
7516 lookup_indexes: vec![],
7517 },
7518 handlers: vec![],
7519 sections: vec![EntitySection {
7520 name: "id".to_string(),
7521 fields: vec![FieldTypeInfo::new(
7522 "address".to_string(),
7523 "String".to_string(),
7524 )],
7525 is_nested_struct: false,
7526 parent_field: None,
7527 }],
7528 field_mappings: BTreeMap::new(),
7529 resolver_hooks: vec![],
7530 instruction_hooks: vec![],
7531 resolver_specs: vec![],
7532 computed_fields: vec![],
7533 computed_field_specs: vec![],
7534 content_hash: None,
7535 views: vec![],
7536 };
7537
7538 let stack_spec = SerializableStackSpec {
7539 ast_version: CURRENT_AST_VERSION.to_string(),
7540 stack_name: "Subscriptions".to_string(),
7541 program_ids: vec![],
7542 idls: vec![idl_snapshot],
7543 program_specs: vec![],
7544 entities: vec![make_entity("Plan"), make_entity("Subscription")],
7545 pdas: BTreeMap::new(),
7546 instructions: vec![],
7547 content_hash: None,
7548 };
7549
7550 let output =
7551 compile_stack_spec(stack_spec, None).expect("stack compilation should succeed");
7552 let file = output.full_file();
7553 let count = output
7554 .interfaces
7555 .matches("export type PlanStatus =")
7556 .count();
7557
7558 assert_eq!(
7559 count, 1,
7560 "expected shared enum type to be emitted once, got:\n{}",
7561 output.interfaces
7562 );
7563 assert!(
7564 file.contains("_STACK_CORE = {"),
7565 "core export missing:\n{}",
7566 file
7567 );
7568 assert!(
7569 !file.contains("extendStack"),
7570 "no extension wiring expected:\n{}",
7571 file
7572 );
7573 }
7574
7575 #[test]
7576 fn golden_ore_stack_json_compiles_program_modules_without_entities() {
7577 let mut spec = crate::public_artifacts::ore_stack_spec_from_exact_artifacts();
7578
7579 spec.entities.clear();
7581
7582 let output =
7583 compile_program_modules(spec, None).expect("program-module compilation should succeed");
7584 let file = output.full_file();
7585
7586 assert!(
7588 file.contains("export const ORE = {"),
7589 "ore const missing:\n{}",
7590 file
7591 );
7592 assert!(
7593 file.contains("export const ENTROPY = {"),
7594 "entropy const missing"
7595 );
7596 assert!(
7597 file.contains("export const ORE_STREAM_PROGRAMS = {"),
7598 "combined program map missing"
7599 );
7600 assert!(file.contains(" ore: ORE,"));
7601 assert!(file.contains(" entropy: ENTROPY,"));
7602 assert!(file.contains("export default ORE_STREAM_PROGRAMS;"));
7603 assert!(file.contains("All portable programs from the OreStream stack"));
7604 assert!(file.contains("export const ORE_STREAM_PROGRAM_READS = {"));
7605
7606 assert!(file.contains("createInstructionHandler"));
7608 assert!(file.contains("pdas: {"));
7609 assert!(file.contains("addresses: {"));
7610 assert!(file.contains("instructions: {"));
7611 assert!(file.contains("createPreparedInstruction({"));
7612 assert!(file.contains("buildInstruction("));
7613
7614 assert!(!file.contains("stateView"), "no view helpers expected");
7616 assert!(!file.contains("listView"), "no view helpers expected");
7617 assert!(!file.contains("views:"), "no views block expected");
7618 assert!(!file.contains("endpoints:"), "no endpoints block expected");
7619 assert!(
7620 !file.contains("extendStack"),
7621 "no extension wiring expected"
7622 );
7623 }
7624
7625 #[test]
7626 fn golden_ore_stack_json_emits_typed_state_view_keys() {
7627 let spec = crate::public_artifacts::ore_stack_spec_from_exact_artifacts();
7628
7629 let output = compile_stack_spec(spec, None).expect("ore stack should compile");
7630 let stack = output.stack_definition;
7631
7632 assert!(stack.contains(
7633 "state: stateView<OreRound, { roundId: bigint }>('OreRound/state', ['roundId'])"
7634 ));
7635 assert!(stack.contains(
7636 "state: stateView<OreBoard, { address: string }>('OreBoard/state', ['address'])"
7637 ));
7638 assert!(stack.contains(
7639 "state: stateView<OreMiner, { authority: string }>('OreMiner/state', ['authority'])"
7640 ));
7641 assert_eq!(
7642 stack.matches(
7643 "state: stateView<OreMiner, { authority: string }>('OreMiner/state', ['authority'])"
7644 )
7645 .count(),
7646 1
7647 );
7648 }
7649
7650 #[test]
7651 fn golden_ore_entity_fields_document_wire_names_bigints_and_notes() {
7652 let spec = crate::public_artifacts::ore_stack_spec_from_exact_artifacts();
7653
7654 let references = typescript_entity_references(&spec).expect("ore entities");
7655 let round = references
7656 .iter()
7657 .find(|entity| entity.entity == "OreRound")
7658 .expect("OreRound");
7659 assert_eq!(round.type_name, "OreRound");
7660 assert_eq!(
7661 round.state_key,
7662 Some(("roundId".to_string(), "bigint".to_string()))
7663 );
7664 let round_id = round
7665 .fields
7666 .iter()
7667 .find(|field| field.path == "id.roundId")
7668 .expect("id.roundId");
7669 assert_eq!(round_id.wire, "id.round_id");
7670 assert_eq!(round_id.ts_type, "bigint");
7671 assert!(round_id.nullable);
7672 assert!(round
7673 .fields
7674 .iter()
7675 .any(|field| field.path == "oreMetadata" && field.wire == "ore_metadata"));
7676
7677 let notes = BTreeMap::from([(
7678 "OreRound".to_string(),
7679 BTreeMap::from([(
7680 "state.total_deployed".to_string(),
7681 "Token amount in whole units (raw / 10^9).".to_string(),
7682 )]),
7683 )]);
7684 let output = compile_stack_spec(
7685 spec,
7686 Some(TypeScriptStackConfig {
7687 field_notes: notes,
7688 ..TypeScriptStackConfig::default()
7689 }),
7690 )
7691 .expect("ore stack should compile");
7692 let interfaces = output.interfaces;
7693 assert!(interfaces.contains(&format!(
7696 " /** Wire name `id.round_id` (CLI and MCP output). {BIGINT_FIELD_DOC} */\n roundId: bigint | null;"
7697 )));
7698 assert!(interfaces.contains(
7699 " /** Wire name `state.total_deployed` (CLI and MCP output). Token amount in whole units (raw / 10^9). */\n totalDeployed: number | null;"
7700 ));
7701 assert!(interfaces.contains(
7702 " /** Wire name `ore_metadata` (CLI and MCP output). */\n oreMetadata: TokenMetadata | null;"
7703 ));
7704 assert!(interfaces.contains(" id: OreRoundId;\n"));
7706 }
7707
7708 #[test]
7709 fn compile_program_modules_emits_amount_aware_semantic_instruction_wrappers() {
7710 let stack_spec = SerializableStackSpec {
7711 ast_version: CURRENT_AST_VERSION.to_string(),
7712 stack_name: "DemoStream".to_string(),
7713 program_ids: vec!["Prog111".to_string()],
7714 idls: vec![IdlSnapshot {
7715 name: "demo".to_string(),
7716 program_id: Some("Prog111".to_string()),
7717 version: "0.1.0".to_string(),
7718 accounts: vec![],
7719 instructions: vec![IdlInstructionSnapshot {
7720 name: "deposit".to_string(),
7721 discriminator: vec![9],
7722 discriminant: None,
7723 docs: vec![],
7724 accounts: vec![],
7725 args: vec![
7726 IdlFieldSnapshot {
7727 name: "amount".to_string(),
7728 type_: IdlTypeSnapshot::Simple("u64".to_string()),
7729 amount_hint: None,
7730 },
7731 IdlFieldSnapshot {
7732 name: "mint".to_string(),
7733 type_: IdlTypeSnapshot::Simple("publicKey".to_string()),
7734 amount_hint: None,
7735 },
7736 ],
7737 }],
7738 types: vec![],
7739 events: vec![],
7740 errors: vec![],
7741 discriminant_size: 1,
7742 }],
7743 program_specs: vec![demo_program_spec()],
7744 entities: vec![],
7745 pdas: BTreeMap::new(),
7746 instructions: vec![InstructionDef {
7747 name: "deposit".to_string(),
7748 discriminator: vec![9],
7749 discriminator_size: 1,
7750 accounts: vec![],
7751 args: vec![
7752 InstructionArgDef {
7753 name: "amount".to_string(),
7754 arg_type: "u64".to_string(),
7755 docs: vec![],
7756 amount_hint: Some(InstructionAmountHint {
7757 decimals_source: AmountDecimalsSource::ArgMint {
7758 arg_name: "mint".to_string(),
7759 },
7760 }),
7761 },
7762 InstructionArgDef {
7763 name: "mint".to_string(),
7764 arg_type: "solana_pubkey::Pubkey".to_string(),
7765 docs: vec![],
7766 amount_hint: None,
7767 },
7768 ],
7769 errors: vec![],
7770 program_id: Some("Prog111".to_string()),
7771 docs: vec![],
7772 }],
7773 content_hash: None,
7774 };
7775
7776 let output = compile_program_modules(stack_spec, None)
7777 .expect("program-module compilation should succeed");
7778 let file = output.full_file();
7779
7780 assert!(
7781 file.contains("type AmountInput"),
7782 "amount import missing:\n{}",
7783 file
7784 );
7785 assert!(
7786 file.contains("PROGRAM_OPERATION_EXTENSIONS"),
7787 "runtime extension import missing"
7788 );
7789 assert!(
7790 output.imports.contains("type BuildOptions"),
7791 "build options import missing"
7792 );
7793 assert!(
7794 output.imports.contains("type ProgramOperationContext"),
7795 "operation context import missing"
7796 );
7797 assert!(
7798 file.contains("resolveAmountToRaw"),
7799 "amount resolver import missing"
7800 );
7801 assert!(file.contains("export interface DepositSemanticParams"));
7802 assert!(file.contains("build?: BuildOptions;"));
7803 assert!(file.contains("[PROGRAM_OPERATION_EXTENSIONS]: {"));
7804 assert!(file.contains("createOperations(context: ProgramOperationContext)"));
7805 assert!(file
7806 .contains("deposit: instructionOperation(async (params: DepositSemanticParams) => {"));
7807 assert!(file.contains("const { build, amountDecimals, ...rawParams } = params;"));
7808 assert!(file.contains("resolveAmountToRaw(context.chain"));
7809 assert!(file.contains("const instruction = buildInstruction(depositInstruction, {"));
7810 assert!(file.contains("createPreparedInstruction({"));
7811 }
7812
7813 #[test]
7814 fn golden_ore_stack_json_compiles_stack_with_root_helper_namespaces() {
7815 let spec = crate::public_artifacts::ore_stack_spec_from_exact_artifacts();
7816
7817 let output = compile_stack_spec(spec, None).expect("stack compilation should succeed");
7818 let file = output.full_file();
7819
7820 assert!(
7821 file.contains("endpoints:"),
7822 "stack endpoints missing:\n{}",
7823 file
7824 );
7825 assert!(
7826 file.contains("programs: {"),
7827 "program block missing:\n{}",
7828 file
7829 );
7830 assert!(
7831 file.contains("addresses: {"),
7832 "root addresses missing:\n{}",
7833 file
7834 );
7835 assert!(
7836 file.contains("instructions: {"),
7837 "program instructions missing:\n{}",
7838 file
7839 );
7840 assert!(
7841 file.contains("buildInstruction("),
7842 "instruction builders missing:\n{}",
7843 file
7844 );
7845 }
7846
7847 #[test]
7848 fn account_codegen_normalizes_raw_keys_and_nested_types() {
7849 let idl_snapshot = IdlSnapshot {
7850 name: "presale".to_string(),
7851 program_id: None,
7852 version: "0.1.0".to_string(),
7853 accounts: vec![IdlAccountSnapshot {
7854 name: "Presale".to_string(),
7855 discriminator: vec![1, 2, 3, 4, 5, 6, 7, 8],
7856 docs: vec![],
7857 serialization: None,
7858 fields: vec![
7859 IdlFieldSnapshot {
7860 name: "owner".to_string(),
7861 type_: IdlTypeSnapshot::Simple("pubkey".to_string()),
7862 amount_hint: None,
7863 },
7864 IdlFieldSnapshot {
7865 name: "total_deposit".to_string(),
7866 type_: IdlTypeSnapshot::Simple("u64".to_string()),
7867 amount_hint: None,
7868 },
7869 IdlFieldSnapshot {
7870 name: "optional_authority".to_string(),
7871 type_: IdlTypeSnapshot::Option(IdlOptionTypeSnapshot {
7872 option: Box::new(IdlTypeSnapshot::Simple("pubkey".to_string())),
7873 }),
7874 amount_hint: None,
7875 },
7876 IdlFieldSnapshot {
7877 name: "createKey".to_string(),
7878 type_: IdlTypeSnapshot::Simple("pubkey".to_string()),
7879 amount_hint: None,
7880 },
7881 IdlFieldSnapshot {
7882 name: "member".to_string(),
7883 type_: IdlTypeSnapshot::Defined(IdlDefinedTypeSnapshot {
7884 defined: IdlDefinedInnerSnapshot::Simple("MemberConfig".to_string()),
7885 }),
7886 amount_hint: None,
7887 },
7888 ],
7889 type_def: None,
7890 }],
7891 instructions: vec![],
7892 types: vec![IdlTypeDefSnapshot {
7893 name: "MemberConfig".to_string(),
7894 docs: vec![],
7895 serialization: None,
7896 type_def: IdlTypeDefKindSnapshot::Struct {
7897 kind: "struct".to_string(),
7898 fields: vec![
7899 IdlFieldSnapshot {
7900 name: "last_updated_at".to_string(),
7901 type_: IdlTypeSnapshot::Simple("i128".to_string()),
7902 amount_hint: None,
7903 },
7904 IdlFieldSnapshot {
7905 name: "authority_key".to_string(),
7906 type_: IdlTypeSnapshot::Simple("pubkey".to_string()),
7907 amount_hint: None,
7908 },
7909 ],
7910 },
7911 }],
7912 events: vec![],
7913 errors: vec![],
7914 discriminant_size: 8,
7915 };
7916
7917 let artifacts = generate_idl_account_artifacts(&[idl_snapshot], &HashSet::new());
7918 let account_bigint = bigint_zod();
7919
7920 assert!(artifacts.code.contains("export interface Presale {"));
7921 assert!(
7922 artifacts.code.contains("owner: string;"),
7923 "missing owner field:\n{}",
7924 artifacts.code
7925 );
7926 assert!(
7927 artifacts.code.contains("totalDeposit: bigint;"),
7928 "missing totalDeposit field:\n{}",
7929 artifacts.code
7930 );
7931 assert!(
7932 artifacts.code.contains("optionalAuthority: string | null;"),
7933 "missing optionalAuthority field:\n{}",
7934 artifacts.code
7935 );
7936 assert!(
7937 artifacts.code.contains("createKey: string;"),
7938 "missing createKey field:\n{}",
7939 artifacts.code
7940 );
7941 assert!(
7942 artifacts.code.contains("member: MemberConfig;"),
7943 "missing nested type field:\n{}",
7944 artifacts.code
7945 );
7946 assert!(artifacts.code.contains("export interface MemberConfig {"));
7947 assert!(
7948 artifacts.code.contains("lastUpdatedAt: bigint;"),
7949 "missing nested bigint field:\n{}",
7950 artifacts.code
7951 );
7952 assert!(
7953 artifacts.code.contains("authorityKey: string;"),
7954 "missing nested camelCase field:\n{}",
7955 artifacts.code
7956 );
7957
7958 assert!(artifacts
7959 .code
7960 .contains("export const PresaleSchema = z.object({"));
7961 assert!(
7962 artifacts.code.contains("owner: z.string(),"),
7963 "missing owner schema field:\n{}",
7964 artifacts.code
7965 );
7966 assert!(
7967 artifacts
7968 .code
7969 .contains(&format!("total_deposit: {},", account_bigint)),
7970 "missing total_deposit schema field:\n{}",
7971 artifacts.code
7972 );
7973 assert!(
7974 artifacts
7975 .code
7976 .contains("optional_authority: z.string().nullable(),"),
7977 "missing optional_authority schema field:\n{}",
7978 artifacts.code
7979 );
7980 assert!(
7981 artifacts.code.contains("create_key: z.string(),"),
7982 "missing create_key schema field:\n{}",
7983 artifacts.code
7984 );
7985 assert!(
7986 artifacts
7987 .code
7988 .contains("member: z.lazy(() => MemberConfigSchema),"),
7989 "missing nested schema field:\n{}",
7990 artifacts.code
7991 );
7992 assert!(
7993 artifacts.code.contains("owner: value.owner,"),
7994 "missing owner transform:\n{}",
7995 artifacts.code
7996 );
7997 assert!(
7998 artifacts
7999 .code
8000 .contains("totalDeposit: value.total_deposit,"),
8001 "missing totalDeposit transform:\n{}",
8002 artifacts.code
8003 );
8004 assert!(
8005 artifacts
8006 .code
8007 .contains("optionalAuthority: value.optional_authority,"),
8008 "missing optionalAuthority transform:\n{}",
8009 artifacts.code
8010 );
8011 assert!(
8012 artifacts.code.contains("createKey: value.create_key,"),
8013 "missing createKey transform:\n{}",
8014 artifacts.code
8015 );
8016 assert!(
8017 artifacts.code.contains("member: value.member,"),
8018 "missing nested transform:\n{}",
8019 artifacts.code
8020 );
8021
8022 assert!(artifacts
8023 .code
8024 .contains("export const MemberConfigSchema = z.object({"));
8025 assert!(
8026 artifacts
8027 .code
8028 .contains(&format!("last_updated_at: {},", bigint_zod())),
8029 "missing nested bigint schema field:\n{}",
8030 artifacts.code
8031 );
8032 assert!(
8033 artifacts.code.contains("authority_key: z.string(),"),
8034 "missing nested schema field:\n{}",
8035 artifacts.code
8036 );
8037 assert!(
8038 artifacts
8039 .code
8040 .contains("lastUpdatedAt: value.last_updated_at,"),
8041 "missing nested bigint transform:\n{}",
8042 artifacts.code
8043 );
8044 assert!(
8045 artifacts
8046 .code
8047 .contains("authorityKey: value.authority_key,"),
8048 "missing nested camelCase transform:\n{}",
8049 artifacts.code
8050 );
8051 }
8052
8053 #[test]
8054 fn account_codegen_falls_back_to_same_named_type_def_when_account_fields_are_empty() {
8055 let idl_snapshot = IdlSnapshot {
8056 name: "presale".to_string(),
8057 program_id: None,
8058 version: "0.1.0".to_string(),
8059 accounts: vec![IdlAccountSnapshot {
8060 name: "Presale".to_string(),
8061 discriminator: vec![1, 2, 3, 4, 5, 6, 7, 8],
8062 docs: vec![],
8063 serialization: None,
8064 fields: vec![],
8065 type_def: None,
8066 }],
8067 instructions: vec![],
8068 types: vec![IdlTypeDefSnapshot {
8069 name: "Presale".to_string(),
8070 docs: vec![],
8071 serialization: None,
8072 type_def: IdlTypeDefKindSnapshot::Struct {
8073 kind: "struct".to_string(),
8074 fields: vec![
8075 IdlFieldSnapshot {
8076 name: "owner".to_string(),
8077 type_: IdlTypeSnapshot::Simple("pubkey".to_string()),
8078 amount_hint: None,
8079 },
8080 IdlFieldSnapshot {
8081 name: "total_deposit".to_string(),
8082 type_: IdlTypeSnapshot::Simple("u64".to_string()),
8083 amount_hint: None,
8084 },
8085 ],
8086 },
8087 }],
8088 events: vec![],
8089 errors: vec![],
8090 discriminant_size: 8,
8091 };
8092
8093 let artifacts = generate_idl_account_artifacts(&[idl_snapshot], &HashSet::new());
8094
8095 assert!(artifacts.code.contains("export interface Presale {"));
8096 assert!(
8097 artifacts.code.contains("owner: string;"),
8098 "missing owner field:\n{}",
8099 artifacts.code
8100 );
8101 assert!(
8102 artifacts.code.contains("totalDeposit: bigint;"),
8103 "missing totalDeposit field:\n{}",
8104 artifacts.code
8105 );
8106 assert!(
8107 artifacts
8108 .code
8109 .contains("export const PresaleSchema = z.object({"),
8110 "missing schema:\n{}",
8111 artifacts.code
8112 );
8113 assert!(
8114 artifacts.code.contains("owner: z.string(),"),
8115 "missing owner schema field:\n{}",
8116 artifacts.code
8117 );
8118 assert!(
8119 artifacts
8120 .code
8121 .contains(&format!("total_deposit: {},", bigint_zod())),
8122 "missing total_deposit schema field:\n{}",
8123 artifacts.code
8124 );
8125 assert!(
8126 artifacts.code.contains("owner: value.owner,"),
8127 "missing owner transform:\n{}",
8128 artifacts.code
8129 );
8130 assert!(
8131 artifacts
8132 .code
8133 .contains("totalDeposit: value.total_deposit,"),
8134 "missing totalDeposit transform:\n{}",
8135 artifacts.code
8136 );
8137 }
8138
8139 #[test]
8140 fn reserved_idl_type_names_take_a_role_suffix_and_keep_numeric_aliases() {
8141 let field = |name: &str, type_name: &str| IdlFieldSnapshot {
8142 name: name.to_string(),
8143 type_: IdlTypeSnapshot::Simple(type_name.to_string()),
8144 amount_hint: None,
8145 };
8146 let idl_snapshot = IdlSnapshot {
8147 name: "ore".to_string(),
8148 program_id: None,
8149 version: "0.1.0".to_string(),
8150 accounts: vec![
8151 IdlAccountSnapshot {
8152 name: "Board".to_string(),
8153 discriminator: vec![1, 0, 0, 0, 0, 0, 0, 0],
8154 docs: vec![],
8155 serialization: None,
8156 fields: vec![field("round_id", "u64")],
8157 type_def: None,
8158 },
8159 IdlAccountSnapshot {
8160 name: "Config".to_string(),
8161 discriminator: vec![2, 0, 0, 0, 0, 0, 0, 0],
8162 docs: vec![],
8163 serialization: None,
8164 fields: vec![IdlFieldSnapshot {
8165 name: "header".to_string(),
8166 type_: IdlTypeSnapshot::Defined(IdlDefinedTypeSnapshot {
8167 defined: IdlDefinedInnerSnapshot::Simple("Header".to_string()),
8168 }),
8169 amount_hint: None,
8170 }],
8171 type_def: None,
8172 },
8173 ],
8174 instructions: vec![],
8175 types: vec![IdlTypeDefSnapshot {
8176 name: "Header".to_string(),
8177 docs: vec![],
8178 serialization: None,
8179 type_def: IdlTypeDefKindSnapshot::Struct {
8180 kind: "struct".to_string(),
8181 fields: vec![field("version", "u8")],
8182 },
8183 }],
8184 events: vec![],
8185 errors: vec![],
8186 discriminant_size: 8,
8187 };
8188 let reserved = HashSet::from([
8190 "Board".to_string(),
8191 "OreBoard".to_string(),
8192 "Header".to_string(),
8193 "OreHeader".to_string(),
8194 ]);
8195
8196 let artifacts = generate_idl_account_artifacts(&[idl_snapshot], &reserved);
8197
8198 assert_eq!(
8199 artifacts
8200 .account_type_names
8201 .get(&("ore".to_string(), "Board".to_string()))
8202 .map(String::as_str),
8203 Some("OreBoardAccount")
8204 );
8205 assert_eq!(
8206 artifacts
8207 .account_type_names
8208 .get(&("ore".to_string(), "Config".to_string()))
8209 .map(String::as_str),
8210 Some("Config")
8211 );
8212 let code = &artifacts.code;
8213 assert!(
8214 code.contains("export interface OreBoardAccount {"),
8215 "{code}"
8216 );
8217 assert!(
8218 code.contains("export const OreBoardAccountSchema = z.object({"),
8219 "{code}"
8220 );
8221 assert!(code.contains("export interface OreHeaderType {"), "{code}");
8222 assert!(code.contains("header: OreHeaderType;"), "{code}");
8223 assert!(
8224 code.contains(
8225 "/** @deprecated Use OreBoardAccount. */\nexport type OreBoard2 = OreBoardAccount;\n/** @deprecated Use OreBoardAccountSchema. */\nexport const OreBoard2Schema = OreBoardAccountSchema;"
8226 ),
8227 "{code}"
8228 );
8229 assert!(
8230 code.contains("export type OreHeader2 = OreHeaderType;")
8231 && code.contains("export const OreHeader2Schema = OreHeaderTypeSchema;"),
8232 "{code}"
8233 );
8234 assert!(
8236 code.find("export const OreBoard2Schema").unwrap()
8237 > code.find("export const OreBoardAccountSchema").unwrap()
8238 );
8239 for name in [
8240 "OreBoardAccount",
8241 "OreBoard2",
8242 "OreHeaderType",
8243 "OreHeader2",
8244 ] {
8245 assert!(artifacts.type_names.contains(name), "{name}");
8246 }
8247 for name in [
8248 "OreBoardAccountSchema",
8249 "OreBoard2Schema",
8250 "OreHeader2Schema",
8251 ] {
8252 assert!(
8253 artifacts.schema_names.iter().any(|schema| schema == name),
8254 "{name}"
8255 );
8256 }
8257
8258 let unreserved = generate_idl_account_artifacts(
8260 &[IdlSnapshot {
8261 name: "ore".to_string(),
8262 program_id: None,
8263 version: "0.1.0".to_string(),
8264 accounts: vec![],
8265 instructions: vec![],
8266 types: vec![],
8267 events: vec![],
8268 errors: vec![],
8269 discriminant_size: 8,
8270 }],
8271 &HashSet::new(),
8272 );
8273 assert!(!unreserved.code.contains("@deprecated"));
8274 }
8275
8276 #[test]
8277 fn compile_program_modules_rejects_specs_without_idls() {
8278 let stack_spec = SerializableStackSpec {
8279 ast_version: CURRENT_AST_VERSION.to_string(),
8280 stack_name: "Empty".to_string(),
8281 program_ids: vec![],
8282 idls: vec![],
8283 program_specs: vec![],
8284 entities: vec![],
8285 pdas: BTreeMap::new(),
8286 instructions: vec![],
8287 content_hash: None,
8288 };
8289
8290 let error =
8291 compile_program_modules(stack_spec, None).expect_err("no IDLs should be an error");
8292 assert!(error.contains("no IDLs"), "unexpected error: {}", error);
8293 }
8294}