dto_roots.rs (32702B)
1 #![cfg_attr(not(test), allow(dead_code))] 2 3 use std::collections::{BTreeMap, BTreeSet}; 4 5 use dto_bindgen_backend_ts::{ 6 DtoRegistryRenderOptions, DtoTypesModule, TypeScriptDeclaration, TypeScriptModule, 7 TypeScriptType, render_registry_types, 8 }; 9 use dto_bindgen_core::{Registry, RootDescriptor, build_registry}; 10 11 #[derive(Clone, Copy, Debug)] 12 pub struct DtoPackageRootSet { 13 pub package_key: &'static str, 14 roots: fn() -> Vec<RootDescriptor>, 15 } 16 17 impl DtoPackageRootSet { 18 pub fn roots(&self) -> Vec<RootDescriptor> { 19 (self.roots)() 20 } 21 22 pub fn registry(&self) -> Registry { 23 build_registry(self.roots()) 24 } 25 } 26 27 #[derive(Clone, Copy, Debug, Eq, PartialEq)] 28 pub struct ManualDescriptorFamily { 29 pub package_key: &'static str, 30 pub source_family: &'static str, 31 pub reason: &'static str, 32 } 33 34 #[derive(Clone, Copy, Debug, Eq, PartialEq)] 35 pub struct SdkLocalWrapperAllowance { 36 pub package_key: &'static str, 37 pub shape_family: &'static str, 38 pub reason: &'static str, 39 } 40 41 #[derive(Clone, Copy, Debug, Eq, PartialEq)] 42 struct DtoExternalOverride { 43 target_type: &'static str, 44 import_name: &'static str, 45 from_package_key: &'static str, 46 from: &'static str, 47 } 48 49 const CORE_BINDINGS_PACKAGE_KEY: &str = "core"; 50 const CORE_BINDINGS_PACKAGE_NAME: &str = "@radroots/core-bindings"; 51 const EVENT_BINDINGS_PACKAGE_KEY: &str = "event"; 52 const EVENT_BINDINGS_PACKAGE_NAME: &str = "@radroots/event-bindings"; 53 54 pub const DTO_PACKAGE_ROOTS: &[DtoPackageRootSet] = &[DtoPackageRootSet { 55 package_key: "core", 56 roots: core_roots, 57 }]; 58 59 pub const MANUAL_DESCRIPTOR_FAMILIES: &[ManualDescriptorFamily] = &[ 60 ManualDescriptorFamily { 61 package_key: "core", 62 source_family: "decimal, currency, money, quantity, percent, quantity price, unit, and discount value families", 63 reason: "custom serde, string-backed newtypes, aliases, and tagged enum wire forms require source-owned manual descriptors", 64 }, 65 ManualDescriptorFamily { 66 package_key: "event", 67 source_family: "event timestamps, counters, and optional metadata fields", 68 reason: "large integers and source-specific optional/null policy must be explicit", 69 }, 70 ManualDescriptorFamily { 71 package_key: "event", 72 source_family: "GeoJSON coordinate arrays", 73 reason: "fixed-size Rust arrays must preserve tuple semantics in TypeScript", 74 }, 75 ManualDescriptorFamily { 76 package_key: "trade", 77 source_family: "trade listing roots and package projection count fields", 78 reason: "core aliases, source-owned event imports, and count-family numeric policy require explicit descriptors", 79 }, 80 ManualDescriptorFamily { 81 package_key: "replica_schema", 82 source_family: "untagged query wrappers and serde_json value fields", 83 reason: "schema query shapes are generated and not all source fields map to derive-supported DTOs", 84 }, 85 ]; 86 87 pub const SDK_LOCAL_WRAPPER_ALLOWANCES: &[SdkLocalWrapperAllowance] = &[ 88 SdkLocalWrapperAllowance { 89 package_key: "core", 90 shape_family: "Currency and Decimal package aliases", 91 reason: "string-backed source descriptors require canonical named TypeScript package aliases", 92 }, 93 SdkLocalWrapperAllowance { 94 package_key: "replica_schema", 95 shape_family: "generated query argument wrappers", 96 reason: "schema operation inputs are generated package shapes rather than source-owned public DTO structs", 97 }, 98 SdkLocalWrapperAllowance { 99 package_key: "trade", 100 shape_family: "marketplace, query, projection, sort, review, and backoffice DTO shapes", 101 reason: "these are SDK package contract shapes layered over source-owned trade, events, and core DTOs", 102 }, 103 ]; 104 105 const TRADE_REQUIRED_EXTERNAL_PACKAGE_IMPORTS: &[&str] = 106 &[CORE_BINDINGS_PACKAGE_NAME, EVENT_BINDINGS_PACKAGE_NAME]; 107 const EVENT_BINDINGS_TYPES_TS: &str = 108 include_str!("../../../../crates/event_bindings/tests/fixtures/generated_types.ts"); 109 110 pub fn package_root_set(package_key: &str) -> Option<&'static DtoPackageRootSet> { 111 DTO_PACKAGE_ROOTS 112 .iter() 113 .find(|root_set| root_set.package_key == package_key) 114 } 115 116 pub fn core_types_module() -> Result<DtoTypesModule, String> { 117 let root_set = package_root_set("core").ok_or_else(|| "missing core DTO roots".to_owned())?; 118 let rendered = 119 render_registry_types(&root_set.registry(), &DtoRegistryRenderOptions::default())?; 120 Ok(with_type_aliases_sorted( 121 rendered, 122 [ 123 type_alias("Currency", TypeScriptType::String), 124 type_alias("Decimal", TypeScriptType::String), 125 ], 126 )) 127 } 128 129 pub fn replica_schema_types_module() -> Result<DtoTypesModule, String> { 130 render_registry_types( 131 &radroots_replica_schema_bindings::dto_registry(), 132 &DtoRegistryRenderOptions::default(), 133 ) 134 } 135 136 fn core_roots() -> Vec<RootDescriptor> { 137 radroots_core_bindings::dto_roots() 138 } 139 140 fn generated_external_package_exports() -> Result<BTreeMap<&'static str, BTreeSet<String>>, String> 141 { 142 Ok(BTreeMap::from([ 143 ( 144 CORE_BINDINGS_PACKAGE_KEY, 145 type_exports(core_types_module()?.body_ts()), 146 ), 147 ( 148 EVENT_BINDINGS_PACKAGE_KEY, 149 type_exports(EVENT_BINDINGS_TYPES_TS), 150 ), 151 ])) 152 } 153 154 fn validate_external_override_target( 155 override_target: DtoExternalOverride, 156 package_exports: &BTreeMap<&'static str, BTreeSet<String>>, 157 ) -> Result<(), String> { 158 let exports = package_exports 159 .get(override_target.from_package_key) 160 .ok_or_else(|| { 161 format!( 162 "external DTO override `{}` references unknown package key `{}`", 163 override_target.target_type, override_target.from_package_key 164 ) 165 })?; 166 if exports.contains(override_target.import_name) { 167 return Ok(()); 168 } 169 Err(format!( 170 "external DTO override `{}` imports `{}` from `{}`, but package `{}` does not export it", 171 override_target.target_type, 172 override_target.import_name, 173 override_target.from, 174 override_target.from_package_key 175 )) 176 } 177 178 fn validate_external_override_usage( 179 module: &DtoTypesModule, 180 overrides: &[DtoExternalOverride], 181 ) -> Result<(), String> { 182 let imports = imported_type_inventory(module.imports_ts().unwrap_or_default()); 183 let package_exports = generated_external_package_exports()?; 184 for package_name in TRADE_REQUIRED_EXTERNAL_PACKAGE_IMPORTS { 185 if !imports.contains_key(*package_name) { 186 return Err(format!( 187 "expected generated DTO imports from `{package_name}` but none were emitted" 188 )); 189 } 190 } 191 192 for override_target in overrides { 193 if type_exports(module.body_ts()).contains(override_target.target_type) { 194 return Err(format!( 195 "external DTO override `{}` from `{}` was emitted locally instead of imported", 196 override_target.target_type, override_target.from 197 )); 198 } 199 if imports 200 .get(override_target.from) 201 .is_some_and(|names| names.contains(override_target.import_name)) 202 { 203 validate_external_override_target(*override_target, &package_exports)?; 204 } 205 } 206 Ok(()) 207 } 208 209 fn imported_type_inventory(imports_ts: &str) -> BTreeMap<String, BTreeSet<String>> { 210 let mut imports = BTreeMap::new(); 211 let mut pending_names: Option<Vec<String>> = None; 212 213 for line in imports_ts.lines() { 214 let line = line.trim(); 215 if let Some(single) = line.strip_prefix("import type { ") { 216 if let Some((name, from)) = single.split_once(" } from ") { 217 insert_import_names(&mut imports, from, name); 218 } 219 } else if line == "import type {" { 220 pending_names = Some(Vec::new()); 221 } else if let Some(from) = line.strip_prefix("} from ") { 222 if let Some(names) = pending_names.take() { 223 for name in names { 224 insert_import(&mut imports, from, &name); 225 } 226 } 227 } else if let Some(names) = pending_names.as_mut() 228 && let Some(name) = line.strip_suffix(',') 229 { 230 names.push(name.to_owned()); 231 } 232 } 233 234 imports 235 } 236 237 fn with_additional_type_imports( 238 module: DtoTypesModule, 239 from: &str, 240 names: impl IntoIterator<Item = &'static str>, 241 ) -> DtoTypesModule { 242 let mut imports = imported_type_inventory(module.imports_ts().unwrap_or_default()); 243 imports 244 .entry(from.to_owned()) 245 .or_default() 246 .extend(names.into_iter().map(str::to_owned)); 247 let imports_ts = imports 248 .into_iter() 249 .map(|(from, names)| { 250 format!( 251 "import type {{ {} }} from \"{from}\";", 252 names.into_iter().collect::<Vec<_>>().join(", ") 253 ) 254 }) 255 .collect::<Vec<_>>() 256 .join("\n"); 257 let imports_ts = if imports_ts.is_empty() { 258 imports_ts 259 } else { 260 format!("{imports_ts}\n\n") 261 }; 262 DtoTypesModule::new(imports_ts, module.body_ts()) 263 } 264 265 fn insert_import_names(imports: &mut BTreeMap<String, BTreeSet<String>>, from: &str, names: &str) { 266 for name in names.split(',') { 267 insert_import(imports, from, name); 268 } 269 } 270 271 fn insert_import(imports: &mut BTreeMap<String, BTreeSet<String>>, from: &str, name: &str) { 272 let from = from.trim_end_matches(';').trim_matches('"'); 273 imports 274 .entry(from.to_owned()) 275 .or_default() 276 .insert(name.trim().to_owned()); 277 } 278 279 fn type_exports(types_ts: &str) -> BTreeSet<String> { 280 types_ts 281 .lines() 282 .filter_map(|line| line.strip_prefix("export type ")) 283 .filter_map(|rest| rest.split([' ', '<']).next()) 284 .map(str::to_owned) 285 .collect() 286 } 287 288 const fn event_override(export_name: &'static str) -> DtoExternalOverride { 289 DtoExternalOverride { 290 target_type: export_name, 291 import_name: export_name, 292 from_package_key: EVENT_BINDINGS_PACKAGE_KEY, 293 from: EVENT_BINDINGS_PACKAGE_NAME, 294 } 295 } 296 297 fn declaration_name(declaration: &str) -> &str { 298 declaration 299 .strip_prefix("export type ") 300 .and_then(|rest| rest.split([' ', '<']).next()) 301 .unwrap_or(declaration) 302 } 303 304 fn with_type_aliases_sorted( 305 module: DtoTypesModule, 306 aliases: impl IntoIterator<Item = String>, 307 ) -> DtoTypesModule { 308 let mut declarations = module 309 .body_ts() 310 .split("\n\n") 311 .filter(|declaration| !declaration.trim().is_empty()) 312 .map(str::to_owned) 313 .collect::<Vec<_>>(); 314 declarations.extend(aliases); 315 declarations.sort_by(|left, right| declaration_name(left).cmp(declaration_name(right))); 316 DtoTypesModule::new( 317 module.imports_ts().unwrap_or_default(), 318 declarations.join("\n\n"), 319 ) 320 } 321 322 fn type_alias(name: impl Into<String>, type_expr: TypeScriptType) -> String { 323 TypeScriptModule::new("types.ts") 324 .with_declaration(TypeScriptDeclaration::type_alias(name, type_expr)) 325 .render_source() 326 .trim() 327 .to_owned() 328 } 329 330 #[cfg(test)] 331 mod tests { 332 use std::collections::{BTreeMap, BTreeSet}; 333 334 use super::{ 335 CORE_BINDINGS_PACKAGE_KEY, CORE_BINDINGS_PACKAGE_NAME, DTO_PACKAGE_ROOTS, 336 DtoExternalOverride, MANUAL_DESCRIPTOR_FAMILIES, SDK_LOCAL_WRAPPER_ALLOWANCES, 337 event_override, imported_type_inventory, package_root_set, type_exports, 338 validate_external_override_target, validate_external_override_usage, 339 with_additional_type_imports, 340 }; 341 use dto_bindgen_backend_ts::DtoTypesModule; 342 343 const CORE_BINDINGS_TYPES_TS: &str = 344 include_str!("../../../../crates/core_bindings/tests/fixtures/generated_types.ts"); 345 const EVENT_BINDINGS_TYPES_TS: &str = 346 include_str!("../../../../crates/event_bindings/tests/fixtures/generated_types.ts"); 347 const REPLICA_SCHEMA_BINDINGS_TYPES_TS: &str = include_str!( 348 "../../../../crates/replica_schema_bindings/tests/fixtures/generated_types.ts" 349 ); 350 const TRADE_BINDINGS_TYPES_TS: &str = 351 include_str!("../../../../crates/trade_bindings/tests/fixtures/generated_types.ts"); 352 const REPLICA_SCHEMA_MODEL_SOURCES: &[&str] = &[ 353 include_str!("../../../../crates/replica_schema/src/models/farm.rs"), 354 include_str!("../../../../crates/replica_schema/src/models/farm_gcs_location.rs"), 355 include_str!("../../../../crates/replica_schema/src/models/farm_member.rs"), 356 include_str!("../../../../crates/replica_schema/src/models/farm_member_claim.rs"), 357 include_str!("../../../../crates/replica_schema/src/models/farm_tag.rs"), 358 include_str!("../../../../crates/replica_schema/src/models/gcs_location.rs"), 359 include_str!("../../../../crates/replica_schema/src/models/log_error.rs"), 360 include_str!("../../../../crates/replica_schema/src/models/media_image.rs"), 361 include_str!("../../../../crates/replica_schema/src/models/nostr_event_head.rs"), 362 include_str!("../../../../crates/replica_schema/src/models/nostr_profile.rs"), 363 include_str!("../../../../crates/replica_schema/src/models/nostr_profile_relay.rs"), 364 include_str!("../../../../crates/replica_schema/src/models/nostr_relay.rs"), 365 include_str!("../../../../crates/replica_schema/src/models/plot.rs"), 366 include_str!("../../../../crates/replica_schema/src/models/plot_gcs_location.rs"), 367 include_str!("../../../../crates/replica_schema/src/models/plot_tag.rs"), 368 include_str!("../../../../crates/replica_schema/src/models/trade_product.rs"), 369 include_str!("../../../../crates/replica_schema/src/models/trade_product_location.rs"), 370 include_str!("../../../../crates/replica_schema/src/models/trade_product_media.rs"), 371 ]; 372 const CORE_TYPE_INVENTORY: &[&str] = &[ 373 "Currency", 374 "Decimal", 375 "Discount", 376 "DiscountScope", 377 "DiscountThreshold", 378 "DiscountValue", 379 "Money", 380 "Percent", 381 "Quantity", 382 "QuantityPrice", 383 "Unit", 384 "UnitDimension", 385 ]; 386 const EVENT_TYPE_INVENTORY: &[&str] = &[ 387 "JobFeedbackStatus", 388 "JobInputType", 389 "JobPaymentRequest", 390 "RadrootsAccountClaim", 391 "RadrootsAddressableRef", 392 "RadrootsAppData", 393 "RadrootsCommercialDomain", 394 "RadrootsContributionAttestation", 395 "RadrootsCoop", 396 "RadrootsCoopLocation", 397 "RadrootsCoopRef", 398 "RadrootsDocument", 399 "RadrootsDocumentSubject", 400 "RadrootsEventEnvelopeDto", 401 "RadrootsEventPtr", 402 "RadrootsEventRef", 403 "RadrootsEvidenceBounty", 404 "RadrootsFarm", 405 "RadrootsFarmPublicLocation", 406 "RadrootsFarmRef", 407 "RadrootsFollow", 408 "RadrootsFollowProfile", 409 "RadrootsGcsLocation", 410 "RadrootsGeoChat", 411 "RadrootsGeoJsonPoint", 412 "RadrootsGeoJsonPolygon", 413 "RadrootsGiftWrap", 414 "RadrootsGiftWrapRecipient", 415 "RadrootsJobFeedback", 416 "RadrootsJobInput", 417 "RadrootsJobParam", 418 "RadrootsJobRequest", 419 "RadrootsJobResult", 420 "RadrootsKnowledgeChangeProposal", 421 "RadrootsKnowledgeCitationSpan", 422 "RadrootsKnowledgeClaim", 423 "RadrootsKnowledgeFieldContext", 424 "RadrootsKnowledgeFieldReport", 425 "RadrootsKnowledgeLocation", 426 "RadrootsKnowledgeLocationPrecision", 427 "RadrootsKnowledgeNodeRef", 428 "RadrootsKnowledgeObservation", 429 "RadrootsKnowledgeObservationValue", 430 "RadrootsKnowledgeRelation", 431 "RadrootsKnowledgeReview", 432 "RadrootsKnowledgeReviewScope", 433 "RadrootsKnowledgeReviewScore", 434 "RadrootsKnowledgeReviewTarget", 435 "RadrootsKnowledgeSource", 436 "RadrootsList", 437 "RadrootsListEntry", 438 "RadrootsListSet", 439 "RadrootsMessage", 440 "RadrootsMessageFile", 441 "RadrootsMessageFileDimensions", 442 "RadrootsMessageRecipient", 443 "RadrootsNip01EventWireDto", 444 "RadrootsOperationalListing", 445 "RadrootsOperationalListingAvailability", 446 "RadrootsOperationalListingBin", 447 "RadrootsOperationalListingDeliveryMethod", 448 "RadrootsOperationalListingImage", 449 "RadrootsOperationalListingImageSize", 450 "RadrootsOperationalListingParseError", 451 "RadrootsOperationalListingProduct", 452 "RadrootsOperationalListingProductTagKeys", 453 "RadrootsOperationalListingPublicLocation", 454 "RadrootsOperationalListingStatus", 455 "RadrootsOperationalListingValidationError", 456 "RadrootsPlot", 457 "RadrootsPlotLocation", 458 "RadrootsPlotRef", 459 "RadrootsPost", 460 "RadrootsProfileType", 461 "RadrootsReaction", 462 "RadrootsRelayDocument", 463 "RadrootsResourceArea", 464 "RadrootsResourceAreaLocation", 465 "RadrootsResourceAreaRef", 466 "RadrootsResourceHarvestCap", 467 "RadrootsResourceHarvestProduct", 468 "RadrootsRightsAssertion", 469 "RadrootsSeal", 470 "RadrootsSignedEventDto", 471 "RadrootsSignedEventVerificationStateDto", 472 "RadrootsSocialFarmAnchor", 473 "RadrootsSocialLocation", 474 "RadrootsSocialMediaDimensions", 475 "RadrootsSocialMediaMetadata", 476 "RadrootsSocialMediaThumbnail", 477 "RadrootsSocialTarget", 478 "RadrootsVerifiedSignedEventDto", 479 "RadrootsVerifiedSignedEventVerificationStateDto", 480 "RadrootsWikiArticle", 481 "RadrootsWikiArticleVersionRef", 482 "RadrootsWikiMergeRequest", 483 "RadrootsWikiRedirect", 484 ]; 485 const TRADE_TYPE_INVENTORY: &[&str] = &[ 486 "RadrootsOperationalListingSubtotal", 487 "RadrootsOperationalListingTotal", 488 "RadrootsOperationalListingTradeProjection", 489 "RadrootsTradeAgreementClaimV1", 490 "RadrootsTradeAgreementStateV1", 491 "RadrootsTradeAttestationRecordV1", 492 "RadrootsTradeAttestationResultV1", 493 "RadrootsTradeAttestationStateV1", 494 "RadrootsTradeConflictStateV1", 495 "RadrootsTradeEvidenceStateV1", 496 "RadrootsTradeEvidenceCoverageV1", 497 "RadrootsTradeEvidenceOutcomeV1", 498 "RadrootsTradeEvidenceManifestV1", 499 "RadrootsRhiEvidenceSupersessionV1", 500 "RadrootsRhiEvidenceReportV1", 501 "RadrootsRhiEvidenceAttestationV1", 502 "RadrootsTypedEvidenceEventPlanV1", 503 "RadrootsTradeFacetCount", 504 "RadrootsTradeFulfillmentStateV1", 505 "RadrootsTradeListingBackofficeOverlay", 506 "RadrootsTradeListingBackofficeQuery", 507 "RadrootsTradeListingBackofficeView", 508 "RadrootsTradeListingBinProjection", 509 "RadrootsTradeListingFacets", 510 "RadrootsTradeListingMarketStatus", 511 "RadrootsTradeListingProjection", 512 "RadrootsTradeListingQuery", 513 "RadrootsTradeListingSort", 514 "RadrootsTradeListingSortField", 515 "RadrootsTradeMarketplaceListingSummary", 516 "RadrootsTradeModerationFlag", 517 "RadrootsTradeModerationSeverity", 518 "RadrootsTradeModerationStatus", 519 "RadrootsTradeNegotiationStateV1", 520 "RadrootsTradePaymentStateV1", 521 "RadrootsTradePrivateTermsStateV1", 522 "RadrootsTradeProjectionV1", 523 "RadrootsTradeReducerIssueV1", 524 "RadrootsTradeReviewPriority", 525 "RadrootsTradeReviewQueueEntry", 526 "RadrootsTradeReviewStatus", 527 "RadrootsTradeSortDirection", 528 ]; 529 530 #[test] 531 fn approved_source_roots_build_registries() { 532 for root_set in DTO_PACKAGE_ROOTS { 533 let registry = root_set.registry(); 534 assert!( 535 !registry.has_errors(), 536 "registry for {} has diagnostics: {:?}", 537 root_set.package_key, 538 registry.diagnostics 539 ); 540 assert!(!registry.roots.is_empty()); 541 } 542 } 543 544 #[test] 545 fn package_roots_are_explicit_not_discovered() { 546 assert!(package_root_set("core").is_some()); 547 assert!(package_root_set("event").is_none()); 548 assert!(package_root_set("trade").is_none()); 549 } 550 551 #[test] 552 fn manual_descriptor_catalog_covers_known_review_families() { 553 assert!( 554 MANUAL_DESCRIPTOR_FAMILIES 555 .iter() 556 .any(|family| family.source_family.contains("GeoJSON")) 557 ); 558 assert!(SDK_LOCAL_WRAPPER_ALLOWANCES.iter().any(|allowance| { 559 allowance 560 .shape_family 561 .contains("marketplace, query, projection") 562 })); 563 } 564 565 #[test] 566 fn external_override_target_validation_rejects_missing_generated_export() { 567 let package_exports = BTreeMap::from([( 568 CORE_BINDINGS_PACKAGE_KEY, 569 BTreeSet::from(["ExistingType".to_owned()]), 570 )]); 571 let error = validate_external_override_target( 572 DtoExternalOverride { 573 target_type: "MissingType", 574 import_name: "MissingType", 575 from_package_key: CORE_BINDINGS_PACKAGE_KEY, 576 from: CORE_BINDINGS_PACKAGE_NAME, 577 }, 578 &package_exports, 579 ) 580 .expect_err("missing target package export must fail"); 581 582 assert_eq!( 583 error, 584 "external DTO override `MissingType` imports `MissingType` from `@radroots/core-bindings`, but package `core` does not export it" 585 ); 586 } 587 588 #[test] 589 fn external_override_usage_rejects_absent_required_package_import() { 590 let module = DtoTypesModule::new("", "export type RadrootsTradeListing = string;"); 591 let error = validate_external_override_usage(&module, &[]) 592 .expect_err("missing required package import must fail"); 593 594 assert_eq!( 595 error, 596 "expected generated DTO imports from `@radroots/core-bindings` but none were emitted" 597 ); 598 } 599 600 #[test] 601 fn external_override_usage_rejects_local_emission_for_imported_type() { 602 let module = DtoTypesModule::new( 603 "import type { Decimal } from \"@radroots/core-bindings\";\n\nimport type { RadrootsFarmRef } from \"@radroots/event-bindings\";\n\n", 604 "export type RadrootsFarmRef = { pubkey: string, d_tag: string, };", 605 ); 606 let error = validate_external_override_usage(&module, &[event_override("RadrootsFarmRef")]) 607 .expect_err("local emission of imported type must fail"); 608 609 assert_eq!( 610 error, 611 "external DTO override `RadrootsFarmRef` from `@radroots/event-bindings` was emitted locally instead of imported" 612 ); 613 } 614 615 #[test] 616 fn generated_import_inventory_parses_single_and_multiline_imports() { 617 let imports = imported_type_inventory( 618 "import type { One, Four } from \"@radroots/one\";\nimport type {\n Two,\n Three,\n} from \"@radroots/many\";\n\n", 619 ); 620 621 assert_eq!( 622 imports.get("@radroots/one").expect("single import package"), 623 &BTreeSet::from(["Four".to_owned(), "One".to_owned()]) 624 ); 625 assert_eq!( 626 imports.get("@radroots/many").expect("multi import package"), 627 &BTreeSet::from(["Three".to_owned(), "Two".to_owned()]) 628 ); 629 } 630 631 #[test] 632 fn additional_type_imports_merge_with_generated_package_imports() { 633 let module = DtoTypesModule::new( 634 "import type { Decimal, Money } from \"@radroots/core-bindings\";\n\n", 635 "export type Listing = { discounts: Array<Discount>, };", 636 ); 637 let merged = with_additional_type_imports(module, CORE_BINDINGS_PACKAGE_NAME, ["Discount"]); 638 639 assert_eq!( 640 merged.imports_ts(), 641 Some("import type { Decimal, Discount, Money } from \"@radroots/core-bindings\";\n\n") 642 ); 643 } 644 645 #[test] 646 fn generated_type_export_inventory_parses_type_aliases() { 647 assert_eq!( 648 type_exports("export type Alpha = string;\nexport type Beta<T> = T;\n"), 649 BTreeSet::from(["Alpha".to_owned(), "Beta".to_owned()]) 650 ); 651 } 652 653 #[test] 654 fn core_type_inventory_matches_current_package_surface() { 655 let actual = type_inventory(CORE_BINDINGS_TYPES_TS); 656 657 assert_eq!(actual, CORE_TYPE_INVENTORY); 658 assert!(!CORE_BINDINGS_TYPES_TS.contains("RadrootsCore")); 659 } 660 661 #[test] 662 fn event_type_inventory_matches_current_package_surface() { 663 let actual = type_inventory(EVENT_BINDINGS_TYPES_TS); 664 665 assert_eq!(actual, EVENT_TYPE_INVENTORY); 666 assert!(!EVENT_BINDINGS_TYPES_TS.contains("RadrootsOrder")); 667 } 668 669 #[test] 670 fn event_generated_types_expose_current_signed_event_dto_shapes() { 671 let wire = type_declaration(EVENT_BINDINGS_TYPES_TS, "RadrootsNip01EventWireDto"); 672 let envelope = type_declaration(EVENT_BINDINGS_TYPES_TS, "RadrootsEventEnvelopeDto"); 673 let signed = type_declaration(EVENT_BINDINGS_TYPES_TS, "RadrootsSignedEventDto"); 674 let verified = type_declaration(EVENT_BINDINGS_TYPES_TS, "RadrootsVerifiedSignedEventDto"); 675 676 assert!(wire.contains("pubkey: string")); 677 assert!(wire.contains("extra: { [key: string]: unknown }")); 678 assert!(envelope.contains("author: string")); 679 assert!(!envelope.contains("pubkey")); 680 assert!(!envelope.contains("extra")); 681 assert!(signed.contains("state: RadrootsSignedEventVerificationStateDto")); 682 assert!(signed.contains("envelope: RadrootsEventEnvelopeDto")); 683 assert!(signed.contains("wire: RadrootsNip01EventWireDto")); 684 assert!(signed.contains("raw_json: string")); 685 assert!(verified.contains("state: RadrootsVerifiedSignedEventVerificationStateDto")); 686 assert!(verified.contains("signed_event: RadrootsSignedEventDto")); 687 assert!( 688 !EVENT_BINDINGS_TYPES_TS.contains("export type RadrootsEventEnvelope ="), 689 "event package must not export the retired raw envelope type name" 690 ); 691 } 692 693 #[test] 694 fn trade_type_inventory_matches_current_package_surface() { 695 let actual = type_inventory(TRADE_BINDINGS_TYPES_TS); 696 697 assert_eq!(actual, TRADE_TYPE_INVENTORY); 698 } 699 700 #[test] 701 fn replica_schema_generated_types_preserve_source_schema_contracts() { 702 let actual = type_inventory(REPLICA_SCHEMA_BINDINGS_TYPES_TS); 703 let trade_product_filter = type_declaration( 704 REPLICA_SCHEMA_BINDINGS_TYPES_TS, 705 "ITradeProductFieldsFilter", 706 ); 707 let trade_product_partial = type_declaration( 708 REPLICA_SCHEMA_BINDINGS_TYPES_TS, 709 "ITradeProductFieldsPartial", 710 ); 711 712 assert!(actual.contains(&"Farm")); 713 assert!(actual.contains(&"GcsLocation")); 714 assert!(actual.contains(&"NostrEventHead")); 715 assert!(actual.contains(&"ReplicaStoreJsonValue")); 716 assert!(actual.contains(&"ITradeProductFieldsPartial")); 717 assert!(!actual.contains(&"NostrEventState")); 718 assert!(REPLICA_SCHEMA_BINDINGS_TYPES_TS.contains( 719 "export type ReplicaStoreJsonValue = null | boolean | number | string | Array<ReplicaStoreJsonValue> | { [key: string]: ReplicaStoreJsonValue };" 720 )); 721 assert!( 722 REPLICA_SCHEMA_BINDINGS_TYPES_TS 723 .contains("export type IFarmFindOneResolve = ReplicaSchemaResult<Farm | null>;") 724 ); 725 assert!(actual.contains(&"ReplicaSchemaResult")); 726 assert!(actual.contains(&"ReplicaSchemaResultList")); 727 assert!(actual.contains(&"ReplicaSchemaResultPass")); 728 assert!(actual.contains(&"ReplicaSchemaError")); 729 assert!(trade_product_filter.contains("year?: bigint")); 730 assert!(trade_product_filter.contains("qty_avail?: bigint")); 731 assert!(trade_product_partial.contains("year?: ReplicaStoreJsonValue | null")); 732 assert!(trade_product_partial.contains("qty_avail?: ReplicaStoreJsonValue | null")); 733 } 734 735 #[test] 736 fn replica_schema_generated_types_match_source_public_inventory() { 737 let actual = type_inventory(REPLICA_SCHEMA_BINDINGS_TYPES_TS) 738 .into_iter() 739 .collect::<BTreeSet<_>>(); 740 let missing = source_public_schema_type_inventory() 741 .into_iter() 742 .filter(|name| !actual.contains(name)) 743 .collect::<Vec<_>>(); 744 745 assert!( 746 missing.is_empty(), 747 "missing generated replica schema exports: {missing:?}" 748 ); 749 } 750 751 #[test] 752 fn trade_package_imports_source_owned_support_types() { 753 assert!(TRADE_BINDINGS_TYPES_TS.contains("from \"@radroots/core-bindings\"")); 754 assert!(TRADE_BINDINGS_TYPES_TS.contains("from \"@radroots/event-bindings\"")); 755 756 let imports = imported_type_inventory(TRADE_BINDINGS_TYPES_TS); 757 assert!( 758 imports 759 .get("@radroots/event-bindings") 760 .is_some_and(|names| names.contains("RadrootsFarmRef")) 761 ); 762 assert!( 763 imports 764 .get("@radroots/event-bindings") 765 .is_some_and(|names| names.contains("RadrootsOperationalListing")) 766 ); 767 assert!( 768 imports 769 .get("@radroots/event-bindings") 770 .is_some_and(|names| names.contains("RadrootsOperationalListingBin")) 771 ); 772 773 for duplicate in [ 774 "export type RadrootsOperationalListing = ", 775 "export type RadrootsFarmRef = ", 776 "export type RadrootsOrderEconomics = ", 777 "export type RadrootsOrderInventoryCommitment = ", 778 "export type RadrootsCommercialMessagePayload = ", 779 "export type RadrootsCommercialMessageType = ", 780 "export type RadrootsOrderStatus = ", 781 "export type RadrootsTradeOrderWorkflowMessage = ", 782 "export type RadrootsOrderWorkflowProjection = ", 783 "export type RadrootsTradeMarketplaceOrderSummary = ", 784 "export type RadrootsTradeOrderQuery = ", 785 ] { 786 assert!(!TRADE_BINDINGS_TYPES_TS.contains(duplicate)); 787 } 788 789 let projection = type_declaration(TRADE_BINDINGS_TYPES_TS, "RadrootsTradeProjectionV1"); 790 assert!(projection.contains("agreement_state: RadrootsTradeAgreementStateV1")); 791 assert!(projection.contains("negotiation_state: RadrootsTradeNegotiationStateV1")); 792 assert!(projection.contains("agreement_claims: Array<RadrootsTradeAgreementClaimV1>")); 793 assert!(projection.contains("issues: Array<RadrootsTradeReducerIssueV1>")); 794 assert!(projection.contains("projection_digest: string")); 795 for stale_counter in [ 796 "root_event_id", 797 "last_message_type", 798 "last_discount_request", 799 "last_discount_offer", 800 "accepted_discount", 801 "last_discount_decline_reason", 802 "has_requested_discounts", 803 "question_count", 804 "answer_count", 805 "discount_request_count", 806 "discount_offer_count", 807 "discount_accept_count", 808 "discount_decline_count", 809 ] { 810 assert!(!projection.contains(stale_counter)); 811 } 812 } 813 814 fn type_inventory(types_ts: &str) -> Vec<&str> { 815 types_ts 816 .lines() 817 .filter_map(|line| line.strip_prefix("export type ")) 818 .map(|rest| rest.split([' ', '<']).next().expect("type name")) 819 .collect() 820 } 821 822 fn source_public_schema_type_inventory() -> Vec<&'static str> { 823 let mut names = BTreeSet::new(); 824 825 for source in REPLICA_SCHEMA_MODEL_SOURCES { 826 for line in source.lines() { 827 if let Some(name) = public_rust_type_name(line) 828 && !name.ends_with("Ts") 829 { 830 names.insert(name); 831 } 832 } 833 } 834 835 names.into_iter().collect() 836 } 837 838 fn public_rust_type_name(line: &'static str) -> Option<&'static str> { 839 let line = line.trim_start(); 840 841 ["pub struct ", "pub enum ", "pub type "] 842 .into_iter() 843 .find_map(|prefix| { 844 line.strip_prefix(prefix).map(|rest| { 845 rest.split(|char: char| !(char == '_' || char.is_ascii_alphanumeric())) 846 .next() 847 .expect("type name") 848 }) 849 }) 850 } 851 852 fn type_declaration<'a>(types_ts: &'a str, name: &str) -> &'a str { 853 types_ts 854 .lines() 855 .find(|line| line.starts_with(&format!("export type {name} = "))) 856 .unwrap_or_else(|| panic!("missing type declaration for {name}")) 857 } 858 }