From 660029133de934bc190e503bb06f4b56f63cbb2a Mon Sep 17 00:00:00 2001 From: Hyo Date: Thu, 23 Jul 2026 22:08:12 +0900 Subject: [PATCH 1/9] docs: clarify one-time discount offers Align DiscountOffer documentation and generated API comments with the Google Play one-time offer contract. Refresh examples and search entries, and add regression audits for offer semantics and Kotlin purchase calls. Closes #249 --- libraries/expo-iap/src/types.ts | 54 +- .../flutter_inapp_purchase/lib/types.dart | 52 +- libraries/godot-iap/addons/godot-iap/types.gd | 22 +- .../io/github/hyochan/kmpiap/openiap/Types.kt | 52 +- libraries/maui-iap/src/OpenIap.Maui/Types.cs | 52 +- libraries/react-native-iap/src/types.ts | 54 +- packages/apple/Sources/Models/Types.swift | 52 +- packages/docs/src/lib/searchData.ts | 33 +- .../docs/src/pages/docs/features/discount.tsx | 654 +++++++++--------- ...one-time-purchase-offer-detail-android.tsx | 2 +- .../src/pages/docs/types/discount-offer.tsx | 145 ++-- packages/docs/src/pages/docs/types/index.tsx | 2 +- .../docs/src/pages/docs/types/product.tsx | 36 +- .../docs/types/request-purchase-props.tsx | 3 +- .../pages/docs/types/subscription-offer.tsx | 76 +- .../pages/docs/types/subscription-product.tsx | 25 +- .../src/main/java/dev/hyo/openiap/Types.kt | 52 +- packages/gql/src/generated/Types.cs | 52 +- packages/gql/src/generated/Types.kt | 52 +- packages/gql/src/generated/Types.swift | 52 +- packages/gql/src/generated/types.dart | 52 +- packages/gql/src/generated/types.gd | 22 +- packages/gql/src/generated/types.ts | 54 +- packages/gql/src/type-android.graphql | 36 +- packages/gql/src/type-ios.graphql | 10 +- packages/gql/src/type.graphql | 12 +- scripts/audit-docs.test.ts | 307 +++++++- scripts/audit-docs.ts | 379 ++++++++++ 28 files changed, 1538 insertions(+), 856 deletions(-) diff --git a/libraries/expo-iap/src/types.ts b/libraries/expo-iap/src/types.ts index 25c7de813..5866e2db8 100644 --- a/libraries/expo-iap/src/types.ts +++ b/libraries/expo-iap/src/types.ts @@ -328,7 +328,7 @@ export interface DiscountDisplayInfoAndroid { /** * Discount information returned from the store. * @deprecated Use the standardized SubscriptionOffer type instead for cross-platform compatibility. - * @see https://openiap.dev/docs/types#subscription-offer + * @see https://openiap.dev/docs/types/subscription-offer */ export interface DiscountIOS { identifier: string; @@ -343,10 +343,11 @@ export interface DiscountIOS { /** * Standardized one-time product discount offer. - * Provides a unified interface for one-time purchase discounts across platforms. + * Provides a platform-neutral OpenIAP shape for Google Play one-time product + * purchase options and offers. * - * Currently supported on Android (Google Play Billing 8.0+). - * iOS does not support one-time purchase discounts in the same way. + * Currently populated only on Android (Google Play Billing 8.0+). + * iOS does not populate this type. * * @see https://openiap.dev/docs/features/discount */ @@ -360,7 +361,7 @@ export interface DiscountOffer { discountAmountMicrosAndroid?: (string | null); /** Formatted display price string (e.g., "$4.99") */ displayPrice: string; - /** [Android] Formatted discount amount string (e.g., "$5.00 OFF"). */ + /** [Android] Formatted discount amount including its currency sign (e.g., "$5.00"). */ formattedDiscountAmountAndroid?: (string | null); /** * [Android] Original full price in micro-units before discount. @@ -418,7 +419,7 @@ export interface DiscountOffer { /** * iOS DiscountOffer (output type). * @deprecated Use the standardized SubscriptionOffer type instead for cross-platform compatibility. - * @see https://openiap.dev/docs/types#subscription-offer + * @see https://openiap.dev/docs/types/subscription-offer */ export interface DiscountOfferIOS { /** Discount identifier */ @@ -1093,9 +1094,9 @@ export interface ProductAndroid extends ProductCommon { debugDescription?: (string | null); description: string; /** - * Standardized discount offers for one-time products. - * Cross-platform type with Android-specific fields using suffix. - * @see https://openiap.dev/docs/types#discount-offer + * Standardized Android one-time product purchase options and offers. + * Native metadata uses Android-suffixed fields. + * @see https://openiap.dev/docs/types/discount-offer */ discountOffers?: (DiscountOffer[] | null); displayName?: (string | null); @@ -1105,7 +1106,7 @@ export interface ProductAndroid extends ProductCommon { /** * One-time purchase offer details including discounts (Android) * Returns all eligible offers. Available in Google Play Billing Library 8.0+ - * @deprecated Use discountOffers instead for cross-platform compatibility. + * @deprecated Use the standardized discountOffers field instead. * @deprecated Use discountOffers instead */ oneTimePurchaseOfferDetailsAndroid?: (ProductAndroidOneTimePurchaseOfferDetail[] | null); @@ -1127,7 +1128,7 @@ export interface ProductAndroid extends ProductCommon { /** * Standardized subscription offers. * Cross-platform type with Android-specific fields using suffix. - * @see https://openiap.dev/docs/types#subscription-offer + * @see https://openiap.dev/docs/types/subscription-offer */ subscriptionOffers?: (SubscriptionOffer[] | null); title: string; @@ -1137,8 +1138,8 @@ export interface ProductAndroid extends ProductCommon { /** * One-time purchase offer details (Android). * Available in Google Play Billing Library 8.0+ - * @deprecated Use the standardized DiscountOffer type instead for cross-platform compatibility. - * @see https://openiap.dev/docs/types#discount-offer + * @deprecated Use the standardized DiscountOffer type for Android one-time offers. + * @see https://openiap.dev/docs/types/discount-offer */ export interface ProductAndroidOneTimePurchaseOfferDetail { /** @@ -1218,7 +1219,7 @@ export interface ProductIOS extends ProductCommon { * Standardized subscription offers. * Cross-platform type with iOS-specific fields using suffix. * Note: iOS does not support one-time product discounts. - * @see https://openiap.dev/docs/types#subscription-offer + * @see https://openiap.dev/docs/types/subscription-offer */ subscriptionOffers?: (SubscriptionOffer[] | null); title: string; @@ -1250,9 +1251,8 @@ export interface ProductSubscriptionAndroid extends ProductCommon { debugDescription?: (string | null); description: string; /** - * Standardized discount offers for one-time products. - * Cross-platform type with Android-specific fields using suffix. - * @see https://openiap.dev/docs/types#discount-offer + * Nullable compatibility field. Google Play does not return one-time purchase + * offer details for subscription products; use subscriptionOffers below. */ discountOffers?: (DiscountOffer[] | null); displayName?: (string | null); @@ -1260,10 +1260,11 @@ export interface ProductSubscriptionAndroid extends ProductCommon { id: string; nameAndroid: string; /** - * One-time purchase offer details including discounts (Android) - * Returns all eligible offers. Available in Google Play Billing Library 8.0+ - * @deprecated Use discountOffers instead for cross-platform compatibility. - * @deprecated Use discountOffers instead + * Legacy nullable compatibility field. Google Play does not populate one-time + * purchase offer details for subscription products. + * @deprecated One-time offers belong to ProductAndroid.discountOffers; + * subscriptions use subscriptionOffers. + * @deprecated Use subscriptionOffers instead */ oneTimePurchaseOfferDetailsAndroid?: (ProductAndroidOneTimePurchaseOfferDetail[] | null); platform: 'android'; @@ -1284,7 +1285,7 @@ export interface ProductSubscriptionAndroid extends ProductCommon { /** * Standardized subscription offers. * Cross-platform type with Android-specific fields using suffix. - * @see https://openiap.dev/docs/types#subscription-offer + * @see https://openiap.dev/docs/types/subscription-offer */ subscriptionOffers: SubscriptionOffer[]; title: string; @@ -1294,7 +1295,7 @@ export interface ProductSubscriptionAndroid extends ProductCommon { /** * Subscription offer details (Android). * @deprecated Use the standardized SubscriptionOffer type instead for cross-platform compatibility. - * @see https://openiap.dev/docs/types#subscription-offer + * @see https://openiap.dev/docs/types/subscription-offer */ export interface ProductSubscriptionAndroidOfferDetails { basePlanId: string; @@ -1347,7 +1348,7 @@ export interface ProductSubscriptionIOS extends ProductCommon { /** * Standardized subscription offers. * Cross-platform type with iOS-specific fields using suffix. - * @see https://openiap.dev/docs/types#subscription-offer + * @see https://openiap.dev/docs/types/subscription-offer */ subscriptionOffers?: (SubscriptionOffer[] | null); subscriptionPeriodNumberIOS?: (string | null); @@ -2126,8 +2127,7 @@ export interface SubscriptionInfoIOS { * - iOS: Introductory offers, promotional offers with server-side signatures * - Android: Offer tokens with pricing phases * - * @see https://openiap.dev/docs/types/ios#discount-offer - * @see https://openiap.dev/docs/types/android#subscription-offer + * @see https://openiap.dev/docs/types/subscription-offer */ export interface SubscriptionOffer { /** @@ -2202,7 +2202,7 @@ export interface SubscriptionOffer { /** * iOS subscription offer details. * @deprecated Use the standardized SubscriptionOffer type instead for cross-platform compatibility. - * @see https://openiap.dev/docs/types#subscription-offer + * @see https://openiap.dev/docs/types/subscription-offer */ export interface SubscriptionOfferIOS { displayPrice: string; diff --git a/libraries/flutter_inapp_purchase/lib/types.dart b/libraries/flutter_inapp_purchase/lib/types.dart index e3af72e5c..0dffa88a4 100644 --- a/libraries/flutter_inapp_purchase/lib/types.dart +++ b/libraries/flutter_inapp_purchase/lib/types.dart @@ -1982,7 +1982,7 @@ class DiscountDisplayInfoAndroid { /// Discount information returned from the store. /// @deprecated Use the standardized SubscriptionOffer type instead for cross-platform compatibility. -/// @see https://openiap.dev/docs/types#subscription-offer +/// @see https://openiap.dev/docs/types/subscription-offer class DiscountIOS { const DiscountIOS({ required this.identifier, @@ -2033,10 +2033,11 @@ class DiscountIOS { } /// Standardized one-time product discount offer. -/// Provides a unified interface for one-time purchase discounts across platforms. +/// Provides a platform-neutral OpenIAP shape for Google Play one-time product +/// purchase options and offers. /// -/// Currently supported on Android (Google Play Billing 8.0+). -/// iOS does not support one-time purchase discounts in the same way. +/// Currently populated only on Android (Google Play Billing 8.0+). +/// iOS does not populate this type. /// /// @see https://openiap.dev/docs/features/discount class DiscountOffer { @@ -2066,7 +2067,7 @@ class DiscountOffer { final String? discountAmountMicrosAndroid; /// Formatted display price string (e.g., "$4.99") final String displayPrice; - /// [Android] Formatted discount amount string (e.g., "$5.00 OFF"). + /// [Android] Formatted discount amount including its currency sign (e.g., "$5.00"). final String? formattedDiscountAmountAndroid; /// [Android] Original full price in micro-units before discount. /// Divide by 1,000,000 to get the actual price. @@ -2150,7 +2151,7 @@ class DiscountOffer { /// iOS DiscountOffer (output type). /// @deprecated Use the standardized SubscriptionOffer type instead for cross-platform compatibility. -/// @see https://openiap.dev/docs/types#subscription-offer +/// @see https://openiap.dev/docs/types/subscription-offer class DiscountOfferIOS { const DiscountOfferIOS({ required this.identifier, @@ -2691,9 +2692,9 @@ class ProductAndroid extends Product implements ProductCommon { final String currency; final String? debugDescription; final String description; - /// Standardized discount offers for one-time products. - /// Cross-platform type with Android-specific fields using suffix. - /// @see https://openiap.dev/docs/types#discount-offer + /// Standardized Android one-time product purchase options and offers. + /// Native metadata uses Android-suffixed fields. + /// @see https://openiap.dev/docs/types/discount-offer final List? discountOffers; final String? displayName; final String displayPrice; @@ -2701,7 +2702,7 @@ class ProductAndroid extends Product implements ProductCommon { final String nameAndroid; /// One-time purchase offer details including discounts (Android) /// Returns all eligible offers. Available in Google Play Billing Library 8.0+ - /// @deprecated Use discountOffers instead for cross-platform compatibility. + /// @deprecated Use the standardized discountOffers field instead. final List? oneTimePurchaseOfferDetailsAndroid; final IapPlatform platform; final double? price; @@ -2715,7 +2716,7 @@ class ProductAndroid extends Product implements ProductCommon { final List? subscriptionOfferDetailsAndroid; /// Standardized subscription offers. /// Cross-platform type with Android-specific fields using suffix. - /// @see https://openiap.dev/docs/types#subscription-offer + /// @see https://openiap.dev/docs/types/subscription-offer final List? subscriptionOffers; final String title; final ProductType type; @@ -2767,8 +2768,8 @@ class ProductAndroid extends Product implements ProductCommon { /// One-time purchase offer details (Android). /// Available in Google Play Billing Library 8.0+ -/// @deprecated Use the standardized DiscountOffer type instead for cross-platform compatibility. -/// @see https://openiap.dev/docs/types#discount-offer +/// @deprecated Use the standardized DiscountOffer type for Android one-time offers. +/// @see https://openiap.dev/docs/types/discount-offer class ProductAndroidOneTimePurchaseOfferDetail { const ProductAndroidOneTimePurchaseOfferDetail({ this.discountDisplayInfo, @@ -2893,7 +2894,7 @@ class ProductIOS extends Product implements ProductCommon { /// Standardized subscription offers. /// Cross-platform type with iOS-specific fields using suffix. /// Note: iOS does not support one-time product discounts. - /// @see https://openiap.dev/docs/types#subscription-offer + /// @see https://openiap.dev/docs/types/subscription-offer final List? subscriptionOffers; final String title; final ProductType type; @@ -2969,17 +2970,17 @@ class ProductSubscriptionAndroid extends ProductSubscription implements ProductC final String currency; final String? debugDescription; final String description; - /// Standardized discount offers for one-time products. - /// Cross-platform type with Android-specific fields using suffix. - /// @see https://openiap.dev/docs/types#discount-offer + /// Nullable compatibility field. Google Play does not return one-time purchase + /// offer details for subscription products; use subscriptionOffers below. final List? discountOffers; final String? displayName; final String displayPrice; final String id; final String nameAndroid; - /// One-time purchase offer details including discounts (Android) - /// Returns all eligible offers. Available in Google Play Billing Library 8.0+ - /// @deprecated Use discountOffers instead for cross-platform compatibility. + /// Legacy nullable compatibility field. Google Play does not populate one-time + /// purchase offer details for subscription products. + /// @deprecated One-time offers belong to ProductAndroid.discountOffers; + /// subscriptions use subscriptionOffers. final List? oneTimePurchaseOfferDetailsAndroid; final IapPlatform platform; final double? price; @@ -2993,7 +2994,7 @@ class ProductSubscriptionAndroid extends ProductSubscription implements ProductC final List subscriptionOfferDetailsAndroid; /// Standardized subscription offers. /// Cross-platform type with Android-specific fields using suffix. - /// @see https://openiap.dev/docs/types#subscription-offer + /// @see https://openiap.dev/docs/types/subscription-offer final List subscriptionOffers; final String title; final ProductType type; @@ -3045,7 +3046,7 @@ class ProductSubscriptionAndroid extends ProductSubscription implements ProductC /// Subscription offer details (Android). /// @deprecated Use the standardized SubscriptionOffer type instead for cross-platform compatibility. -/// @see https://openiap.dev/docs/types#subscription-offer +/// @see https://openiap.dev/docs/types/subscription-offer class ProductSubscriptionAndroidOfferDetails { const ProductSubscriptionAndroidOfferDetails({ required this.basePlanId, @@ -3147,7 +3148,7 @@ class ProductSubscriptionIOS extends ProductSubscription implements ProductCommo final SubscriptionInfoIOS? subscriptionInfoIOS; /// Standardized subscription offers. /// Cross-platform type with iOS-specific fields using suffix. - /// @see https://openiap.dev/docs/types#subscription-offer + /// @see https://openiap.dev/docs/types/subscription-offer final List? subscriptionOffers; final String? subscriptionPeriodNumberIOS; final SubscriptionPeriodIOS? subscriptionPeriodUnitIOS; @@ -3915,8 +3916,7 @@ class SubscriptionInfoIOS { /// - iOS: Introductory offers, promotional offers with server-side signatures /// - Android: Offer tokens with pricing phases /// -/// @see https://openiap.dev/docs/types/ios#discount-offer -/// @see https://openiap.dev/docs/types/android#subscription-offer +/// @see https://openiap.dev/docs/types/subscription-offer class SubscriptionOffer { const SubscriptionOffer({ this.basePlanIdAndroid, @@ -4042,7 +4042,7 @@ class SubscriptionOffer { /// iOS subscription offer details. /// @deprecated Use the standardized SubscriptionOffer type instead for cross-platform compatibility. -/// @see https://openiap.dev/docs/types#subscription-offer +/// @see https://openiap.dev/docs/types/subscription-offer class SubscriptionOfferIOS { const SubscriptionOfferIOS({ required this.displayPrice, diff --git a/libraries/godot-iap/addons/godot-iap/types.gd b/libraries/godot-iap/addons/godot-iap/types.gd index 6322882b8..17db2f5a1 100644 --- a/libraries/godot-iap/addons/godot-iap/types.gd +++ b/libraries/godot-iap/addons/godot-iap/types.gd @@ -1005,7 +1005,7 @@ class DiscountDisplayInfoAndroid: dict["discountAmount"] = discount_amount return dict -## Discount information returned from the store. @deprecated Use the standardized SubscriptionOffer type instead for cross-platform compatibility. @see https://openiap.dev/docs/types#subscription-offer +## Discount information returned from the store. @deprecated Use the standardized SubscriptionOffer type instead for cross-platform compatibility. @see https://openiap.dev/docs/types/subscription-offer class DiscountIOS: var identifier: String = "" var type: String = "" @@ -1056,7 +1056,7 @@ class DiscountIOS: dict["localizedPrice"] = localized_price return dict -## Standardized one-time product discount offer. Provides a unified interface for one-time purchase discounts across platforms. Currently supported on Android (Google Play Billing 8.0+). iOS does not support one-time purchase discounts in the same way. @see https://openiap.dev/docs/features/discount +## Standardized one-time product discount offer. Provides a platform-neutral OpenIAP shape for Google Play one-time product purchase options and offers. Currently populated only on Android (Google Play Billing 8.0+). iOS does not populate this type. @see https://openiap.dev/docs/features/discount class DiscountOffer: ## Unique identifier for the offer. var id: Variant = null @@ -1078,7 +1078,7 @@ class DiscountOffer: var percentage_discount_android: Variant = null ## [Android] Fixed discount amount in micro-units. var discount_amount_micros_android: Variant = null - ## [Android] Formatted discount amount string (e.g., "$5.00 OFF"). + ## [Android] Formatted discount amount including its currency sign (e.g., "$5.00"). var formatted_discount_amount_android: Variant = null ## [Android] Valid time window for the offer. var valid_time_window_android: ValidTimeWindowAndroid @@ -1190,7 +1190,7 @@ class DiscountOffer: dict["purchaseOptionIdAndroid"] = purchase_option_id_android return dict -## iOS DiscountOffer (output type). @deprecated Use the standardized SubscriptionOffer type instead for cross-platform compatibility. @see https://openiap.dev/docs/types#subscription-offer +## iOS DiscountOffer (output type). @deprecated Use the standardized SubscriptionOffer type instead for cross-platform compatibility. @see https://openiap.dev/docs/types/subscription-offer class DiscountOfferIOS: ## Discount identifier var identifier: String = "" @@ -1612,7 +1612,7 @@ class ProductAndroid: var name_android: String = "" ## Product-level status code indicating fetch result (Android 8.0+) var product_status_android: Variant = null - ## Standardized discount offers for one-time products. + ## Standardized Android one-time product purchase options and offers. var discount_offers: Array[DiscountOffer] = [] ## Standardized subscription offers. var subscription_offers: Array[SubscriptionOffer] = [] @@ -1766,7 +1766,7 @@ class ProductAndroid: dict["subscriptionOfferDetailsAndroid"] = null return dict -## One-time purchase offer details (Android). Available in Google Play Billing Library 8.0+ @deprecated Use the standardized DiscountOffer type instead for cross-platform compatibility. @see https://openiap.dev/docs/types#discount-offer +## One-time purchase offer details (Android). Available in Google Play Billing Library 8.0+ @deprecated Use the standardized DiscountOffer type for Android one-time offers. @see https://openiap.dev/docs/types/discount-offer class ProductAndroidOneTimePurchaseOfferDetail: ## Offer ID var offer_id: Variant = null @@ -2034,11 +2034,11 @@ class ProductSubscriptionAndroid: var name_android: String = "" ## Product-level status code indicating fetch result (Android 8.0+) var product_status_android: Variant = null - ## Standardized discount offers for one-time products. + ## Nullable compatibility field. Google Play does not return one-time purchase var discount_offers: Array[DiscountOffer] = [] ## Standardized subscription offers. var subscription_offers: Array[SubscriptionOffer] = [] - ## One-time purchase offer details including discounts (Android) + ## Legacy nullable compatibility field. Google Play does not populate one-time var one_time_purchase_offer_details_android: Array[ProductAndroidOneTimePurchaseOfferDetail] = [] ## @deprecated Use subscriptionOffers instead for cross-platform compatibility. var subscription_offer_details_android: Array[ProductSubscriptionAndroidOfferDetails] = [] @@ -2188,7 +2188,7 @@ class ProductSubscriptionAndroid: dict["subscriptionOfferDetailsAndroid"] = null return dict -## Subscription offer details (Android). @deprecated Use the standardized SubscriptionOffer type instead for cross-platform compatibility. @see https://openiap.dev/docs/types#subscription-offer +## Subscription offer details (Android). @deprecated Use the standardized SubscriptionOffer type instead for cross-platform compatibility. @see https://openiap.dev/docs/types/subscription-offer class ProductSubscriptionAndroidOfferDetails: var base_plan_id: String = "" var offer_id: Variant = null @@ -3281,7 +3281,7 @@ class SubscriptionInfoIOS: dict["subscriptionPeriod"] = subscription_period return dict -## Standardized subscription discount/promotional offer. Provides a unified interface for subscription offers across iOS and Android. Both platforms support subscription offers with different implementations: - iOS: Introductory offers, promotional offers with server-side signatures - Android: Offer tokens with pricing phases @see https://openiap.dev/docs/types/ios#discount-offer @see https://openiap.dev/docs/types/android#subscription-offer +## Standardized subscription discount/promotional offer. Provides a unified interface for subscription offers across iOS and Android. Both platforms support subscription offers with different implementations: - iOS: Introductory offers, promotional offers with server-side signatures - Android: Offer tokens with pricing phases @see https://openiap.dev/docs/types/subscription-offer class SubscriptionOffer: ## Unique identifier for the offer. var id: String = "" @@ -3435,7 +3435,7 @@ class SubscriptionOffer: dict["installmentPlanDetailsAndroid"] = installment_plan_details_android return dict -## iOS subscription offer details. @deprecated Use the standardized SubscriptionOffer type instead for cross-platform compatibility. @see https://openiap.dev/docs/types#subscription-offer +## iOS subscription offer details. @deprecated Use the standardized SubscriptionOffer type instead for cross-platform compatibility. @see https://openiap.dev/docs/types/subscription-offer class SubscriptionOfferIOS: var display_price: String = "" var id: String = "" diff --git a/libraries/kmp-iap/library/src/commonMain/kotlin/io/github/hyochan/kmpiap/openiap/Types.kt b/libraries/kmp-iap/library/src/commonMain/kotlin/io/github/hyochan/kmpiap/openiap/Types.kt index c57f504ea..a501815bb 100644 --- a/libraries/kmp-iap/library/src/commonMain/kotlin/io/github/hyochan/kmpiap/openiap/Types.kt +++ b/libraries/kmp-iap/library/src/commonMain/kotlin/io/github/hyochan/kmpiap/openiap/Types.kt @@ -2205,7 +2205,7 @@ public data class DiscountDisplayInfoAndroid( /** * Discount information returned from the store. * @deprecated Use the standardized SubscriptionOffer type instead for cross-platform compatibility. - * @see https://openiap.dev/docs/types#subscription-offer + * @see https://openiap.dev/docs/types/subscription-offer */ public data class DiscountIOS( val identifier: String, @@ -2248,10 +2248,11 @@ public data class DiscountIOS( /** * Standardized one-time product discount offer. - * Provides a unified interface for one-time purchase discounts across platforms. + * Provides a platform-neutral OpenIAP shape for Google Play one-time product + * purchase options and offers. * - * Currently supported on Android (Google Play Billing 8.0+). - * iOS does not support one-time purchase discounts in the same way. + * Currently populated only on Android (Google Play Billing 8.0+). + * iOS does not populate this type. * * @see https://openiap.dev/docs/features/discount */ @@ -2270,7 +2271,7 @@ public data class DiscountOffer( */ val displayPrice: String, /** - * [Android] Formatted discount amount string (e.g., "$5.00 OFF"). + * [Android] Formatted discount amount including its currency sign (e.g., "$5.00"). */ val formattedDiscountAmountAndroid: String? = null, /** @@ -2381,7 +2382,7 @@ public data class DiscountOffer( /** * iOS DiscountOffer (output type). * @deprecated Use the standardized SubscriptionOffer type instead for cross-platform compatibility. - * @see https://openiap.dev/docs/types#subscription-offer + * @see https://openiap.dev/docs/types/subscription-offer */ public data class DiscountOfferIOS( /** @@ -2898,9 +2899,9 @@ public data class ProductAndroid( override val debugDescription: String? = null, override val description: String, /** - * Standardized discount offers for one-time products. - * Cross-platform type with Android-specific fields using suffix. - * @see https://openiap.dev/docs/types#discount-offer + * Standardized Android one-time product purchase options and offers. + * Native metadata uses Android-suffixed fields. + * @see https://openiap.dev/docs/types/discount-offer */ val discountOffers: List? = null, override val displayName: String? = null, @@ -2910,7 +2911,7 @@ public data class ProductAndroid( /** * One-time purchase offer details including discounts (Android) * Returns all eligible offers. Available in Google Play Billing Library 8.0+ - * @deprecated Use discountOffers instead for cross-platform compatibility. + * @deprecated Use the standardized discountOffers field instead. */ val oneTimePurchaseOfferDetailsAndroid: List? = null, override val platform: IapPlatform = IapPlatform.Android, @@ -2930,7 +2931,7 @@ public data class ProductAndroid( /** * Standardized subscription offers. * Cross-platform type with Android-specific fields using suffix. - * @see https://openiap.dev/docs/types#subscription-offer + * @see https://openiap.dev/docs/types/subscription-offer */ val subscriptionOffers: List? = null, override val title: String, @@ -2984,8 +2985,8 @@ public data class ProductAndroid( /** * One-time purchase offer details (Android). * Available in Google Play Billing Library 8.0+ - * @deprecated Use the standardized DiscountOffer type instead for cross-platform compatibility. - * @see https://openiap.dev/docs/types#discount-offer + * @deprecated Use the standardized DiscountOffer type for Android one-time offers. + * @see https://openiap.dev/docs/types/discount-offer */ public data class ProductAndroidOneTimePurchaseOfferDetail( /** @@ -3101,7 +3102,7 @@ public data class ProductIOS( * Standardized subscription offers. * Cross-platform type with iOS-specific fields using suffix. * Note: iOS does not support one-time product discounts. - * @see https://openiap.dev/docs/types#subscription-offer + * @see https://openiap.dev/docs/types/subscription-offer */ val subscriptionOffers: List? = null, override val title: String, @@ -3160,9 +3161,8 @@ public data class ProductSubscriptionAndroid( override val debugDescription: String? = null, override val description: String, /** - * Standardized discount offers for one-time products. - * Cross-platform type with Android-specific fields using suffix. - * @see https://openiap.dev/docs/types#discount-offer + * Nullable compatibility field. Google Play does not return one-time purchase + * offer details for subscription products; use subscriptionOffers below. */ val discountOffers: List? = null, override val displayName: String? = null, @@ -3170,9 +3170,10 @@ public data class ProductSubscriptionAndroid( override val id: String, val nameAndroid: String, /** - * One-time purchase offer details including discounts (Android) - * Returns all eligible offers. Available in Google Play Billing Library 8.0+ - * @deprecated Use discountOffers instead for cross-platform compatibility. + * Legacy nullable compatibility field. Google Play does not populate one-time + * purchase offer details for subscription products. + * @deprecated One-time offers belong to ProductAndroid.discountOffers; + * subscriptions use subscriptionOffers. */ val oneTimePurchaseOfferDetailsAndroid: List? = null, override val platform: IapPlatform = IapPlatform.Android, @@ -3192,7 +3193,7 @@ public data class ProductSubscriptionAndroid( /** * Standardized subscription offers. * Cross-platform type with Android-specific fields using suffix. - * @see https://openiap.dev/docs/types#subscription-offer + * @see https://openiap.dev/docs/types/subscription-offer */ val subscriptionOffers: List, override val title: String, @@ -3246,7 +3247,7 @@ public data class ProductSubscriptionAndroid( /** * Subscription offer details (Android). * @deprecated Use the standardized SubscriptionOffer type instead for cross-platform compatibility. - * @see https://openiap.dev/docs/types#subscription-offer + * @see https://openiap.dev/docs/types/subscription-offer */ public data class ProductSubscriptionAndroidOfferDetails( val basePlanId: String, @@ -3323,7 +3324,7 @@ public data class ProductSubscriptionIOS( /** * Standardized subscription offers. * Cross-platform type with iOS-specific fields using suffix. - * @see https://openiap.dev/docs/types#subscription-offer + * @see https://openiap.dev/docs/types/subscription-offer */ val subscriptionOffers: List? = null, val subscriptionPeriodNumberIOS: String? = null, @@ -4045,8 +4046,7 @@ public data class SubscriptionInfoIOS( * - iOS: Introductory offers, promotional offers with server-side signatures * - Android: Offer tokens with pricing phases * - * @see https://openiap.dev/docs/types/ios#discount-offer - * @see https://openiap.dev/docs/types/android#subscription-offer + * @see https://openiap.dev/docs/types/subscription-offer */ public data class SubscriptionOffer( /** @@ -4191,7 +4191,7 @@ public data class SubscriptionOffer( /** * iOS subscription offer details. * @deprecated Use the standardized SubscriptionOffer type instead for cross-platform compatibility. - * @see https://openiap.dev/docs/types#subscription-offer + * @see https://openiap.dev/docs/types/subscription-offer */ public data class SubscriptionOfferIOS( val displayPrice: String, diff --git a/libraries/maui-iap/src/OpenIap.Maui/Types.cs b/libraries/maui-iap/src/OpenIap.Maui/Types.cs index 7677ef615..1da78ba5c 100644 --- a/libraries/maui-iap/src/OpenIap.Maui/Types.cs +++ b/libraries/maui-iap/src/OpenIap.Maui/Types.cs @@ -2944,7 +2944,7 @@ public sealed record DiscountDisplayInfoAndroid /// Discount information returned from the store. /// @deprecated Use the standardized SubscriptionOffer type instead for cross-platform compatibility. -/// @see https://openiap.dev/docs/types#subscription-offer +/// @see https://openiap.dev/docs/types/subscription-offer public sealed record DiscountIOS { [JsonPropertyName("identifier")] @@ -2966,10 +2966,11 @@ public sealed record DiscountIOS } /// Standardized one-time product discount offer. -/// Provides a unified interface for one-time purchase discounts across platforms. +/// Provides a platform-neutral OpenIAP shape for Google Play one-time product +/// purchase options and offers. /// -/// Currently supported on Android (Google Play Billing 8.0+). -/// iOS does not support one-time purchase discounts in the same way. +/// Currently populated only on Android (Google Play Billing 8.0+). +/// iOS does not populate this type. /// /// @see https://openiap.dev/docs/features/discount public sealed record DiscountOffer @@ -2984,7 +2985,7 @@ public sealed record DiscountOffer /// Formatted display price string (e.g., "$4.99") [JsonPropertyName("displayPrice")] public required string DisplayPrice { get; init; } - /// [Android] Formatted discount amount string (e.g., "$5.00 OFF"). + /// [Android] Formatted discount amount including its currency sign (e.g., "$5.00"). [JsonPropertyName("formattedDiscountAmountAndroid")] public string? FormattedDiscountAmountAndroid { get; init; } /// [Android] Original full price in micro-units before discount. @@ -3038,7 +3039,7 @@ public sealed record DiscountOffer /// iOS DiscountOffer (output type). /// @deprecated Use the standardized SubscriptionOffer type instead for cross-platform compatibility. -/// @see https://openiap.dev/docs/types#subscription-offer +/// @see https://openiap.dev/docs/types/subscription-offer public sealed record DiscountOfferIOS { /// Discount identifier @@ -3265,9 +3266,9 @@ public sealed record ProductAndroid : Product, ProductCommon public string? DebugDescription { get; init; } [JsonPropertyName("description")] public required string Description { get; init; } - /// Standardized discount offers for one-time products. - /// Cross-platform type with Android-specific fields using suffix. - /// @see https://openiap.dev/docs/types#discount-offer + /// Standardized Android one-time product purchase options and offers. + /// Native metadata uses Android-suffixed fields. + /// @see https://openiap.dev/docs/types/discount-offer [JsonPropertyName("discountOffers")] public IReadOnlyList? DiscountOffers { get; init; } [JsonPropertyName("displayName")] @@ -3280,7 +3281,7 @@ public sealed record ProductAndroid : Product, ProductCommon public required string NameAndroid { get; init; } /// One-time purchase offer details including discounts (Android) /// Returns all eligible offers. Available in Google Play Billing Library 8.0+ - /// @deprecated Use discountOffers instead for cross-platform compatibility. + /// @deprecated Use the standardized discountOffers field instead. [JsonPropertyName("oneTimePurchaseOfferDetailsAndroid")] public IReadOnlyList? OneTimePurchaseOfferDetailsAndroid { get; init; } [JsonPropertyName("platform")] @@ -3299,7 +3300,7 @@ public sealed record ProductAndroid : Product, ProductCommon public IReadOnlyList? SubscriptionOfferDetailsAndroid { get; init; } /// Standardized subscription offers. /// Cross-platform type with Android-specific fields using suffix. - /// @see https://openiap.dev/docs/types#subscription-offer + /// @see https://openiap.dev/docs/types/subscription-offer [JsonPropertyName("subscriptionOffers")] public IReadOnlyList? SubscriptionOffers { get; init; } [JsonPropertyName("title")] @@ -3310,8 +3311,8 @@ public sealed record ProductAndroid : Product, ProductCommon /// One-time purchase offer details (Android). /// Available in Google Play Billing Library 8.0+ -/// @deprecated Use the standardized DiscountOffer type instead for cross-platform compatibility. -/// @see https://openiap.dev/docs/types#discount-offer +/// @deprecated Use the standardized DiscountOffer type for Android one-time offers. +/// @see https://openiap.dev/docs/types/discount-offer public sealed record ProductAndroidOneTimePurchaseOfferDetail { /// Discount display information @@ -3391,7 +3392,7 @@ public sealed record ProductIOS : Product, ProductCommon /// Standardized subscription offers. /// Cross-platform type with iOS-specific fields using suffix. /// Note: iOS does not support one-time product discounts. - /// @see https://openiap.dev/docs/types#subscription-offer + /// @see https://openiap.dev/docs/types/subscription-offer [JsonPropertyName("subscriptionOffers")] public IReadOnlyList? SubscriptionOffers { get; init; } [JsonPropertyName("title")] @@ -3410,9 +3411,8 @@ public sealed record ProductSubscriptionAndroid : ProductSubscription, ProductCo public string? DebugDescription { get; init; } [JsonPropertyName("description")] public required string Description { get; init; } - /// Standardized discount offers for one-time products. - /// Cross-platform type with Android-specific fields using suffix. - /// @see https://openiap.dev/docs/types#discount-offer + /// Nullable compatibility field. Google Play does not return one-time purchase + /// offer details for subscription products; use subscriptionOffers below. [JsonPropertyName("discountOffers")] public IReadOnlyList? DiscountOffers { get; init; } [JsonPropertyName("displayName")] @@ -3423,9 +3423,10 @@ public sealed record ProductSubscriptionAndroid : ProductSubscription, ProductCo public required string Id { get; init; } [JsonPropertyName("nameAndroid")] public required string NameAndroid { get; init; } - /// One-time purchase offer details including discounts (Android) - /// Returns all eligible offers. Available in Google Play Billing Library 8.0+ - /// @deprecated Use discountOffers instead for cross-platform compatibility. + /// Legacy nullable compatibility field. Google Play does not populate one-time + /// purchase offer details for subscription products. + /// @deprecated One-time offers belong to ProductAndroid.discountOffers; + /// subscriptions use subscriptionOffers. [JsonPropertyName("oneTimePurchaseOfferDetailsAndroid")] public IReadOnlyList? OneTimePurchaseOfferDetailsAndroid { get; init; } [JsonPropertyName("platform")] @@ -3444,7 +3445,7 @@ public sealed record ProductSubscriptionAndroid : ProductSubscription, ProductCo public required IReadOnlyList SubscriptionOfferDetailsAndroid { get; init; } /// Standardized subscription offers. /// Cross-platform type with Android-specific fields using suffix. - /// @see https://openiap.dev/docs/types#subscription-offer + /// @see https://openiap.dev/docs/types/subscription-offer [JsonPropertyName("subscriptionOffers")] public required IReadOnlyList SubscriptionOffers { get; init; } [JsonPropertyName("title")] @@ -3455,7 +3456,7 @@ public sealed record ProductSubscriptionAndroid : ProductSubscription, ProductCo /// Subscription offer details (Android). /// @deprecated Use the standardized SubscriptionOffer type instead for cross-platform compatibility. -/// @see https://openiap.dev/docs/types#subscription-offer +/// @see https://openiap.dev/docs/types/subscription-offer public sealed record ProductSubscriptionAndroidOfferDetails { [JsonPropertyName("basePlanId")] @@ -3524,7 +3525,7 @@ public sealed record ProductSubscriptionIOS : ProductSubscription, ProductCommon public SubscriptionInfoIOS? SubscriptionInfoIOS { get; init; } /// Standardized subscription offers. /// Cross-platform type with iOS-specific fields using suffix. - /// @see https://openiap.dev/docs/types#subscription-offer + /// @see https://openiap.dev/docs/types/subscription-offer [JsonPropertyName("subscriptionOffers")] public IReadOnlyList? SubscriptionOffers { get; init; } [JsonPropertyName("subscriptionPeriodNumberIOS")] @@ -3861,8 +3862,7 @@ public sealed record SubscriptionInfoIOS /// - iOS: Introductory offers, promotional offers with server-side signatures /// - Android: Offer tokens with pricing phases /// -/// @see https://openiap.dev/docs/types/ios#discount-offer -/// @see https://openiap.dev/docs/types/android#subscription-offer +/// @see https://openiap.dev/docs/types/subscription-offer public sealed record SubscriptionOffer { /// [Android] Base plan identifier. @@ -3937,7 +3937,7 @@ public sealed record SubscriptionOffer /// iOS subscription offer details. /// @deprecated Use the standardized SubscriptionOffer type instead for cross-platform compatibility. -/// @see https://openiap.dev/docs/types#subscription-offer +/// @see https://openiap.dev/docs/types/subscription-offer public sealed record SubscriptionOfferIOS { [JsonPropertyName("displayPrice")] diff --git a/libraries/react-native-iap/src/types.ts b/libraries/react-native-iap/src/types.ts index 25c7de813..5866e2db8 100644 --- a/libraries/react-native-iap/src/types.ts +++ b/libraries/react-native-iap/src/types.ts @@ -328,7 +328,7 @@ export interface DiscountDisplayInfoAndroid { /** * Discount information returned from the store. * @deprecated Use the standardized SubscriptionOffer type instead for cross-platform compatibility. - * @see https://openiap.dev/docs/types#subscription-offer + * @see https://openiap.dev/docs/types/subscription-offer */ export interface DiscountIOS { identifier: string; @@ -343,10 +343,11 @@ export interface DiscountIOS { /** * Standardized one-time product discount offer. - * Provides a unified interface for one-time purchase discounts across platforms. + * Provides a platform-neutral OpenIAP shape for Google Play one-time product + * purchase options and offers. * - * Currently supported on Android (Google Play Billing 8.0+). - * iOS does not support one-time purchase discounts in the same way. + * Currently populated only on Android (Google Play Billing 8.0+). + * iOS does not populate this type. * * @see https://openiap.dev/docs/features/discount */ @@ -360,7 +361,7 @@ export interface DiscountOffer { discountAmountMicrosAndroid?: (string | null); /** Formatted display price string (e.g., "$4.99") */ displayPrice: string; - /** [Android] Formatted discount amount string (e.g., "$5.00 OFF"). */ + /** [Android] Formatted discount amount including its currency sign (e.g., "$5.00"). */ formattedDiscountAmountAndroid?: (string | null); /** * [Android] Original full price in micro-units before discount. @@ -418,7 +419,7 @@ export interface DiscountOffer { /** * iOS DiscountOffer (output type). * @deprecated Use the standardized SubscriptionOffer type instead for cross-platform compatibility. - * @see https://openiap.dev/docs/types#subscription-offer + * @see https://openiap.dev/docs/types/subscription-offer */ export interface DiscountOfferIOS { /** Discount identifier */ @@ -1093,9 +1094,9 @@ export interface ProductAndroid extends ProductCommon { debugDescription?: (string | null); description: string; /** - * Standardized discount offers for one-time products. - * Cross-platform type with Android-specific fields using suffix. - * @see https://openiap.dev/docs/types#discount-offer + * Standardized Android one-time product purchase options and offers. + * Native metadata uses Android-suffixed fields. + * @see https://openiap.dev/docs/types/discount-offer */ discountOffers?: (DiscountOffer[] | null); displayName?: (string | null); @@ -1105,7 +1106,7 @@ export interface ProductAndroid extends ProductCommon { /** * One-time purchase offer details including discounts (Android) * Returns all eligible offers. Available in Google Play Billing Library 8.0+ - * @deprecated Use discountOffers instead for cross-platform compatibility. + * @deprecated Use the standardized discountOffers field instead. * @deprecated Use discountOffers instead */ oneTimePurchaseOfferDetailsAndroid?: (ProductAndroidOneTimePurchaseOfferDetail[] | null); @@ -1127,7 +1128,7 @@ export interface ProductAndroid extends ProductCommon { /** * Standardized subscription offers. * Cross-platform type with Android-specific fields using suffix. - * @see https://openiap.dev/docs/types#subscription-offer + * @see https://openiap.dev/docs/types/subscription-offer */ subscriptionOffers?: (SubscriptionOffer[] | null); title: string; @@ -1137,8 +1138,8 @@ export interface ProductAndroid extends ProductCommon { /** * One-time purchase offer details (Android). * Available in Google Play Billing Library 8.0+ - * @deprecated Use the standardized DiscountOffer type instead for cross-platform compatibility. - * @see https://openiap.dev/docs/types#discount-offer + * @deprecated Use the standardized DiscountOffer type for Android one-time offers. + * @see https://openiap.dev/docs/types/discount-offer */ export interface ProductAndroidOneTimePurchaseOfferDetail { /** @@ -1218,7 +1219,7 @@ export interface ProductIOS extends ProductCommon { * Standardized subscription offers. * Cross-platform type with iOS-specific fields using suffix. * Note: iOS does not support one-time product discounts. - * @see https://openiap.dev/docs/types#subscription-offer + * @see https://openiap.dev/docs/types/subscription-offer */ subscriptionOffers?: (SubscriptionOffer[] | null); title: string; @@ -1250,9 +1251,8 @@ export interface ProductSubscriptionAndroid extends ProductCommon { debugDescription?: (string | null); description: string; /** - * Standardized discount offers for one-time products. - * Cross-platform type with Android-specific fields using suffix. - * @see https://openiap.dev/docs/types#discount-offer + * Nullable compatibility field. Google Play does not return one-time purchase + * offer details for subscription products; use subscriptionOffers below. */ discountOffers?: (DiscountOffer[] | null); displayName?: (string | null); @@ -1260,10 +1260,11 @@ export interface ProductSubscriptionAndroid extends ProductCommon { id: string; nameAndroid: string; /** - * One-time purchase offer details including discounts (Android) - * Returns all eligible offers. Available in Google Play Billing Library 8.0+ - * @deprecated Use discountOffers instead for cross-platform compatibility. - * @deprecated Use discountOffers instead + * Legacy nullable compatibility field. Google Play does not populate one-time + * purchase offer details for subscription products. + * @deprecated One-time offers belong to ProductAndroid.discountOffers; + * subscriptions use subscriptionOffers. + * @deprecated Use subscriptionOffers instead */ oneTimePurchaseOfferDetailsAndroid?: (ProductAndroidOneTimePurchaseOfferDetail[] | null); platform: 'android'; @@ -1284,7 +1285,7 @@ export interface ProductSubscriptionAndroid extends ProductCommon { /** * Standardized subscription offers. * Cross-platform type with Android-specific fields using suffix. - * @see https://openiap.dev/docs/types#subscription-offer + * @see https://openiap.dev/docs/types/subscription-offer */ subscriptionOffers: SubscriptionOffer[]; title: string; @@ -1294,7 +1295,7 @@ export interface ProductSubscriptionAndroid extends ProductCommon { /** * Subscription offer details (Android). * @deprecated Use the standardized SubscriptionOffer type instead for cross-platform compatibility. - * @see https://openiap.dev/docs/types#subscription-offer + * @see https://openiap.dev/docs/types/subscription-offer */ export interface ProductSubscriptionAndroidOfferDetails { basePlanId: string; @@ -1347,7 +1348,7 @@ export interface ProductSubscriptionIOS extends ProductCommon { /** * Standardized subscription offers. * Cross-platform type with iOS-specific fields using suffix. - * @see https://openiap.dev/docs/types#subscription-offer + * @see https://openiap.dev/docs/types/subscription-offer */ subscriptionOffers?: (SubscriptionOffer[] | null); subscriptionPeriodNumberIOS?: (string | null); @@ -2126,8 +2127,7 @@ export interface SubscriptionInfoIOS { * - iOS: Introductory offers, promotional offers with server-side signatures * - Android: Offer tokens with pricing phases * - * @see https://openiap.dev/docs/types/ios#discount-offer - * @see https://openiap.dev/docs/types/android#subscription-offer + * @see https://openiap.dev/docs/types/subscription-offer */ export interface SubscriptionOffer { /** @@ -2202,7 +2202,7 @@ export interface SubscriptionOffer { /** * iOS subscription offer details. * @deprecated Use the standardized SubscriptionOffer type instead for cross-platform compatibility. - * @see https://openiap.dev/docs/types#subscription-offer + * @see https://openiap.dev/docs/types/subscription-offer */ export interface SubscriptionOfferIOS { displayPrice: string; diff --git a/packages/apple/Sources/Models/Types.swift b/packages/apple/Sources/Models/Types.swift index 40ffcd7ae..a46c07cd5 100644 --- a/packages/apple/Sources/Models/Types.swift +++ b/packages/apple/Sources/Models/Types.swift @@ -838,7 +838,7 @@ public struct DiscountDisplayInfoAndroid: Codable { /// Discount information returned from the store. /// @deprecated Use the standardized SubscriptionOffer type instead for cross-platform compatibility. -/// @see https://openiap.dev/docs/types#subscription-offer +/// @see https://openiap.dev/docs/types/subscription-offer public struct DiscountIOS: Codable { public var identifier: String public var localizedPrice: String? = nil @@ -851,10 +851,11 @@ public struct DiscountIOS: Codable { } /// Standardized one-time product discount offer. -/// Provides a unified interface for one-time purchase discounts across platforms. +/// Provides a platform-neutral OpenIAP shape for Google Play one-time product +/// purchase options and offers. /// -/// Currently supported on Android (Google Play Billing 8.0+). -/// iOS does not support one-time purchase discounts in the same way. +/// Currently populated only on Android (Google Play Billing 8.0+). +/// iOS does not populate this type. /// /// @see https://openiap.dev/docs/features/discount public struct DiscountOffer: Codable { @@ -865,7 +866,7 @@ public struct DiscountOffer: Codable { public var discountAmountMicrosAndroid: String? = nil /// Formatted display price string (e.g., "$4.99") public var displayPrice: String - /// [Android] Formatted discount amount string (e.g., "$5.00 OFF"). + /// [Android] Formatted discount amount including its currency sign (e.g., "$5.00"). public var formattedDiscountAmountAndroid: String? = nil /// [Android] Original full price in micro-units before discount. /// Divide by 1,000,000 to get the actual price. @@ -906,7 +907,7 @@ public struct DiscountOffer: Codable { /// iOS DiscountOffer (output type). /// @deprecated Use the standardized SubscriptionOffer type instead for cross-platform compatibility. -/// @see https://openiap.dev/docs/types#subscription-offer +/// @see https://openiap.dev/docs/types/subscription-offer public struct DiscountOfferIOS: Codable { /// Discount identifier public var identifier: String @@ -1071,9 +1072,9 @@ public struct ProductAndroid: Codable, ProductCommon { public var currency: String public var debugDescription: String? = nil public var description: String - /// Standardized discount offers for one-time products. - /// Cross-platform type with Android-specific fields using suffix. - /// @see https://openiap.dev/docs/types#discount-offer + /// Standardized Android one-time product purchase options and offers. + /// Native metadata uses Android-suffixed fields. + /// @see https://openiap.dev/docs/types/discount-offer public var discountOffers: [DiscountOffer]? = nil public var displayName: String? = nil public var displayPrice: String @@ -1081,7 +1082,7 @@ public struct ProductAndroid: Codable, ProductCommon { public var nameAndroid: String /// One-time purchase offer details including discounts (Android) /// Returns all eligible offers. Available in Google Play Billing Library 8.0+ - /// @deprecated Use discountOffers instead for cross-platform compatibility. + /// @deprecated Use the standardized discountOffers field instead. public var oneTimePurchaseOfferDetailsAndroid: [ProductAndroidOneTimePurchaseOfferDetail]? = nil public var platform: IapPlatform = .android public var price: Double? = nil @@ -1095,7 +1096,7 @@ public struct ProductAndroid: Codable, ProductCommon { public var subscriptionOfferDetailsAndroid: [ProductSubscriptionAndroidOfferDetails]? = nil /// Standardized subscription offers. /// Cross-platform type with Android-specific fields using suffix. - /// @see https://openiap.dev/docs/types#subscription-offer + /// @see https://openiap.dev/docs/types/subscription-offer public var subscriptionOffers: [SubscriptionOffer]? = nil public var title: String public var type: ProductType = .inApp @@ -1103,8 +1104,8 @@ public struct ProductAndroid: Codable, ProductCommon { /// One-time purchase offer details (Android). /// Available in Google Play Billing Library 8.0+ -/// @deprecated Use the standardized DiscountOffer type instead for cross-platform compatibility. -/// @see https://openiap.dev/docs/types#discount-offer +/// @deprecated Use the standardized DiscountOffer type for Android one-time offers. +/// @see https://openiap.dev/docs/types/discount-offer public struct ProductAndroidOneTimePurchaseOfferDetail: Codable { /// Discount display information /// Only available for discounted offers @@ -1156,7 +1157,7 @@ public struct ProductIOS: Codable, ProductCommon { /// Standardized subscription offers. /// Cross-platform type with iOS-specific fields using suffix. /// Note: iOS does not support one-time product discounts. - /// @see https://openiap.dev/docs/types#subscription-offer + /// @see https://openiap.dev/docs/types/subscription-offer public var subscriptionOffers: [SubscriptionOffer]? = nil public var title: String public var type: ProductType = .inApp @@ -1167,17 +1168,17 @@ public struct ProductSubscriptionAndroid: Codable, ProductCommon { public var currency: String public var debugDescription: String? = nil public var description: String - /// Standardized discount offers for one-time products. - /// Cross-platform type with Android-specific fields using suffix. - /// @see https://openiap.dev/docs/types#discount-offer + /// Nullable compatibility field. Google Play does not return one-time purchase + /// offer details for subscription products; use subscriptionOffers below. public var discountOffers: [DiscountOffer]? = nil public var displayName: String? = nil public var displayPrice: String public var id: String public var nameAndroid: String - /// One-time purchase offer details including discounts (Android) - /// Returns all eligible offers. Available in Google Play Billing Library 8.0+ - /// @deprecated Use discountOffers instead for cross-platform compatibility. + /// Legacy nullable compatibility field. Google Play does not populate one-time + /// purchase offer details for subscription products. + /// @deprecated One-time offers belong to ProductAndroid.discountOffers; + /// subscriptions use subscriptionOffers. public var oneTimePurchaseOfferDetailsAndroid: [ProductAndroidOneTimePurchaseOfferDetail]? = nil public var platform: IapPlatform = .android public var price: Double? = nil @@ -1191,7 +1192,7 @@ public struct ProductSubscriptionAndroid: Codable, ProductCommon { public var subscriptionOfferDetailsAndroid: [ProductSubscriptionAndroidOfferDetails] /// Standardized subscription offers. /// Cross-platform type with Android-specific fields using suffix. - /// @see https://openiap.dev/docs/types#subscription-offer + /// @see https://openiap.dev/docs/types/subscription-offer public var subscriptionOffers: [SubscriptionOffer] public var title: String public var type: ProductType = .subs @@ -1199,7 +1200,7 @@ public struct ProductSubscriptionAndroid: Codable, ProductCommon { /// Subscription offer details (Android). /// @deprecated Use the standardized SubscriptionOffer type instead for cross-platform compatibility. -/// @see https://openiap.dev/docs/types#subscription-offer +/// @see https://openiap.dev/docs/types/subscription-offer public struct ProductSubscriptionAndroidOfferDetails: Codable { public var basePlanId: String /// Installment plan details for this subscription offer. @@ -1240,7 +1241,7 @@ public struct ProductSubscriptionIOS: Codable, ProductCommon { public var subscriptionInfoIOS: SubscriptionInfoIOS? = nil /// Standardized subscription offers. /// Cross-platform type with iOS-specific fields using suffix. - /// @see https://openiap.dev/docs/types#subscription-offer + /// @see https://openiap.dev/docs/types/subscription-offer public var subscriptionOffers: [SubscriptionOffer]? = nil public var subscriptionPeriodNumberIOS: String? = nil public var subscriptionPeriodUnitIOS: SubscriptionPeriodIOS? = nil @@ -1453,8 +1454,7 @@ public struct SubscriptionInfoIOS: Codable { /// - iOS: Introductory offers, promotional offers with server-side signatures /// - Android: Offer tokens with pricing phases /// -/// @see https://openiap.dev/docs/types/ios#discount-offer -/// @see https://openiap.dev/docs/types/android#subscription-offer +/// @see https://openiap.dev/docs/types/subscription-offer public struct SubscriptionOffer: Codable { /// [Android] Base plan identifier. /// Identifies which base plan this offer belongs to. @@ -1509,7 +1509,7 @@ public struct SubscriptionOffer: Codable { /// iOS subscription offer details. /// @deprecated Use the standardized SubscriptionOffer type instead for cross-platform compatibility. -/// @see https://openiap.dev/docs/types#subscription-offer +/// @see https://openiap.dev/docs/types/subscription-offer public struct SubscriptionOfferIOS: Codable { public var displayPrice: String public var id: String diff --git a/packages/docs/src/lib/searchData.ts b/packages/docs/src/lib/searchData.ts index 190b6b541..417f27c85 100644 --- a/packages/docs/src/lib/searchData.ts +++ b/packages/docs/src/lib/searchData.ts @@ -726,14 +726,15 @@ export const apiData: ApiItem[] = [ title: 'iOS Types', category: 'Types', description: - 'DiscountOffer, SubscriptionStatusIOS, PaymentMode, AppTransaction, SubscriptionBillingPlanTypeIOS', + 'DiscountOfferIOS, SubscriptionStatusIOS, PaymentMode, AppTransaction, SubscriptionBillingPlanTypeIOS', path: '/docs/types#ios-types', }, { id: 'types-android', title: 'Android Types', category: 'Types', - description: 'SubscriptionOffer, PricingPhase, PricingPhasesAndroid', + description: + 'ProductSubscriptionAndroidOfferDetails, PricingPhase, PricingPhasesAndroid', path: '/docs/types#android-types', }, { @@ -744,14 +745,30 @@ export const apiData: ApiItem[] = [ 'AlternativeBillingModeAndroid, InitConnectionConfig, External Purchase Link', path: '/docs/types/alternative-billing-types', }, - - // iOS-Specific Types (from types/ios.tsx) { id: 'discount-offer', title: 'DiscountOffer', + category: 'Types', + description: + 'Standardized Android one-time product purchase options and offers', + path: '/docs/types/discount-offer', + }, + { + id: 'subscription-offer', + title: 'SubscriptionOffer', + category: 'Types', + description: + 'Standardized iOS and Android subscription introductory and promotional offers', + path: '/docs/types/subscription-offer', + }, + + // iOS-Specific Types (from types/ios.tsx) + { + id: 'discount-offer-ios', + title: 'DiscountOfferIOS', category: 'Types (iOS)', description: - 'iOS promotional offer for purchase: identifier, keyIdentifier, nonce, signature, timestamp', + 'Deprecated iOS promotional offer: identifier, keyIdentifier, nonce, signature, timestamp', path: '/docs/types/ios/discount-offer-ios', }, { @@ -811,11 +828,11 @@ export const apiData: ApiItem[] = [ // Android-Specific Types (from types/android.tsx) { - id: 'subscription-offer', - title: 'SubscriptionOffer', + id: 'subscription-offer-android', + title: 'ProductSubscriptionAndroidOfferDetails', category: 'Types (Android)', description: - 'Android subscription offer: sku, offerToken for Play Billing purchases', + 'Deprecated Android subscription offer details with Play Billing offer tokens', path: '/docs/types/android/subscription-offer-android', }, { diff --git a/packages/docs/src/pages/docs/features/discount.tsx b/packages/docs/src/pages/docs/features/discount.tsx index 6ca0203e5..781c52df0 100644 --- a/packages/docs/src/pages/docs/features/discount.tsx +++ b/packages/docs/src/pages/docs/features/discount.tsx @@ -31,12 +31,15 @@ function Discount() {

-
+

- Standardized Types: For cross-platform development, - use the new DiscountOffer{' '} - type which provides a unified interface with platform-specific fields - via suffixes (e.g., offerTokenAndroid). + Standardized API: Read{' '} + ProductAndroid.discountOffers. Each{' '} + DiscountOffer exposes + common price fields and Android-only details through the{' '} + Android suffix (for example,{' '} + offerTokenAndroid). The older{' '} + oneTimePurchaseOfferDetailsAndroid field is deprecated.

@@ -78,224 +81,168 @@ function Discount() { Data Structure

- The oneTimePurchaseOfferDetailsAndroid field is now an - array containing all available offers for a product: + The discountOffers field contains standardized{' '} + DiscountOffer values. The following excerpts use the + generated field names:

{{ typescript: ( - {`interface ProductAndroidOneTimePurchaseOfferDetail { - // Offer identification - offerId: string | null; - offerToken: string; - offerTags: string[]; - - // Pricing - formattedPrice: string; // "$4.99" - priceCurrencyCode: string; // "USD" - priceAmountMicros: string; // "4990000" - - // Discount information (only for discounted offers) - fullPriceMicros: string | null; // Original price: "9990000" - discountDisplayInfo: DiscountDisplayInfoAndroid | null; - - // Time and quantity limits - validTimeWindow: ValidTimeWindowAndroid | null; - limitedQuantityInfo: LimitedQuantityInfoAndroid | null; - - // Special offer types - preorderDetailsAndroid: PreorderDetailsAndroid | null; - rentalDetailsAndroid: RentalDetailsAndroid | null; -} - -interface DiscountDisplayInfoAndroid { - percentageDiscount: number | null; // 50 for 50% off - discountAmount: DiscountAmountAndroid | null; -} - -interface DiscountAmountAndroid { - discountAmountMicros: string; // "5000000" - formattedDiscountAmount: string; // "$5.00" -} - -interface ValidTimeWindowAndroid { - startTimeMillis: string; - endTimeMillis: string; + {`interface ProductAndroid { + discountOffers?: DiscountOffer[] | null; } -interface LimitedQuantityInfoAndroid { - maximumQuantity: number; - remainingQuantity: number; +interface DiscountOffer { + id?: string | null; + displayPrice: string; // "$4.99" + price: number; // 4.99 + currency: string; // "USD" + type: DiscountOfferType; // "one-time" + offerTokenAndroid?: string | null; + offerTagsAndroid?: string[] | null; + fullPriceMicrosAndroid?: string | null; // "9990000" + percentageDiscountAndroid?: number | null; + discountAmountMicrosAndroid?: string | null; + formattedDiscountAmountAndroid?: string | null; + validTimeWindowAndroid?: ValidTimeWindowAndroid | null; + limitedQuantityInfoAndroid?: LimitedQuantityInfoAndroid | null; + preorderDetailsAndroid?: PreorderDetailsAndroid | null; + rentalDetailsAndroid?: RentalDetailsAndroid | null; + purchaseOptionIdAndroid?: string | null; }`} ), swift: ( {`// iOS does not support one-time purchase discounts in the same way. -// For iOS promotional offers, see the Subscription feature documentation.`} +// For iOS subscription offers, use SubscriptionOffer instead.`} ), kotlin: ( - {`data class ProductAndroidOneTimePurchaseOfferDetail( - // Offer identification - val offerId: String?, - val offerToken: String, - val offerTags: List, - - // Pricing - val formattedPrice: String, // "$4.99" - val priceCurrencyCode: String, // "USD" - val priceAmountMicros: String, // "4990000" - - // Discount information (only for discounted offers) - val fullPriceMicros: String?, // Original price: "9990000" - val discountDisplayInfo: DiscountDisplayInfoAndroid?, - - // Time and quantity limits - val validTimeWindow: ValidTimeWindowAndroid?, - val limitedQuantityInfo: LimitedQuantityInfoAndroid?, - - // Special offer types - val preorderDetailsAndroid: PreorderDetailsAndroid?, - val rentalDetailsAndroid: RentalDetailsAndroid? -) - -data class DiscountDisplayInfoAndroid( - val percentageDiscount: Int?, // 50 for 50% off - val discountAmount: DiscountAmountAndroid? -) - -data class DiscountAmountAndroid( - val discountAmountMicros: String, // "5000000" - val formattedDiscountAmount: String // "$5.00" -) - -data class ValidTimeWindowAndroid( - val startTimeMillis: String, - val endTimeMillis: String + {`data class ProductAndroid( + val discountOffers: List? = null, + // ... ) -data class LimitedQuantityInfoAndroid( - val maximumQuantity: Int, - val remainingQuantity: Int +data class DiscountOffer( + val id: String? = null, + val displayPrice: String, + val price: Double, + val currency: String, + val type: DiscountOfferType, + val offerTokenAndroid: String? = null, + val offerTagsAndroid: List? = null, + val fullPriceMicrosAndroid: String? = null, + val percentageDiscountAndroid: Int? = null, + val discountAmountMicrosAndroid: String? = null, + val formattedDiscountAmountAndroid: String? = null, + val validTimeWindowAndroid: ValidTimeWindowAndroid? = null, + val limitedQuantityInfoAndroid: LimitedQuantityInfoAndroid? = null, + val preorderDetailsAndroid: PreorderDetailsAndroid? = null, + val rentalDetailsAndroid: RentalDetailsAndroid? = null, + val purchaseOptionIdAndroid: String? = null )`} ), kmp: ( - {`data class ProductAndroidOneTimePurchaseOfferDetail( - // Offer identification - val offerId: String?, - val offerToken: String, - val offerTags: List, - - // Pricing - val formattedPrice: String, // "$4.99" - val priceCurrencyCode: String, // "USD" - val priceAmountMicros: String, // "4990000" - - // Discount information (only for discounted offers) - val fullPriceMicros: String?, // Original price: "9990000" - val discountDisplayInfo: DiscountDisplayInfoAndroid?, - - // Time and quantity limits - val validTimeWindow: ValidTimeWindowAndroid?, - val limitedQuantityInfo: LimitedQuantityInfoAndroid?, - - // Special offer types - val preorderDetailsAndroid: PreorderDetailsAndroid?, - val rentalDetailsAndroid: RentalDetailsAndroid? -) - -data class DiscountDisplayInfoAndroid( - val percentageDiscount: Int?, // 50 for 50% off - val discountAmount: DiscountAmountAndroid? -) - -data class DiscountAmountAndroid( - val discountAmountMicros: String, // "5000000" - val formattedDiscountAmount: String // "$5.00" -) - -data class ValidTimeWindowAndroid( - val startTimeMillis: String, - val endTimeMillis: String + {`data class ProductAndroid( + val discountOffers: List? = null, + // ... ) -data class LimitedQuantityInfoAndroid( - val maximumQuantity: Int, - val remainingQuantity: Int +data class DiscountOffer( + val id: String? = null, + val displayPrice: String, + val price: Double, + val currency: String, + val type: DiscountOfferType, + val offerTokenAndroid: String? = null, + val offerTagsAndroid: List? = null, + val fullPriceMicrosAndroid: String? = null, + val percentageDiscountAndroid: Int? = null, + val discountAmountMicrosAndroid: String? = null, + val formattedDiscountAmountAndroid: String? = null, + val validTimeWindowAndroid: ValidTimeWindowAndroid? = null, + val limitedQuantityInfoAndroid: LimitedQuantityInfoAndroid? = null, + val preorderDetailsAndroid: PreorderDetailsAndroid? = null, + val rentalDetailsAndroid: RentalDetailsAndroid? = null, + val purchaseOptionIdAndroid: String? = null )`} ), dart: ( - {`class ProductAndroidOneTimePurchaseOfferDetail { - // Offer identification - final String? offerId; - final String offerToken; - final List offerTags; - - // Pricing - final String formattedPrice; // "\$4.99" - final String priceCurrencyCode; // "USD" - final String priceAmountMicros; // "4990000" - - // Discount information (only for discounted offers) - final String? fullPriceMicros; // Original price: "9990000" - final DiscountDisplayInfoAndroid? discountDisplayInfo; - - // Time and quantity limits - final ValidTimeWindowAndroid? validTimeWindow; - final LimitedQuantityInfoAndroid? limitedQuantityInfo; + {`class ProductAndroid { + final List? discountOffers; + // ... +} + +class DiscountOffer { + final String? id; + final String displayPrice; // "\$4.99" + final double price; // 4.99 + final String currency; // "USD" + final DiscountOfferType type; + final String? offerTokenAndroid; + final List? offerTagsAndroid; + final String? fullPriceMicrosAndroid; // "9990000" + final int? percentageDiscountAndroid; + final String? discountAmountMicrosAndroid; + final String? formattedDiscountAmountAndroid; + final ValidTimeWindowAndroid? validTimeWindowAndroid; + final LimitedQuantityInfoAndroid? limitedQuantityInfoAndroid; + final PreorderDetailsAndroid? preorderDetailsAndroid; + final RentalDetailsAndroid? rentalDetailsAndroid; + final String? purchaseOptionIdAndroid; }`} ), csharp: ( {`using OpenIap; using System.Collections.Generic; -public sealed record ProductAndroidOneTimePurchaseOfferDetail -{ - public string? OfferId { get; init; } - public required string OfferToken { get; init; } - public required IReadOnlyList OfferTags { get; init; } - public required string FormattedPrice { get; init; } // "$4.99" - public required string PriceCurrencyCode { get; init; } // "USD" - public required string PriceAmountMicros { get; init; } // "4990000" - public string? FullPriceMicros { get; init; } // "9990000" - public DiscountDisplayInfoAndroid? DiscountDisplayInfo { get; init; } - public ValidTimeWindowAndroid? ValidTimeWindow { get; init; } - public LimitedQuantityInfoAndroid? LimitedQuantityInfo { get; init; } - public PreorderDetailsAndroid? PreorderDetailsAndroid { get; init; } - public RentalDetailsAndroid? RentalDetailsAndroid { get; init; } -} - -public sealed record DiscountDisplayInfoAndroid +public sealed record ProductAndroid { - public int? PercentageDiscount { get; init; } // 50 for 50% off - public DiscountAmountAndroid? DiscountAmount { get; init; } + public IReadOnlyList? DiscountOffers { get; init; } + // ... } -public sealed record DiscountAmountAndroid +public sealed record DiscountOffer { - public required string DiscountAmountMicros { get; init; } // "5000000" - public required string FormattedDiscountAmount { get; init; } // "$5.00" + public string? Id { get; init; } + public required string DisplayPrice { get; init; } // "$4.99" + public required double Price { get; init; } // 4.99 + public required string Currency { get; init; } // "USD" + public required DiscountOfferType Type { get; init; } + public string? OfferTokenAndroid { get; init; } + public IReadOnlyList? OfferTagsAndroid { get; init; } + public string? FullPriceMicrosAndroid { get; init; } // "9990000" + public int? PercentageDiscountAndroid { get; init; } + public string? DiscountAmountMicrosAndroid { get; init; } + public string? FormattedDiscountAmountAndroid { get; init; } + public ValidTimeWindowAndroid? ValidTimeWindowAndroid { get; init; } + public LimitedQuantityInfoAndroid? LimitedQuantityInfoAndroid { get; init; } + public PreorderDetailsAndroid? PreorderDetailsAndroid { get; init; } + public RentalDetailsAndroid? RentalDetailsAndroid { get; init; } + public string? PurchaseOptionIdAndroid { get; init; } }`} ), gdscript: ( - {`class_name ProductAndroidOneTimePurchaseOfferDetail - -# Offer identification -var offer_id: String # May be empty -var offer_token: String # Required for purchase -var offer_tags: Array[String] # Tags for categorization - -# Pricing -var formatted_price: String # "$4.99" -var price_currency_code: String # "USD" -var price_amount_micros: String # "4990000" - -# Discount information (only for discounted offers) -var full_price_micros: String # Original price: "9990000" -var discount_display_info: DiscountDisplayInfoAndroid - -# Time and quantity limits -var valid_time_window: ValidTimeWindowAndroid -var limited_quantity_info: LimitedQuantityInfoAndroid`} + {`class ProductAndroid: + var discount_offers: Array[DiscountOffer] = [] + # ... + +class DiscountOffer: + var id: Variant = null + var display_price: String = "" # "$4.99" + var price: float = 0.0 # 4.99 + var currency: String = "" # "USD" + var type: DiscountOfferType + var offer_token_android: Variant = null + var offer_tags_android: Array[String] = [] + var full_price_micros_android: Variant = null # "9990000" + var percentage_discount_android: Variant = null + var discount_amount_micros_android: Variant = null + var formatted_discount_amount_android: Variant = null + var valid_time_window_android: ValidTimeWindowAndroid + var limited_quantity_info_android: LimitedQuantityInfoAndroid + var preorder_details_android: PreorderDetailsAndroid + var rental_details_android: RentalDetailsAndroid + var purchase_option_id_android: Variant = null`} ), }} @@ -307,8 +254,8 @@ var limited_quantity_info: LimitedQuantityInfoAndroid`}

Fetch products normally using fetchProducts. Discounted - offers will be included in the{' '} - oneTimePurchaseOfferDetailsAndroid array: + one-time offers will be included in the discountOffers{' '} + array:

@@ -321,22 +268,33 @@ const products = await fetchProducts({ }); products.forEach((product) => { - const offers = product.oneTimePurchaseOfferDetailsAndroid; + if (product.platform !== 'android') return; + + const offers = product.discountOffers; if (offers && offers.length > 0) { const firstOffer = offers[0]; - const hasDiscount = firstOffer.discountDisplayInfo != null; + const hasDiscount = + firstOffer.percentageDiscountAndroid != null || + firstOffer.discountAmountMicrosAndroid != null; console.log('Product:', product.id); - console.log('Display Price:', product.displayPrice); + console.log('Display Price:', firstOffer.displayPrice); if (hasDiscount) { - const discount = firstOffer.discountDisplayInfo; - const fullPriceMicros = parseInt(firstOffer.fullPriceMicros || '0', 10); + const fullPriceMicros = parseInt( + firstOffer.fullPriceMicrosAndroid || '0', + 10, + ); const fullPrice = fullPriceMicros / 1_000_000; console.log('Original Price:', fullPrice); - console.log('Discount:', discount?.percentageDiscount + '% OFF'); + console.log( + 'Discount:', + firstOffer.percentageDiscountAndroid != null + ? \`\${firstOffer.percentageDiscountAndroid}% OFF\` + : firstOffer.formattedDiscountAmountAndroid, + ); } } });`} @@ -363,22 +321,28 @@ val products = (result as? FetchProductsResultProducts) .filterIsInstance() products.forEach { product -> - val offers = product.oneTimePurchaseOfferDetailsAndroid + val offers = product.discountOffers if (!offers.isNullOrEmpty()) { val firstOffer = offers.first() - val hasDiscount = firstOffer.discountDisplayInfo != null + val hasDiscount = + firstOffer.percentageDiscountAndroid != null || + firstOffer.discountAmountMicrosAndroid != null println("Product: \${product.id}") - println("Display Price: \${product.displayPrice}") + println("Display Price: \${firstOffer.displayPrice}") if (hasDiscount) { - val discount = firstOffer.discountDisplayInfo - val fullPriceMicros = firstOffer.fullPriceMicros?.toLongOrNull() ?: 0L + val fullPriceMicros = + firstOffer.fullPriceMicrosAndroid?.toLongOrNull() ?: 0L val fullPrice = fullPriceMicros.toDouble() / 1_000_000.0 println("Original Price: $fullPrice") - println("Discount: \${discount?.percentageDiscount}% OFF") + println( + "Discount: " + + (firstOffer.percentageDiscountAndroid?.let { "$it% OFF" } + ?: firstOffer.formattedDiscountAmountAndroid) + ) } } }`} @@ -401,22 +365,28 @@ val products = (result as? FetchProductsResultProducts) .filterIsInstance() products.forEach { product -> - val offers = product.oneTimePurchaseOfferDetailsAndroid + val offers = product.discountOffers if (!offers.isNullOrEmpty()) { val firstOffer = offers.first() - val hasDiscount = firstOffer.discountDisplayInfo != null + val hasDiscount = + firstOffer.percentageDiscountAndroid != null || + firstOffer.discountAmountMicrosAndroid != null println("Product: \${product.id}") - println("Display Price: \${product.displayPrice}") + println("Display Price: \${firstOffer.displayPrice}") if (hasDiscount) { - val discount = firstOffer.discountDisplayInfo - val fullPriceMicros = firstOffer.fullPriceMicros?.toLongOrNull() ?: 0L + val fullPriceMicros = + firstOffer.fullPriceMicrosAndroid?.toLongOrNull() ?: 0L val fullPrice = fullPriceMicros.toDouble() / 1_000_000.0 println("Original Price: $fullPrice") - println("Discount: \${discount?.percentageDiscount}% OFF") + println( + "Discount: " + + (firstOffer.percentageDiscountAndroid?.let { "$it% OFF" } + ?: firstOffer.formattedDiscountAmountAndroid) + ) } } }`} @@ -428,22 +398,27 @@ products.forEach { product -> for (final product in products) { if (product is! ProductAndroid) continue; - final offers = product.oneTimePurchaseOfferDetailsAndroid; + final offers = product.discountOffers; if (offers != null && offers.isNotEmpty) { final firstOffer = offers.first; - final hasDiscount = firstOffer.discountDisplayInfo != null; + final hasDiscount = + firstOffer.percentageDiscountAndroid != null || + firstOffer.discountAmountMicrosAndroid != null; print('Product: \${product.id}'); - print('Display Price: \${product.displayPrice}'); + print('Display Price: \${firstOffer.displayPrice}'); if (hasDiscount) { - final discount = firstOffer.discountDisplayInfo!; - final fullPriceMicros = int.tryParse(firstOffer.fullPriceMicros ?? '0') ?? 0; + final fullPriceMicros = + int.tryParse(firstOffer.fullPriceMicrosAndroid ?? '0') ?? 0; final fullPrice = fullPriceMicros / 1000000; print('Original Price: $fullPrice'); - print('Discount: \${discount.percentageDiscount}% OFF'); + final discountText = firstOffer.percentageDiscountAndroid != null + ? '\${firstOffer.percentageDiscountAndroid}% OFF' + : firstOffer.formattedDiscountAmountAndroid; + print('Discount: $discountText'); } } }`} @@ -466,25 +441,31 @@ var products = result is FetchProductsResultProducts productResult foreach (var product in products) { - var firstOffer = product.OneTimePurchaseOfferDetailsAndroid?.FirstOrDefault(); + var firstOffer = product.DiscountOffers?.FirstOrDefault(); if (firstOffer is null) { continue; } Console.WriteLine($"Product: {product.Id}"); - Console.WriteLine($"Display Price: {product.DisplayPrice}"); + Console.WriteLine($"Display Price: {firstOffer.DisplayPrice}"); - var discount = firstOffer.DiscountDisplayInfo; - if (discount is not null) + var hasDiscount = firstOffer.PercentageDiscountAndroid is not null + || firstOffer.DiscountAmountMicrosAndroid is not null; + if (hasDiscount) { - var fullPriceMicros = long.TryParse(firstOffer.FullPriceMicros, out var micros) + var fullPriceMicros = long.TryParse( + firstOffer.FullPriceMicrosAndroid, + out var micros) ? micros : 0; var fullPrice = fullPriceMicros / 1_000_000.0; + var discountText = firstOffer.PercentageDiscountAndroid is { } percent + ? $"{percent}% OFF" + : firstOffer.FormattedDiscountAmountAndroid; Console.WriteLine($"Original Price: {fullPrice}"); - Console.WriteLine($"Discount: {discount.PercentageDiscount}% OFF"); + Console.WriteLine($"Discount: {discountText}"); } }`} ), @@ -495,22 +476,30 @@ request.type = ProductQueryType.IN_APP var products = await iap.fetch_products(request) for product in products: - var offers = product.one_time_purchase_offer_details_android + var offers = product.discount_offers if offers and offers.size() > 0: var first_offer = offers[0] - var has_discount = first_offer.discount_display_info != null + var has_discount = ( + first_offer.percentage_discount_android != null + or first_offer.discount_amount_micros_android != null + ) print("Product: %s" % product.id) - print("Display Price: %s" % product.display_price) + print("Display Price: %s" % first_offer.display_price) if has_discount: - var discount = first_offer.discount_display_info - var full_price_micros = int(first_offer.full_price_micros) if first_offer.full_price_micros else 0 + var full_price_micros = ( + int(first_offer.full_price_micros_android) + if first_offer.full_price_micros_android else 0 + ) var full_price = full_price_micros / 1000000.0 print("Original Price: %.2f" % full_price) - print("Discount: %d%% OFF" % discount.percentage_discount)`} + if first_offer.percentage_discount_android != null: + print("Discount: %d%% OFF" % first_offer.percentage_discount_android) + else: + print("Discount: %s" % first_offer.formatted_discount_amount_android)`} ), }} @@ -532,24 +521,28 @@ for product in products: import type { ProductAndroid } from 'expo-iap'; function ProductCard({ product }: { product: ProductAndroid }) { - const offers = product.oneTimePurchaseOfferDetailsAndroid; + const offers = product.discountOffers; const firstOffer = offers?.[0]; - const discount = firstOffer?.discountDisplayInfo; - const hasDiscount = discount != null; + const hasDiscount = + firstOffer?.percentageDiscountAndroid != null || + firstOffer?.discountAmountMicrosAndroid != null; // Calculate original price for strikethrough - const fullPriceMicros = parseInt(firstOffer?.fullPriceMicros || '0', 10); + const fullPriceMicros = parseInt( + firstOffer?.fullPriceMicrosAndroid || '0', + 10, + ); const fullPrice = fullPriceMicros / 1_000_000; - const currency = firstOffer?.priceCurrencyCode || ''; + const currency = firstOffer?.currency || ''; // Build discount text const getDiscountText = () => { - if (!discount) return null; - if (discount.percentageDiscount) { - return \`\${discount.percentageDiscount}% OFF\`; + if (!hasDiscount) return null; + if (firstOffer?.percentageDiscountAndroid != null) { + return \`\${firstOffer.percentageDiscountAndroid}% OFF\`; } - if (discount.discountAmount) { - return \`\${discount.discountAmount.formattedDiscountAmount} OFF\`; + if (firstOffer?.formattedDiscountAmountAndroid) { + return \`\${firstOffer.formattedDiscountAmountAndroid} OFF\`; } return 'SALE'; }; @@ -569,7 +562,7 @@ function ProductCard({ product }: { product: ProductAndroid }) { {/* Current (discounted) price */} - {product.displayPrice} + {firstOffer?.displayPrice ?? product.displayPrice} {/* Discount badge */} @@ -641,9 +634,10 @@ fun ProductCard( product: ProductAndroid, onPurchase: () -> Unit ) { - val firstOffer = product.oneTimePurchaseOfferDetailsAndroid?.firstOrNull() - val discountInfo = firstOffer?.discountDisplayInfo - val hasDiscount = discountInfo != null + val firstOffer = product.discountOffers?.firstOrNull() + val hasDiscount = + firstOffer?.percentageDiscountAndroid != null || + firstOffer?.discountAmountMicrosAndroid != null Card( modifier = Modifier @@ -673,11 +667,12 @@ fun ProductCard( horizontalArrangement = Arrangement.spacedBy(8.dp) ) { // Original price with strikethrough - if (hasDiscount && firstOffer?.fullPriceMicros != null) { - val fullPriceMicros = firstOffer.fullPriceMicros?.toLongOrNull() ?: 0L + if (hasDiscount && firstOffer?.fullPriceMicrosAndroid != null) { + val fullPriceMicros = + firstOffer.fullPriceMicrosAndroid?.toLongOrNull() ?: 0L val fullPrice = fullPriceMicros.toDouble() / 1_000_000.0 Text( - text = "\${firstOffer.priceCurrencyCode} \${String.format("%.2f", fullPrice)}", + text = "\${firstOffer.currency} \${String.format("%.2f", fullPrice)}", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, textDecoration = TextDecoration.LineThrough @@ -686,7 +681,7 @@ fun ProductCard( // Current (discounted) price Text( - text = product.displayPrice, + text = firstOffer?.displayPrice ?: product.displayPrice, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold, color = if (hasDiscount) Color(0xFF34C759) else MaterialTheme.colorScheme.primary @@ -695,10 +690,10 @@ fun ProductCard( // Discount badge if (hasDiscount) { val discountText = when { - discountInfo?.percentageDiscount != null -> - "\${discountInfo.percentageDiscount}% OFF" - discountInfo?.discountAmount != null -> - "\${discountInfo.discountAmount?.formattedDiscountAmount} OFF" + firstOffer?.percentageDiscountAndroid != null -> + "\${firstOffer.percentageDiscountAndroid}% OFF" + firstOffer?.formattedDiscountAmountAndroid != null -> + "\${firstOffer.formattedDiscountAmountAndroid} OFF" else -> "SALE" } Surface( @@ -734,9 +729,10 @@ fun ProductCard( product: ProductAndroid, onPurchase: () -> Unit ) { - val firstOffer = product.oneTimePurchaseOfferDetailsAndroid?.firstOrNull() - val discountInfo = firstOffer?.discountDisplayInfo - val hasDiscount = discountInfo != null + val firstOffer = product.discountOffers?.firstOrNull() + val hasDiscount = + firstOffer?.percentageDiscountAndroid != null || + firstOffer?.discountAmountMicrosAndroid != null Card( modifier = Modifier @@ -766,11 +762,12 @@ fun ProductCard( horizontalArrangement = Arrangement.spacedBy(8.dp) ) { // Original price with strikethrough - if (hasDiscount && firstOffer?.fullPriceMicros != null) { - val fullPriceMicros = firstOffer.fullPriceMicros?.toLongOrNull() ?: 0L + if (hasDiscount && firstOffer?.fullPriceMicrosAndroid != null) { + val fullPriceMicros = + firstOffer.fullPriceMicrosAndroid?.toLongOrNull() ?: 0L val fullPrice = fullPriceMicros.toDouble() / 1_000_000.0 Text( - text = "\${firstOffer.priceCurrencyCode} \${String.format("%.2f", fullPrice)}", + text = "\${firstOffer.currency} \${String.format("%.2f", fullPrice)}", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, textDecoration = TextDecoration.LineThrough @@ -779,7 +776,7 @@ fun ProductCard( // Current (discounted) price Text( - text = product.displayPrice, + text = firstOffer?.displayPrice ?: product.displayPrice, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold, color = if (hasDiscount) Color(0xFF34C759) else MaterialTheme.colorScheme.primary @@ -788,10 +785,10 @@ fun ProductCard( // Discount badge if (hasDiscount) { val discountText = when { - discountInfo?.percentageDiscount != null -> - "\${discountInfo.percentageDiscount}% OFF" - discountInfo?.discountAmount != null -> - "\${discountInfo.discountAmount?.formattedDiscountAmount} OFF" + firstOffer?.percentageDiscountAndroid != null -> + "\${firstOffer.percentageDiscountAndroid}% OFF" + firstOffer?.formattedDiscountAmountAndroid != null -> + "\${firstOffer.formattedDiscountAmountAndroid} OFF" else -> "SALE" } Surface( @@ -837,18 +834,18 @@ public sealed record ProductCardViewModel ProductCardViewModel BuildProductCard(ProductAndroid product) { - var firstOffer = product.OneTimePurchaseOfferDetailsAndroid?.FirstOrDefault(); - var discountInfo = firstOffer?.DiscountDisplayInfo; - var hasDiscount = discountInfo is not null; + var firstOffer = product.DiscountOffers?.FirstOrDefault(); + var hasDiscount = firstOffer?.PercentageDiscountAndroid is not null + || firstOffer?.DiscountAmountMicrosAndroid is not null; - var originalPrice = firstOffer is { FullPriceMicros: { } fullPriceMicros } + var originalPrice = firstOffer is { FullPriceMicrosAndroid: { } fullPriceMicros } && long.TryParse(fullPriceMicros, out var micros) - ? $"{firstOffer.PriceCurrencyCode} {micros / 1_000_000.0:F2}" + ? $"{firstOffer.Currency} {micros / 1_000_000.0:F2}" : null; - var badge = discountInfo?.PercentageDiscount is { } percent + var badge = firstOffer?.PercentageDiscountAndroid is { } percent ? $"{percent}% OFF" - : discountInfo?.DiscountAmount?.FormattedDiscountAmount is { } amount + : firstOffer?.FormattedDiscountAmountAndroid is { } amount ? $"{amount} OFF" : hasDiscount ? "SALE" : null; @@ -856,7 +853,7 @@ ProductCardViewModel BuildProductCard(ProductAndroid product) { Title = product.Title, Description = product.Description, - Price = product.DisplayPrice, + Price = firstOffer?.DisplayPrice ?? product.DisplayPrice, OriginalPrice = originalPrice, DiscountBadge = badge, }; @@ -871,8 +868,8 @@ async Task PurchaseAsync(ProductAndroid product) Google = new RequestPurchaseAndroidProps { Skus = new[] { product.Id }, - OfferToken = product.OneTimePurchaseOfferDetailsAndroid? - .FirstOrDefault()?.OfferToken, + OfferToken = product.DiscountOffers? + .FirstOrDefault()?.OfferTokenAndroid, }, }, Type = ProductQueryType.InApp, @@ -894,25 +891,30 @@ var product: ProductAndroid func setup(p: ProductAndroid) -> void: product = p - var offers = product.one_time_purchase_offer_details_android + var offers = product.discount_offers var first_offer = offers[0] if offers and offers.size() > 0 else null - var discount = first_offer.discount_display_info if first_offer else null - var has_discount = discount != null + var has_discount = ( + first_offer != null + and ( + first_offer.percentage_discount_android != null + or first_offer.discount_amount_micros_android != null + ) + ) title_label.text = product.title - price_label.text = product.display_price + price_label.text = first_offer.display_price if first_offer else product.display_price - if has_discount and first_offer.full_price_micros: - var full_price_micros = int(first_offer.full_price_micros) + if has_discount and first_offer.full_price_micros_android: + var full_price_micros = int(first_offer.full_price_micros_android) var full_price = full_price_micros / 1000000.0 - original_price_label.text = "%s %.2f" % [first_offer.price_currency_code, full_price] + original_price_label.text = "%s %.2f" % [first_offer.currency, full_price] original_price_label.visible = true # Set discount badge text - if discount.percentage_discount: - discount_badge.text = "%d%% OFF" % discount.percentage_discount - elif discount.discount_amount: - discount_badge.text = "%s OFF" % discount.discount_amount.formatted_discount_amount + if first_offer.percentage_discount_android != null: + discount_badge.text = "%d%% OFF" % first_offer.percentage_discount_android + elif first_offer.formatted_discount_amount_android: + discount_badge.text = "%s OFF" % first_offer.formatted_discount_amount_android else: discount_badge.text = "SALE" discount_badge.visible = true @@ -924,9 +926,9 @@ func setup(p: ProductAndroid) -> void: discount_badge.visible = false func _on_buy_button_pressed() -> void: - var offers = product.one_time_purchase_offer_details_android + var offers = product.discount_offers if offers and offers.size() > 0: - emit_signal("purchase_requested", product, offers[0].offer_token)`} + emit_signal("purchase_requested", product, offers[0].offer_token_android)`} ), }} @@ -944,8 +946,8 @@ func _on_buy_button_pressed() -> void: {{ typescript: ( - {`function checkOfferValidity(offer: ProductAndroidOneTimePurchaseOfferDetail) { - const timeWindow = offer.validTimeWindow; + {`function checkOfferValidity(offer: DiscountOffer) { + const timeWindow = offer.validTimeWindowAndroid; if (!timeWindow) { return { isValid: true, message: 'Always available' }; @@ -987,8 +989,8 @@ func _on_buy_button_pressed() -> void: val message: String ) -fun checkOfferValidity(offer: ProductAndroidOneTimePurchaseOfferDetail): OfferValidity { - val timeWindow = offer.validTimeWindow +fun checkOfferValidity(offer: DiscountOffer): OfferValidity { + val timeWindow = offer.validTimeWindowAndroid ?: return OfferValidity(true, "Always available") val now = System.currentTimeMillis() @@ -1021,8 +1023,8 @@ fun checkOfferValidity(offer: ProductAndroidOneTimePurchaseOfferDetail): OfferVa val message: String ) -fun checkOfferValidity(offer: ProductAndroidOneTimePurchaseOfferDetail): OfferValidity { - val timeWindow = offer.validTimeWindow +fun checkOfferValidity(offer: DiscountOffer): OfferValidity { + val timeWindow = offer.validTimeWindowAndroid ?: return OfferValidity(true, "Always available") val now = System.currentTimeMillis() @@ -1055,9 +1057,9 @@ using System; public sealed record OfferValidity(bool IsValid, string Message); -OfferValidity CheckOfferValidity(ProductAndroidOneTimePurchaseOfferDetail offer) +OfferValidity CheckOfferValidity(DiscountOffer offer) { - var timeWindow = offer.ValidTimeWindow; + var timeWindow = offer.ValidTimeWindowAndroid; if (timeWindow is null) { return new OfferValidity(true, "Always available"); @@ -1092,8 +1094,8 @@ OfferValidity CheckOfferValidity(ProductAndroidOneTimePurchaseOfferDetail offer) }`} ), gdscript: ( - {`func check_offer_validity(offer: ProductAndroidOneTimePurchaseOfferDetail) -> Dictionary: - var time_window = offer.valid_time_window + {`func check_offer_validity(offer: DiscountOffer) -> Dictionary: + var time_window = offer.valid_time_window_android if not time_window: return {"is_valid": true, "message": "Always available"} @@ -1137,8 +1139,8 @@ OfferValidity CheckOfferValidity(ProductAndroidOneTimePurchaseOfferDetail offer) {{ typescript: ( - {`function checkQuantityAvailability(offer: ProductAndroidOneTimePurchaseOfferDetail) { - const quantityInfo = offer.limitedQuantityInfo; + {`function checkQuantityAvailability(offer: DiscountOffer) { + const quantityInfo = offer.limitedQuantityInfoAndroid; if (!quantityInfo) { return { isAvailable: true, message: null }; @@ -1175,8 +1177,8 @@ OfferValidity CheckOfferValidity(ProductAndroidOneTimePurchaseOfferDetail offer) val message: String? ) -fun checkQuantityAvailability(offer: ProductAndroidOneTimePurchaseOfferDetail): QuantityAvailability { - val quantityInfo = offer.limitedQuantityInfo +fun checkQuantityAvailability(offer: DiscountOffer): QuantityAvailability { + val quantityInfo = offer.limitedQuantityInfoAndroid ?: return QuantityAvailability(true, null) val (maximumQuantity, remainingQuantity) = quantityInfo @@ -1198,8 +1200,8 @@ fun checkQuantityAvailability(offer: ProductAndroidOneTimePurchaseOfferDetail): val message: String? ) -fun checkQuantityAvailability(offer: ProductAndroidOneTimePurchaseOfferDetail): QuantityAvailability { - val quantityInfo = offer.limitedQuantityInfo +fun checkQuantityAvailability(offer: DiscountOffer): QuantityAvailability { + val quantityInfo = offer.limitedQuantityInfoAndroid ?: return QuantityAvailability(true, null) val (maximumQuantity, remainingQuantity) = quantityInfo @@ -1220,9 +1222,9 @@ fun checkQuantityAvailability(offer: ProductAndroidOneTimePurchaseOfferDetail): public sealed record QuantityAvailability(bool IsAvailable, string? Message); -QuantityAvailability CheckQuantityAvailability(ProductAndroidOneTimePurchaseOfferDetail offer) +QuantityAvailability CheckQuantityAvailability(DiscountOffer offer) { - var quantityInfo = offer.LimitedQuantityInfo; + var quantityInfo = offer.LimitedQuantityInfoAndroid; if (quantityInfo is null) { return new QuantityAvailability(true, null); @@ -1247,8 +1249,8 @@ QuantityAvailability CheckQuantityAvailability(ProductAndroidOneTimePurchaseOffe }`} ), gdscript: ( - {`func check_quantity_availability(offer: ProductAndroidOneTimePurchaseOfferDetail) -> Dictionary: - var quantity_info = offer.limited_quantity_info + {`func check_quantity_availability(offer: DiscountOffer) -> Dictionary: + var quantity_info = offer.limited_quantity_info_android if not quantity_info: return {"is_available": true, "message": null} @@ -1273,8 +1275,8 @@ QuantityAvailability CheckQuantityAvailability(ProductAndroidOneTimePurchaseOffe Purchasing with Specific Offer

- When purchasing a discounted product, use the offerToken{' '} - from the specific offer you want to apply: + When purchasing a discounted product, pass the selected offer's{' '} + offerTokenAndroid to the Android purchase request:

@@ -1286,7 +1288,7 @@ async function purchaseWithOffer( product: ProductAndroid, offerIndex: number = 0 ) { - const offers = product.oneTimePurchaseOfferDetailsAndroid; + const offers = product.discountOffers; if (!offers || offers.length === 0) { throw new Error('No offers available for this product'); @@ -1299,8 +1301,8 @@ async function purchaseWithOffer( request: { google: { skus: [product.id], - // Include offerTokenAndroid for discounted purchases (Android 8.0+) - offerToken: selectedOffer.offerToken, + // Pass the standardized offer's Android token. + offerToken: selectedOffer.offerTokenAndroid, }, }, }); @@ -1312,26 +1314,24 @@ async function purchaseWithOffer( ), kotlin: ( {`suspend fun purchaseWithOffer( - activity: Activity, product: ProductAndroid, offerIndex: Int = 0 ) { - val offers = product.oneTimePurchaseOfferDetailsAndroid + val offers = product.discountOffers ?: throw IllegalStateException("No offers available") val selectedOffer = offers.getOrNull(offerIndex) ?: throw IllegalStateException("Invalid offer index") iapStore.requestPurchase( - activity = activity, - props = RequestPurchaseProps( + RequestPurchaseProps( type = ProductQueryType.InApp, request = RequestPurchaseProps.Request.Purchase( RequestPurchasePropsByPlatforms( google = RequestPurchaseAndroidProps( skus = listOf(product.id), - // Include offerTokenAndroid for discounted purchases (Android 8.0+) - offerToken = selectedOffer.offerToken + // Pass the standardized offer's Android token. + offerToken = selectedOffer.offerTokenAndroid ) ) ) @@ -1341,26 +1341,24 @@ async function purchaseWithOffer( ), kmp: ( {`suspend fun purchaseWithOffer( - activity: Activity, product: ProductAndroid, offerIndex: Int = 0 ) { - val offers = product.oneTimePurchaseOfferDetailsAndroid + val offers = product.discountOffers ?: throw IllegalStateException("No offers available") val selectedOffer = offers.getOrNull(offerIndex) ?: throw IllegalStateException("Invalid offer index") kmpIAP.requestPurchase( - activity = activity, - props = RequestPurchaseProps( + RequestPurchaseProps( type = ProductQueryType.InApp, request = RequestPurchaseProps.Request.Purchase( RequestPurchasePropsByPlatforms( google = RequestPurchaseAndroidProps( skus = listOf(product.id), - // Include offerTokenAndroid for discounted purchases (Android 8.0+) - offerToken = selectedOffer.offerToken + // Pass the standardized offer's Android token. + offerToken = selectedOffer.offerTokenAndroid ) ) ) @@ -1376,7 +1374,7 @@ using System.Linq; async Task PurchaseWithOfferAsync(ProductAndroid product, int offerIndex = 0) { - var offers = product.OneTimePurchaseOfferDetailsAndroid + var offers = product.DiscountOffers ?? throw new InvalidOperationException("No offers available"); var selectedOffer = offers.ElementAtOrDefault(offerIndex) @@ -1390,8 +1388,8 @@ async Task PurchaseWithOfferAsync(ProductAndroid product, int offerIndex = 0) Google = new RequestPurchaseAndroidProps { Skus = new[] { product.Id }, - // Include OfferToken for discounted purchases (Android 8.0+). - OfferToken = selectedOffer.OfferToken, + // Pass the standardized offer's Android token. + OfferToken = selectedOffer.OfferTokenAndroid, }, }, }); @@ -1399,7 +1397,7 @@ async Task PurchaseWithOfferAsync(ProductAndroid product, int offerIndex = 0) ), gdscript: ( {`func purchase_with_offer(product: ProductAndroid, offer_index: int = 0) -> void: - var offers = product.one_time_purchase_offer_details_android + var offers = product.discount_offers if not offers or offers.size() == 0: push_error("No offers available for this product") @@ -1416,7 +1414,7 @@ async Task PurchaseWithOfferAsync(ProductAndroid product, int offerIndex = 0) props.request = RequestPurchasePropsByPlatforms.new() props.request.google = RequestPurchaseAndroidProps.new() props.request.google.skus = [product.id] - props.request.google.offer_token = selected_offer.offer_token + props.request.google.offer_token = selected_offer.offer_token_android await iap.request_purchase(props)`} ), @@ -1510,16 +1508,6 @@ async Task PurchaseWithOfferAsync(ProductAndroid product, int offerIndex = 0) Native References
    -
  • - Google ·{' '} - - Discounted offers - -
  • Google ·{' '} DiscountOffer {' '} - (cross-platform) instead. + (the standardized Android one-time offer shape) instead.

    One-time purchase offer details for Android products. Available with{' '} diff --git a/packages/docs/src/pages/docs/types/discount-offer.tsx b/packages/docs/src/pages/docs/types/discount-offer.tsx index 0a4bc7ad3..7542dd65c 100644 --- a/packages/docs/src/pages/docs/types/discount-offer.tsx +++ b/packages/docs/src/pages/docs/types/discount-offer.tsx @@ -12,9 +12,9 @@ function DiscountOffer() {

    DiscountOffer

    @@ -22,42 +22,30 @@ function DiscountOffer() { DiscountOffer

    - Unified discount-offer type covering both subscription discounts ( - Introductory, Promotional) and one-time - product offers (OneTime, Android only on Google Play - Billing Library 8.0+). For iOS-specific WinBack offers see{' '} - DiscountOfferIOS; - iOS does not support one-time product discounts. + DiscountOffer is the standardized representation of a + Google Play one-time product purchase option or offer. OpenIAP + populates ProductAndroid.discountOffers from{' '} + ProductDetails.getOneTimePurchaseOfferDetailsList() on + Google Play Billing Library 8.0+.

    - Cross-platform discount-offer envelope. iOS: maps to - a signed Product.SubscriptionOffer ( - - Apple docs - - ). Android: maps to a Play{' '} - SubscriptionOfferDetails entry ( - - Google docs - - ). + Each entry maps to{' '} + ProductDetails.OneTimePurchaseOfferDetails and has the + wire type one-time. Subscription introductory and + promotional offers use{' '} + + SubscriptionOffer + {' '} + instead. iOS does not populate DiscountOffer.

    Native references:{' '} - Google · Discounted offers + Google · Multiple purchase options and offers for one-time products {' · '} DiscountOfferType! - Type of offer: Introductory,{' '} - Promotional, or OneTime (Android-only - Play Billing 8.0+ feature). + Always one-time for DiscountOffer{' '} + entries. The shared enum's introductory and{' '} + promotional variants are used by{' '} + + SubscriptionOffer + + . @@ -201,7 +193,10 @@ function DiscountOffer() { String - Formatted discount amount (e.g., "$5.00 OFF") + + Localized discount amount including the currency symbol (e.g., + "$5.00") + @@ -270,32 +265,29 @@ function DiscountOffer() { typescript: ( {`interface DiscountOffer { // Common fields - id: string | null; + id?: string | null; displayPrice: string; price: number; currency: string; type: DiscountOfferType; // Android-specific fields - offerTokenAndroid?: string; - offerTagsAndroid?: string[]; - fullPriceMicrosAndroid?: string; - percentageDiscountAndroid?: number; - discountAmountMicrosAndroid?: string; - formattedDiscountAmountAndroid?: string; - validTimeWindowAndroid?: ValidTimeWindowAndroid; - limitedQuantityInfoAndroid?: LimitedQuantityInfoAndroid; - preorderDetailsAndroid?: PreorderDetailsAndroid; - rentalDetailsAndroid?: RentalDetailsAndroid; - purchaseOptionIdAndroid?: string; + offerTokenAndroid?: string | null; + offerTagsAndroid?: string[] | null; + fullPriceMicrosAndroid?: string | null; + percentageDiscountAndroid?: number | null; + discountAmountMicrosAndroid?: string | null; + formattedDiscountAmountAndroid?: string | null; + validTimeWindowAndroid?: ValidTimeWindowAndroid | null; + limitedQuantityInfoAndroid?: LimitedQuantityInfoAndroid | null; + preorderDetailsAndroid?: PreorderDetailsAndroid | null; + rentalDetailsAndroid?: RentalDetailsAndroid | null; + purchaseOptionIdAndroid?: string | null; } -enum DiscountOfferType { - Introductory = 'Introductory', - Promotional = 'Promotional', - WinBack = 'WinBack', // iOS 18+ - OneTime = 'OneTime', -}`} +type DiscountOfferType = 'introductory' | 'promotional' | 'one-time'; + +// DiscountOffer instances currently use only 'one-time'.`} ), swift: ( {`struct DiscountOffer: Codable { @@ -321,16 +313,15 @@ enum DiscountOfferType { } enum DiscountOfferType: String, Codable { - case introductory = "Introductory" - case promotional = "Promotional" - case winBack = "WinBack" // iOS 18+ - case oneTime = "OneTime" + case introductory = "introductory" + case promotional = "promotional" + case oneTime = "one-time" }`} ), kotlin: ( {`data class DiscountOffer( // Common fields - val id: String?, + val id: String? = null, val displayPrice: String, val price: Double, val currency: String, @@ -350,11 +341,10 @@ enum DiscountOfferType: String, Codable { val purchaseOptionIdAndroid: String? = null ) -enum class DiscountOfferType { - Introductory, - Promotional, - WinBack, // iOS 18+ - OneTime +enum class DiscountOfferType(val rawValue: String) { + Introductory("introductory"), + Promotional("promotional"), + OneTime("one-time") }`} ), dart: ( @@ -400,10 +390,12 @@ enum class DiscountOfferType { } enum DiscountOfferType { - introductory, - promotional, - winBack, // iOS 18+ - oneTime, + Introductory('introductory'), + Promotional('promotional'), + OneTime('one-time'); + + const DiscountOfferType(this.value); + final String value; }`} ), csharp: ( @@ -445,29 +437,28 @@ public enum DiscountOfferType {`class_name DiscountOffer # Common fields -var id: String -var display_price: String -var price: float -var currency: String +var id: Variant = null +var display_price: String = "" +var price: float = 0.0 +var currency: String = "" var type: DiscountOfferType # Android-specific fields -var offer_token_android: String -var offer_tags_android: Array[String] -var full_price_micros_android: String -var percentage_discount_android: int -var discount_amount_micros_android: String -var formatted_discount_amount_android: String +var offer_token_android: Variant = null +var offer_tags_android: Array[String] = [] +var full_price_micros_android: Variant = null +var percentage_discount_android: Variant = null +var discount_amount_micros_android: Variant = null +var formatted_discount_amount_android: Variant = null var valid_time_window_android: ValidTimeWindowAndroid var limited_quantity_info_android: LimitedQuantityInfoAndroid var preorder_details_android: PreorderDetailsAndroid var rental_details_android: RentalDetailsAndroid -var purchase_option_id_android: String +var purchase_option_id_android: Variant = null enum DiscountOfferType { INTRODUCTORY, PROMOTIONAL, - WIN_BACK, # iOS 18+ ONE_TIME }`} ), diff --git a/packages/docs/src/pages/docs/types/index.tsx b/packages/docs/src/pages/docs/types/index.tsx index e762dd54e..2f1d72765 100644 --- a/packages/docs/src/pages/docs/types/index.tsx +++ b/packages/docs/src/pages/docs/types/index.tsx @@ -145,7 +145,7 @@ const COMMON_TYPES: TypeRow[] = [ { to: '/docs/types/discount-offer', name: 'DiscountOffer', - description: 'Cross-platform discount offer details.', + description: 'Standardized Android one-time product offer details.', }, { to: '/docs/types/subscription-offer', diff --git a/packages/docs/src/pages/docs/types/product.tsx b/packages/docs/src/pages/docs/types/product.tsx index 802db5394..b3efd28a1 100644 --- a/packages/docs/src/pages/docs/types/product.tsx +++ b/packages/docs/src/pages/docs/types/product.tsx @@ -283,28 +283,18 @@ function Product() { - oneTimePurchaseOfferDetailsAndroid + + oneTimePurchaseOfferDetailsAndroid + - Array of one-time purchase offers. Each offer contains:{' '} - formattedPrice,{' '} - priceAmountMicros,{' '} - priceCurrencyCode, offerToken,{' '} - discountDisplayInfo (discount info),{' '} - fullPriceMicros (original price),{' '} - validTimeWindow,{' '} - limitedQuantityInfo,{' '} - preorderDetailsAndroid,{' '} - rentalDetailsAndroid. See{' '} - Discounts. - Requires{' '} - - Billing Library 8.0+ - + Deprecated. Legacy Android-native + one-time purchase offer details. Use{' '} + discountOffers and the standardized{' '} + + DiscountOffer + {' '} + shape instead. @@ -340,11 +330,13 @@ function Product() { discountOffers - Cross-platform array of{' '} + Standardized Android one-time product purchase options + and offers as{' '} DiscountOffer {' '} - — unified discount metadata. + entries. Populated from Google Play Billing 8.0+{' '} + OneTimePurchaseOfferDetails. diff --git a/packages/docs/src/pages/docs/types/request-purchase-props.tsx b/packages/docs/src/pages/docs/types/request-purchase-props.tsx index aef95dfae..cc79af668 100644 --- a/packages/docs/src/pages/docs/types/request-purchase-props.tsx +++ b/packages/docs/src/pages/docs/types/request-purchase-props.tsx @@ -436,7 +436,8 @@ await iap.request_purchase(subs_props)`} withOffer - Promotional/discount offer to apply (see DiscountOffer) + Signed iOS subscription promotional offer input ( + DiscountOfferInputIOS) diff --git a/packages/docs/src/pages/docs/types/subscription-offer.tsx b/packages/docs/src/pages/docs/types/subscription-offer.tsx index b00f6b0f1..d6036617c 100644 --- a/packages/docs/src/pages/docs/types/subscription-offer.tsx +++ b/packages/docs/src/pages/docs/types/subscription-offer.tsx @@ -125,8 +125,7 @@ function SubscriptionOffer() { - Introductory, Promotional, or{' '} - WinBack (iOS 18+) + introductory or promotional @@ -344,20 +343,13 @@ interface SubscriptionPeriod { value: number; } -enum SubscriptionPeriodUnit { - Day = 'Day', - Week = 'Week', - Month = 'Month', - Year = 'Year', - Unknown = 'Unknown', -} +type SubscriptionPeriodUnit = 'day' | 'week' | 'month' | 'year' | 'unknown'; -enum PaymentMode { - FreeTrial = 'FreeTrial', - PayAsYouGo = 'PayAsYouGo', - PayUpFront = 'PayUpFront', - Unknown = 'Unknown', -}`} +type PaymentMode = + | 'free-trial' + | 'pay-as-you-go' + | 'pay-up-front' + | 'unknown';`} ), swift: ( {`struct SubscriptionOffer: Codable { @@ -398,18 +390,18 @@ struct SubscriptionPeriod: Codable { } enum SubscriptionPeriodUnit: String, Codable { - case day = "Day" - case week = "Week" - case month = "Month" - case year = "Year" - case unknown = "Unknown" + case day = "day" + case week = "week" + case month = "month" + case year = "year" + case unknown = "unknown" } enum PaymentMode: String, Codable { - case freeTrial = "FreeTrial" - case payAsYouGo = "PayAsYouGo" - case payUpFront = "PayUpFront" - case unknown = "Unknown" + case freeTrial = "free-trial" + case payAsYouGo = "pay-as-you-go" + case payUpFront = "pay-up-front" + case unknown = "unknown" }`} ), kotlin: ( @@ -450,12 +442,19 @@ data class SubscriptionPeriod( val value: Int ) -enum class SubscriptionPeriodUnit { - Day, Week, Month, Year, Unknown +enum class SubscriptionPeriodUnit(val rawValue: String) { + Day("day"), + Week("week"), + Month("month"), + Year("year"), + Unknown("unknown") } -enum class PaymentMode { - FreeTrial, PayAsYouGo, PayUpFront, Unknown +enum class PaymentMode(val rawValue: String) { + FreeTrial("free-trial"), + PayAsYouGo("pay-as-you-go"), + PayUpFront("pay-up-front"), + Unknown("unknown") }`} ), dart: ( @@ -525,9 +524,26 @@ class SubscriptionPeriod { SubscriptionPeriod({required this.unit, required this.value}); } -enum SubscriptionPeriodUnit { day, week, month, year, unknown } +enum SubscriptionPeriodUnit { + Day('day'), + Week('week'), + Month('month'), + Year('year'), + Unknown('unknown'); + + const SubscriptionPeriodUnit(this.value); + final String value; +} -enum PaymentMode { freeTrial, payAsYouGo, payUpFront, unknown }`} +enum PaymentMode { + FreeTrial('free-trial'), + PayAsYouGo('pay-as-you-go'), + PayUpFront('pay-up-front'), + Unknown('unknown'); + + const PaymentMode(this.value); + final String value; +}`} ), csharp: ( {`using OpenIap; diff --git a/packages/docs/src/pages/docs/types/subscription-product.tsx b/packages/docs/src/pages/docs/types/subscription-product.tsx index f2835a001..4b16385c5 100644 --- a/packages/docs/src/pages/docs/types/subscription-product.tsx +++ b/packages/docs/src/pages/docs/types/subscription-product.tsx @@ -80,7 +80,7 @@ function SubscriptionProduct() { debugDescription,{' '} platform ( Deprecated.)), plus the subscription-only override - and the cross-platform offer arrays below. + and standardized subscription offer array below.

    @@ -118,21 +118,6 @@ function SubscriptionProduct() { offers on iOS and from Play Billing offer details on Android. - - - - -
    - discountOffers - - - DiscountOffer[] - - - Cross-platform discount list (introductory pricing, promo - codes). Always present in the schema; iOS-only stores may return - an empty array. -
    @@ -281,6 +266,14 @@ function SubscriptionProduct() { ProductSubscriptionAndroid

    Additional fields available on Android subscriptions:

    +

    + The generated Android shape retains a nullable{' '} + discountOffers compatibility field, but Google + Play only returns one-time purchase offer details for{' '} + in-app products. It is not subscription discount + metadata; use subscriptionOffers for + subscriptions. +

    diff --git a/packages/google/openiap/src/main/java/dev/hyo/openiap/Types.kt b/packages/google/openiap/src/main/java/dev/hyo/openiap/Types.kt index 2a99c11d3..8a910f5b0 100644 --- a/packages/google/openiap/src/main/java/dev/hyo/openiap/Types.kt +++ b/packages/google/openiap/src/main/java/dev/hyo/openiap/Types.kt @@ -2077,7 +2077,7 @@ public data class DiscountDisplayInfoAndroid( /** * Discount information returned from the store. * @deprecated Use the standardized SubscriptionOffer type instead for cross-platform compatibility. - * @see https://openiap.dev/docs/types#subscription-offer + * @see https://openiap.dev/docs/types/subscription-offer */ public data class DiscountIOS( val identifier: String, @@ -2120,10 +2120,11 @@ public data class DiscountIOS( /** * Standardized one-time product discount offer. - * Provides a unified interface for one-time purchase discounts across platforms. + * Provides a platform-neutral OpenIAP shape for Google Play one-time product + * purchase options and offers. * - * Currently supported on Android (Google Play Billing 8.0+). - * iOS does not support one-time purchase discounts in the same way. + * Currently populated only on Android (Google Play Billing 8.0+). + * iOS does not populate this type. * * @see https://openiap.dev/docs/features/discount */ @@ -2142,7 +2143,7 @@ public data class DiscountOffer( */ val displayPrice: String, /** - * [Android] Formatted discount amount string (e.g., "$5.00 OFF"). + * [Android] Formatted discount amount including its currency sign (e.g., "$5.00"). */ val formattedDiscountAmountAndroid: String? = null, /** @@ -2253,7 +2254,7 @@ public data class DiscountOffer( /** * iOS DiscountOffer (output type). * @deprecated Use the standardized SubscriptionOffer type instead for cross-platform compatibility. - * @see https://openiap.dev/docs/types#subscription-offer + * @see https://openiap.dev/docs/types/subscription-offer */ public data class DiscountOfferIOS( /** @@ -2770,9 +2771,9 @@ public data class ProductAndroid( override val debugDescription: String? = null, override val description: String, /** - * Standardized discount offers for one-time products. - * Cross-platform type with Android-specific fields using suffix. - * @see https://openiap.dev/docs/types#discount-offer + * Standardized Android one-time product purchase options and offers. + * Native metadata uses Android-suffixed fields. + * @see https://openiap.dev/docs/types/discount-offer */ val discountOffers: List? = null, override val displayName: String? = null, @@ -2782,7 +2783,7 @@ public data class ProductAndroid( /** * One-time purchase offer details including discounts (Android) * Returns all eligible offers. Available in Google Play Billing Library 8.0+ - * @deprecated Use discountOffers instead for cross-platform compatibility. + * @deprecated Use the standardized discountOffers field instead. */ val oneTimePurchaseOfferDetailsAndroid: List? = null, override val platform: IapPlatform = IapPlatform.Android, @@ -2802,7 +2803,7 @@ public data class ProductAndroid( /** * Standardized subscription offers. * Cross-platform type with Android-specific fields using suffix. - * @see https://openiap.dev/docs/types#subscription-offer + * @see https://openiap.dev/docs/types/subscription-offer */ val subscriptionOffers: List? = null, override val title: String, @@ -2856,8 +2857,8 @@ public data class ProductAndroid( /** * One-time purchase offer details (Android). * Available in Google Play Billing Library 8.0+ - * @deprecated Use the standardized DiscountOffer type instead for cross-platform compatibility. - * @see https://openiap.dev/docs/types#discount-offer + * @deprecated Use the standardized DiscountOffer type for Android one-time offers. + * @see https://openiap.dev/docs/types/discount-offer */ public data class ProductAndroidOneTimePurchaseOfferDetail( /** @@ -2973,7 +2974,7 @@ public data class ProductIOS( * Standardized subscription offers. * Cross-platform type with iOS-specific fields using suffix. * Note: iOS does not support one-time product discounts. - * @see https://openiap.dev/docs/types#subscription-offer + * @see https://openiap.dev/docs/types/subscription-offer */ val subscriptionOffers: List? = null, override val title: String, @@ -3032,9 +3033,8 @@ public data class ProductSubscriptionAndroid( override val debugDescription: String? = null, override val description: String, /** - * Standardized discount offers for one-time products. - * Cross-platform type with Android-specific fields using suffix. - * @see https://openiap.dev/docs/types#discount-offer + * Nullable compatibility field. Google Play does not return one-time purchase + * offer details for subscription products; use subscriptionOffers below. */ val discountOffers: List? = null, override val displayName: String? = null, @@ -3042,9 +3042,10 @@ public data class ProductSubscriptionAndroid( override val id: String, val nameAndroid: String, /** - * One-time purchase offer details including discounts (Android) - * Returns all eligible offers. Available in Google Play Billing Library 8.0+ - * @deprecated Use discountOffers instead for cross-platform compatibility. + * Legacy nullable compatibility field. Google Play does not populate one-time + * purchase offer details for subscription products. + * @deprecated One-time offers belong to ProductAndroid.discountOffers; + * subscriptions use subscriptionOffers. */ val oneTimePurchaseOfferDetailsAndroid: List? = null, override val platform: IapPlatform = IapPlatform.Android, @@ -3064,7 +3065,7 @@ public data class ProductSubscriptionAndroid( /** * Standardized subscription offers. * Cross-platform type with Android-specific fields using suffix. - * @see https://openiap.dev/docs/types#subscription-offer + * @see https://openiap.dev/docs/types/subscription-offer */ val subscriptionOffers: List, override val title: String, @@ -3118,7 +3119,7 @@ public data class ProductSubscriptionAndroid( /** * Subscription offer details (Android). * @deprecated Use the standardized SubscriptionOffer type instead for cross-platform compatibility. - * @see https://openiap.dev/docs/types#subscription-offer + * @see https://openiap.dev/docs/types/subscription-offer */ public data class ProductSubscriptionAndroidOfferDetails( val basePlanId: String, @@ -3195,7 +3196,7 @@ public data class ProductSubscriptionIOS( /** * Standardized subscription offers. * Cross-platform type with iOS-specific fields using suffix. - * @see https://openiap.dev/docs/types#subscription-offer + * @see https://openiap.dev/docs/types/subscription-offer */ val subscriptionOffers: List? = null, val subscriptionPeriodNumberIOS: String? = null, @@ -3917,8 +3918,7 @@ public data class SubscriptionInfoIOS( * - iOS: Introductory offers, promotional offers with server-side signatures * - Android: Offer tokens with pricing phases * - * @see https://openiap.dev/docs/types/ios#discount-offer - * @see https://openiap.dev/docs/types/android#subscription-offer + * @see https://openiap.dev/docs/types/subscription-offer */ public data class SubscriptionOffer( /** @@ -4063,7 +4063,7 @@ public data class SubscriptionOffer( /** * iOS subscription offer details. * @deprecated Use the standardized SubscriptionOffer type instead for cross-platform compatibility. - * @see https://openiap.dev/docs/types#subscription-offer + * @see https://openiap.dev/docs/types/subscription-offer */ public data class SubscriptionOfferIOS( val displayPrice: String, diff --git a/packages/gql/src/generated/Types.cs b/packages/gql/src/generated/Types.cs index 7677ef615..1da78ba5c 100644 --- a/packages/gql/src/generated/Types.cs +++ b/packages/gql/src/generated/Types.cs @@ -2944,7 +2944,7 @@ public sealed record DiscountDisplayInfoAndroid /// Discount information returned from the store. /// @deprecated Use the standardized SubscriptionOffer type instead for cross-platform compatibility. -/// @see https://openiap.dev/docs/types#subscription-offer +/// @see https://openiap.dev/docs/types/subscription-offer public sealed record DiscountIOS { [JsonPropertyName("identifier")] @@ -2966,10 +2966,11 @@ public sealed record DiscountIOS } /// Standardized one-time product discount offer. -/// Provides a unified interface for one-time purchase discounts across platforms. +/// Provides a platform-neutral OpenIAP shape for Google Play one-time product +/// purchase options and offers. /// -/// Currently supported on Android (Google Play Billing 8.0+). -/// iOS does not support one-time purchase discounts in the same way. +/// Currently populated only on Android (Google Play Billing 8.0+). +/// iOS does not populate this type. /// /// @see https://openiap.dev/docs/features/discount public sealed record DiscountOffer @@ -2984,7 +2985,7 @@ public sealed record DiscountOffer /// Formatted display price string (e.g., "$4.99") [JsonPropertyName("displayPrice")] public required string DisplayPrice { get; init; } - /// [Android] Formatted discount amount string (e.g., "$5.00 OFF"). + /// [Android] Formatted discount amount including its currency sign (e.g., "$5.00"). [JsonPropertyName("formattedDiscountAmountAndroid")] public string? FormattedDiscountAmountAndroid { get; init; } /// [Android] Original full price in micro-units before discount. @@ -3038,7 +3039,7 @@ public sealed record DiscountOffer /// iOS DiscountOffer (output type). /// @deprecated Use the standardized SubscriptionOffer type instead for cross-platform compatibility. -/// @see https://openiap.dev/docs/types#subscription-offer +/// @see https://openiap.dev/docs/types/subscription-offer public sealed record DiscountOfferIOS { /// Discount identifier @@ -3265,9 +3266,9 @@ public sealed record ProductAndroid : Product, ProductCommon public string? DebugDescription { get; init; } [JsonPropertyName("description")] public required string Description { get; init; } - /// Standardized discount offers for one-time products. - /// Cross-platform type with Android-specific fields using suffix. - /// @see https://openiap.dev/docs/types#discount-offer + /// Standardized Android one-time product purchase options and offers. + /// Native metadata uses Android-suffixed fields. + /// @see https://openiap.dev/docs/types/discount-offer [JsonPropertyName("discountOffers")] public IReadOnlyList? DiscountOffers { get; init; } [JsonPropertyName("displayName")] @@ -3280,7 +3281,7 @@ public sealed record ProductAndroid : Product, ProductCommon public required string NameAndroid { get; init; } /// One-time purchase offer details including discounts (Android) /// Returns all eligible offers. Available in Google Play Billing Library 8.0+ - /// @deprecated Use discountOffers instead for cross-platform compatibility. + /// @deprecated Use the standardized discountOffers field instead. [JsonPropertyName("oneTimePurchaseOfferDetailsAndroid")] public IReadOnlyList? OneTimePurchaseOfferDetailsAndroid { get; init; } [JsonPropertyName("platform")] @@ -3299,7 +3300,7 @@ public sealed record ProductAndroid : Product, ProductCommon public IReadOnlyList? SubscriptionOfferDetailsAndroid { get; init; } /// Standardized subscription offers. /// Cross-platform type with Android-specific fields using suffix. - /// @see https://openiap.dev/docs/types#subscription-offer + /// @see https://openiap.dev/docs/types/subscription-offer [JsonPropertyName("subscriptionOffers")] public IReadOnlyList? SubscriptionOffers { get; init; } [JsonPropertyName("title")] @@ -3310,8 +3311,8 @@ public sealed record ProductAndroid : Product, ProductCommon /// One-time purchase offer details (Android). /// Available in Google Play Billing Library 8.0+ -/// @deprecated Use the standardized DiscountOffer type instead for cross-platform compatibility. -/// @see https://openiap.dev/docs/types#discount-offer +/// @deprecated Use the standardized DiscountOffer type for Android one-time offers. +/// @see https://openiap.dev/docs/types/discount-offer public sealed record ProductAndroidOneTimePurchaseOfferDetail { /// Discount display information @@ -3391,7 +3392,7 @@ public sealed record ProductIOS : Product, ProductCommon /// Standardized subscription offers. /// Cross-platform type with iOS-specific fields using suffix. /// Note: iOS does not support one-time product discounts. - /// @see https://openiap.dev/docs/types#subscription-offer + /// @see https://openiap.dev/docs/types/subscription-offer [JsonPropertyName("subscriptionOffers")] public IReadOnlyList? SubscriptionOffers { get; init; } [JsonPropertyName("title")] @@ -3410,9 +3411,8 @@ public sealed record ProductSubscriptionAndroid : ProductSubscription, ProductCo public string? DebugDescription { get; init; } [JsonPropertyName("description")] public required string Description { get; init; } - /// Standardized discount offers for one-time products. - /// Cross-platform type with Android-specific fields using suffix. - /// @see https://openiap.dev/docs/types#discount-offer + /// Nullable compatibility field. Google Play does not return one-time purchase + /// offer details for subscription products; use subscriptionOffers below. [JsonPropertyName("discountOffers")] public IReadOnlyList? DiscountOffers { get; init; } [JsonPropertyName("displayName")] @@ -3423,9 +3423,10 @@ public sealed record ProductSubscriptionAndroid : ProductSubscription, ProductCo public required string Id { get; init; } [JsonPropertyName("nameAndroid")] public required string NameAndroid { get; init; } - /// One-time purchase offer details including discounts (Android) - /// Returns all eligible offers. Available in Google Play Billing Library 8.0+ - /// @deprecated Use discountOffers instead for cross-platform compatibility. + /// Legacy nullable compatibility field. Google Play does not populate one-time + /// purchase offer details for subscription products. + /// @deprecated One-time offers belong to ProductAndroid.discountOffers; + /// subscriptions use subscriptionOffers. [JsonPropertyName("oneTimePurchaseOfferDetailsAndroid")] public IReadOnlyList? OneTimePurchaseOfferDetailsAndroid { get; init; } [JsonPropertyName("platform")] @@ -3444,7 +3445,7 @@ public sealed record ProductSubscriptionAndroid : ProductSubscription, ProductCo public required IReadOnlyList SubscriptionOfferDetailsAndroid { get; init; } /// Standardized subscription offers. /// Cross-platform type with Android-specific fields using suffix. - /// @see https://openiap.dev/docs/types#subscription-offer + /// @see https://openiap.dev/docs/types/subscription-offer [JsonPropertyName("subscriptionOffers")] public required IReadOnlyList SubscriptionOffers { get; init; } [JsonPropertyName("title")] @@ -3455,7 +3456,7 @@ public sealed record ProductSubscriptionAndroid : ProductSubscription, ProductCo /// Subscription offer details (Android). /// @deprecated Use the standardized SubscriptionOffer type instead for cross-platform compatibility. -/// @see https://openiap.dev/docs/types#subscription-offer +/// @see https://openiap.dev/docs/types/subscription-offer public sealed record ProductSubscriptionAndroidOfferDetails { [JsonPropertyName("basePlanId")] @@ -3524,7 +3525,7 @@ public sealed record ProductSubscriptionIOS : ProductSubscription, ProductCommon public SubscriptionInfoIOS? SubscriptionInfoIOS { get; init; } /// Standardized subscription offers. /// Cross-platform type with iOS-specific fields using suffix. - /// @see https://openiap.dev/docs/types#subscription-offer + /// @see https://openiap.dev/docs/types/subscription-offer [JsonPropertyName("subscriptionOffers")] public IReadOnlyList? SubscriptionOffers { get; init; } [JsonPropertyName("subscriptionPeriodNumberIOS")] @@ -3861,8 +3862,7 @@ public sealed record SubscriptionInfoIOS /// - iOS: Introductory offers, promotional offers with server-side signatures /// - Android: Offer tokens with pricing phases /// -/// @see https://openiap.dev/docs/types/ios#discount-offer -/// @see https://openiap.dev/docs/types/android#subscription-offer +/// @see https://openiap.dev/docs/types/subscription-offer public sealed record SubscriptionOffer { /// [Android] Base plan identifier. @@ -3937,7 +3937,7 @@ public sealed record SubscriptionOffer /// iOS subscription offer details. /// @deprecated Use the standardized SubscriptionOffer type instead for cross-platform compatibility. -/// @see https://openiap.dev/docs/types#subscription-offer +/// @see https://openiap.dev/docs/types/subscription-offer public sealed record SubscriptionOfferIOS { [JsonPropertyName("displayPrice")] diff --git a/packages/gql/src/generated/Types.kt b/packages/gql/src/generated/Types.kt index fb59190ea..a222be9b9 100644 --- a/packages/gql/src/generated/Types.kt +++ b/packages/gql/src/generated/Types.kt @@ -2203,7 +2203,7 @@ public data class DiscountDisplayInfoAndroid( /** * Discount information returned from the store. * @deprecated Use the standardized SubscriptionOffer type instead for cross-platform compatibility. - * @see https://openiap.dev/docs/types#subscription-offer + * @see https://openiap.dev/docs/types/subscription-offer */ public data class DiscountIOS( val identifier: String, @@ -2246,10 +2246,11 @@ public data class DiscountIOS( /** * Standardized one-time product discount offer. - * Provides a unified interface for one-time purchase discounts across platforms. + * Provides a platform-neutral OpenIAP shape for Google Play one-time product + * purchase options and offers. * - * Currently supported on Android (Google Play Billing 8.0+). - * iOS does not support one-time purchase discounts in the same way. + * Currently populated only on Android (Google Play Billing 8.0+). + * iOS does not populate this type. * * @see https://openiap.dev/docs/features/discount */ @@ -2268,7 +2269,7 @@ public data class DiscountOffer( */ val displayPrice: String, /** - * [Android] Formatted discount amount string (e.g., "$5.00 OFF"). + * [Android] Formatted discount amount including its currency sign (e.g., "$5.00"). */ val formattedDiscountAmountAndroid: String? = null, /** @@ -2379,7 +2380,7 @@ public data class DiscountOffer( /** * iOS DiscountOffer (output type). * @deprecated Use the standardized SubscriptionOffer type instead for cross-platform compatibility. - * @see https://openiap.dev/docs/types#subscription-offer + * @see https://openiap.dev/docs/types/subscription-offer */ public data class DiscountOfferIOS( /** @@ -2896,9 +2897,9 @@ public data class ProductAndroid( override val debugDescription: String? = null, override val description: String, /** - * Standardized discount offers for one-time products. - * Cross-platform type with Android-specific fields using suffix. - * @see https://openiap.dev/docs/types#discount-offer + * Standardized Android one-time product purchase options and offers. + * Native metadata uses Android-suffixed fields. + * @see https://openiap.dev/docs/types/discount-offer */ val discountOffers: List? = null, override val displayName: String? = null, @@ -2908,7 +2909,7 @@ public data class ProductAndroid( /** * One-time purchase offer details including discounts (Android) * Returns all eligible offers. Available in Google Play Billing Library 8.0+ - * @deprecated Use discountOffers instead for cross-platform compatibility. + * @deprecated Use the standardized discountOffers field instead. */ val oneTimePurchaseOfferDetailsAndroid: List? = null, override val platform: IapPlatform = IapPlatform.Android, @@ -2928,7 +2929,7 @@ public data class ProductAndroid( /** * Standardized subscription offers. * Cross-platform type with Android-specific fields using suffix. - * @see https://openiap.dev/docs/types#subscription-offer + * @see https://openiap.dev/docs/types/subscription-offer */ val subscriptionOffers: List? = null, override val title: String, @@ -2982,8 +2983,8 @@ public data class ProductAndroid( /** * One-time purchase offer details (Android). * Available in Google Play Billing Library 8.0+ - * @deprecated Use the standardized DiscountOffer type instead for cross-platform compatibility. - * @see https://openiap.dev/docs/types#discount-offer + * @deprecated Use the standardized DiscountOffer type for Android one-time offers. + * @see https://openiap.dev/docs/types/discount-offer */ public data class ProductAndroidOneTimePurchaseOfferDetail( /** @@ -3099,7 +3100,7 @@ public data class ProductIOS( * Standardized subscription offers. * Cross-platform type with iOS-specific fields using suffix. * Note: iOS does not support one-time product discounts. - * @see https://openiap.dev/docs/types#subscription-offer + * @see https://openiap.dev/docs/types/subscription-offer */ val subscriptionOffers: List? = null, override val title: String, @@ -3158,9 +3159,8 @@ public data class ProductSubscriptionAndroid( override val debugDescription: String? = null, override val description: String, /** - * Standardized discount offers for one-time products. - * Cross-platform type with Android-specific fields using suffix. - * @see https://openiap.dev/docs/types#discount-offer + * Nullable compatibility field. Google Play does not return one-time purchase + * offer details for subscription products; use subscriptionOffers below. */ val discountOffers: List? = null, override val displayName: String? = null, @@ -3168,9 +3168,10 @@ public data class ProductSubscriptionAndroid( override val id: String, val nameAndroid: String, /** - * One-time purchase offer details including discounts (Android) - * Returns all eligible offers. Available in Google Play Billing Library 8.0+ - * @deprecated Use discountOffers instead for cross-platform compatibility. + * Legacy nullable compatibility field. Google Play does not populate one-time + * purchase offer details for subscription products. + * @deprecated One-time offers belong to ProductAndroid.discountOffers; + * subscriptions use subscriptionOffers. */ val oneTimePurchaseOfferDetailsAndroid: List? = null, override val platform: IapPlatform = IapPlatform.Android, @@ -3190,7 +3191,7 @@ public data class ProductSubscriptionAndroid( /** * Standardized subscription offers. * Cross-platform type with Android-specific fields using suffix. - * @see https://openiap.dev/docs/types#subscription-offer + * @see https://openiap.dev/docs/types/subscription-offer */ val subscriptionOffers: List, override val title: String, @@ -3244,7 +3245,7 @@ public data class ProductSubscriptionAndroid( /** * Subscription offer details (Android). * @deprecated Use the standardized SubscriptionOffer type instead for cross-platform compatibility. - * @see https://openiap.dev/docs/types#subscription-offer + * @see https://openiap.dev/docs/types/subscription-offer */ public data class ProductSubscriptionAndroidOfferDetails( val basePlanId: String, @@ -3321,7 +3322,7 @@ public data class ProductSubscriptionIOS( /** * Standardized subscription offers. * Cross-platform type with iOS-specific fields using suffix. - * @see https://openiap.dev/docs/types#subscription-offer + * @see https://openiap.dev/docs/types/subscription-offer */ val subscriptionOffers: List? = null, val subscriptionPeriodNumberIOS: String? = null, @@ -4043,8 +4044,7 @@ public data class SubscriptionInfoIOS( * - iOS: Introductory offers, promotional offers with server-side signatures * - Android: Offer tokens with pricing phases * - * @see https://openiap.dev/docs/types/ios#discount-offer - * @see https://openiap.dev/docs/types/android#subscription-offer + * @see https://openiap.dev/docs/types/subscription-offer */ public data class SubscriptionOffer( /** @@ -4189,7 +4189,7 @@ public data class SubscriptionOffer( /** * iOS subscription offer details. * @deprecated Use the standardized SubscriptionOffer type instead for cross-platform compatibility. - * @see https://openiap.dev/docs/types#subscription-offer + * @see https://openiap.dev/docs/types/subscription-offer */ public data class SubscriptionOfferIOS( val displayPrice: String, diff --git a/packages/gql/src/generated/Types.swift b/packages/gql/src/generated/Types.swift index 40ffcd7ae..a46c07cd5 100644 --- a/packages/gql/src/generated/Types.swift +++ b/packages/gql/src/generated/Types.swift @@ -838,7 +838,7 @@ public struct DiscountDisplayInfoAndroid: Codable { /// Discount information returned from the store. /// @deprecated Use the standardized SubscriptionOffer type instead for cross-platform compatibility. -/// @see https://openiap.dev/docs/types#subscription-offer +/// @see https://openiap.dev/docs/types/subscription-offer public struct DiscountIOS: Codable { public var identifier: String public var localizedPrice: String? = nil @@ -851,10 +851,11 @@ public struct DiscountIOS: Codable { } /// Standardized one-time product discount offer. -/// Provides a unified interface for one-time purchase discounts across platforms. +/// Provides a platform-neutral OpenIAP shape for Google Play one-time product +/// purchase options and offers. /// -/// Currently supported on Android (Google Play Billing 8.0+). -/// iOS does not support one-time purchase discounts in the same way. +/// Currently populated only on Android (Google Play Billing 8.0+). +/// iOS does not populate this type. /// /// @see https://openiap.dev/docs/features/discount public struct DiscountOffer: Codable { @@ -865,7 +866,7 @@ public struct DiscountOffer: Codable { public var discountAmountMicrosAndroid: String? = nil /// Formatted display price string (e.g., "$4.99") public var displayPrice: String - /// [Android] Formatted discount amount string (e.g., "$5.00 OFF"). + /// [Android] Formatted discount amount including its currency sign (e.g., "$5.00"). public var formattedDiscountAmountAndroid: String? = nil /// [Android] Original full price in micro-units before discount. /// Divide by 1,000,000 to get the actual price. @@ -906,7 +907,7 @@ public struct DiscountOffer: Codable { /// iOS DiscountOffer (output type). /// @deprecated Use the standardized SubscriptionOffer type instead for cross-platform compatibility. -/// @see https://openiap.dev/docs/types#subscription-offer +/// @see https://openiap.dev/docs/types/subscription-offer public struct DiscountOfferIOS: Codable { /// Discount identifier public var identifier: String @@ -1071,9 +1072,9 @@ public struct ProductAndroid: Codable, ProductCommon { public var currency: String public var debugDescription: String? = nil public var description: String - /// Standardized discount offers for one-time products. - /// Cross-platform type with Android-specific fields using suffix. - /// @see https://openiap.dev/docs/types#discount-offer + /// Standardized Android one-time product purchase options and offers. + /// Native metadata uses Android-suffixed fields. + /// @see https://openiap.dev/docs/types/discount-offer public var discountOffers: [DiscountOffer]? = nil public var displayName: String? = nil public var displayPrice: String @@ -1081,7 +1082,7 @@ public struct ProductAndroid: Codable, ProductCommon { public var nameAndroid: String /// One-time purchase offer details including discounts (Android) /// Returns all eligible offers. Available in Google Play Billing Library 8.0+ - /// @deprecated Use discountOffers instead for cross-platform compatibility. + /// @deprecated Use the standardized discountOffers field instead. public var oneTimePurchaseOfferDetailsAndroid: [ProductAndroidOneTimePurchaseOfferDetail]? = nil public var platform: IapPlatform = .android public var price: Double? = nil @@ -1095,7 +1096,7 @@ public struct ProductAndroid: Codable, ProductCommon { public var subscriptionOfferDetailsAndroid: [ProductSubscriptionAndroidOfferDetails]? = nil /// Standardized subscription offers. /// Cross-platform type with Android-specific fields using suffix. - /// @see https://openiap.dev/docs/types#subscription-offer + /// @see https://openiap.dev/docs/types/subscription-offer public var subscriptionOffers: [SubscriptionOffer]? = nil public var title: String public var type: ProductType = .inApp @@ -1103,8 +1104,8 @@ public struct ProductAndroid: Codable, ProductCommon { /// One-time purchase offer details (Android). /// Available in Google Play Billing Library 8.0+ -/// @deprecated Use the standardized DiscountOffer type instead for cross-platform compatibility. -/// @see https://openiap.dev/docs/types#discount-offer +/// @deprecated Use the standardized DiscountOffer type for Android one-time offers. +/// @see https://openiap.dev/docs/types/discount-offer public struct ProductAndroidOneTimePurchaseOfferDetail: Codable { /// Discount display information /// Only available for discounted offers @@ -1156,7 +1157,7 @@ public struct ProductIOS: Codable, ProductCommon { /// Standardized subscription offers. /// Cross-platform type with iOS-specific fields using suffix. /// Note: iOS does not support one-time product discounts. - /// @see https://openiap.dev/docs/types#subscription-offer + /// @see https://openiap.dev/docs/types/subscription-offer public var subscriptionOffers: [SubscriptionOffer]? = nil public var title: String public var type: ProductType = .inApp @@ -1167,17 +1168,17 @@ public struct ProductSubscriptionAndroid: Codable, ProductCommon { public var currency: String public var debugDescription: String? = nil public var description: String - /// Standardized discount offers for one-time products. - /// Cross-platform type with Android-specific fields using suffix. - /// @see https://openiap.dev/docs/types#discount-offer + /// Nullable compatibility field. Google Play does not return one-time purchase + /// offer details for subscription products; use subscriptionOffers below. public var discountOffers: [DiscountOffer]? = nil public var displayName: String? = nil public var displayPrice: String public var id: String public var nameAndroid: String - /// One-time purchase offer details including discounts (Android) - /// Returns all eligible offers. Available in Google Play Billing Library 8.0+ - /// @deprecated Use discountOffers instead for cross-platform compatibility. + /// Legacy nullable compatibility field. Google Play does not populate one-time + /// purchase offer details for subscription products. + /// @deprecated One-time offers belong to ProductAndroid.discountOffers; + /// subscriptions use subscriptionOffers. public var oneTimePurchaseOfferDetailsAndroid: [ProductAndroidOneTimePurchaseOfferDetail]? = nil public var platform: IapPlatform = .android public var price: Double? = nil @@ -1191,7 +1192,7 @@ public struct ProductSubscriptionAndroid: Codable, ProductCommon { public var subscriptionOfferDetailsAndroid: [ProductSubscriptionAndroidOfferDetails] /// Standardized subscription offers. /// Cross-platform type with Android-specific fields using suffix. - /// @see https://openiap.dev/docs/types#subscription-offer + /// @see https://openiap.dev/docs/types/subscription-offer public var subscriptionOffers: [SubscriptionOffer] public var title: String public var type: ProductType = .subs @@ -1199,7 +1200,7 @@ public struct ProductSubscriptionAndroid: Codable, ProductCommon { /// Subscription offer details (Android). /// @deprecated Use the standardized SubscriptionOffer type instead for cross-platform compatibility. -/// @see https://openiap.dev/docs/types#subscription-offer +/// @see https://openiap.dev/docs/types/subscription-offer public struct ProductSubscriptionAndroidOfferDetails: Codable { public var basePlanId: String /// Installment plan details for this subscription offer. @@ -1240,7 +1241,7 @@ public struct ProductSubscriptionIOS: Codable, ProductCommon { public var subscriptionInfoIOS: SubscriptionInfoIOS? = nil /// Standardized subscription offers. /// Cross-platform type with iOS-specific fields using suffix. - /// @see https://openiap.dev/docs/types#subscription-offer + /// @see https://openiap.dev/docs/types/subscription-offer public var subscriptionOffers: [SubscriptionOffer]? = nil public var subscriptionPeriodNumberIOS: String? = nil public var subscriptionPeriodUnitIOS: SubscriptionPeriodIOS? = nil @@ -1453,8 +1454,7 @@ public struct SubscriptionInfoIOS: Codable { /// - iOS: Introductory offers, promotional offers with server-side signatures /// - Android: Offer tokens with pricing phases /// -/// @see https://openiap.dev/docs/types/ios#discount-offer -/// @see https://openiap.dev/docs/types/android#subscription-offer +/// @see https://openiap.dev/docs/types/subscription-offer public struct SubscriptionOffer: Codable { /// [Android] Base plan identifier. /// Identifies which base plan this offer belongs to. @@ -1509,7 +1509,7 @@ public struct SubscriptionOffer: Codable { /// iOS subscription offer details. /// @deprecated Use the standardized SubscriptionOffer type instead for cross-platform compatibility. -/// @see https://openiap.dev/docs/types#subscription-offer +/// @see https://openiap.dev/docs/types/subscription-offer public struct SubscriptionOfferIOS: Codable { public var displayPrice: String public var id: String diff --git a/packages/gql/src/generated/types.dart b/packages/gql/src/generated/types.dart index e3af72e5c..0dffa88a4 100644 --- a/packages/gql/src/generated/types.dart +++ b/packages/gql/src/generated/types.dart @@ -1982,7 +1982,7 @@ class DiscountDisplayInfoAndroid { /// Discount information returned from the store. /// @deprecated Use the standardized SubscriptionOffer type instead for cross-platform compatibility. -/// @see https://openiap.dev/docs/types#subscription-offer +/// @see https://openiap.dev/docs/types/subscription-offer class DiscountIOS { const DiscountIOS({ required this.identifier, @@ -2033,10 +2033,11 @@ class DiscountIOS { } /// Standardized one-time product discount offer. -/// Provides a unified interface for one-time purchase discounts across platforms. +/// Provides a platform-neutral OpenIAP shape for Google Play one-time product +/// purchase options and offers. /// -/// Currently supported on Android (Google Play Billing 8.0+). -/// iOS does not support one-time purchase discounts in the same way. +/// Currently populated only on Android (Google Play Billing 8.0+). +/// iOS does not populate this type. /// /// @see https://openiap.dev/docs/features/discount class DiscountOffer { @@ -2066,7 +2067,7 @@ class DiscountOffer { final String? discountAmountMicrosAndroid; /// Formatted display price string (e.g., "$4.99") final String displayPrice; - /// [Android] Formatted discount amount string (e.g., "$5.00 OFF"). + /// [Android] Formatted discount amount including its currency sign (e.g., "$5.00"). final String? formattedDiscountAmountAndroid; /// [Android] Original full price in micro-units before discount. /// Divide by 1,000,000 to get the actual price. @@ -2150,7 +2151,7 @@ class DiscountOffer { /// iOS DiscountOffer (output type). /// @deprecated Use the standardized SubscriptionOffer type instead for cross-platform compatibility. -/// @see https://openiap.dev/docs/types#subscription-offer +/// @see https://openiap.dev/docs/types/subscription-offer class DiscountOfferIOS { const DiscountOfferIOS({ required this.identifier, @@ -2691,9 +2692,9 @@ class ProductAndroid extends Product implements ProductCommon { final String currency; final String? debugDescription; final String description; - /// Standardized discount offers for one-time products. - /// Cross-platform type with Android-specific fields using suffix. - /// @see https://openiap.dev/docs/types#discount-offer + /// Standardized Android one-time product purchase options and offers. + /// Native metadata uses Android-suffixed fields. + /// @see https://openiap.dev/docs/types/discount-offer final List? discountOffers; final String? displayName; final String displayPrice; @@ -2701,7 +2702,7 @@ class ProductAndroid extends Product implements ProductCommon { final String nameAndroid; /// One-time purchase offer details including discounts (Android) /// Returns all eligible offers. Available in Google Play Billing Library 8.0+ - /// @deprecated Use discountOffers instead for cross-platform compatibility. + /// @deprecated Use the standardized discountOffers field instead. final List? oneTimePurchaseOfferDetailsAndroid; final IapPlatform platform; final double? price; @@ -2715,7 +2716,7 @@ class ProductAndroid extends Product implements ProductCommon { final List? subscriptionOfferDetailsAndroid; /// Standardized subscription offers. /// Cross-platform type with Android-specific fields using suffix. - /// @see https://openiap.dev/docs/types#subscription-offer + /// @see https://openiap.dev/docs/types/subscription-offer final List? subscriptionOffers; final String title; final ProductType type; @@ -2767,8 +2768,8 @@ class ProductAndroid extends Product implements ProductCommon { /// One-time purchase offer details (Android). /// Available in Google Play Billing Library 8.0+ -/// @deprecated Use the standardized DiscountOffer type instead for cross-platform compatibility. -/// @see https://openiap.dev/docs/types#discount-offer +/// @deprecated Use the standardized DiscountOffer type for Android one-time offers. +/// @see https://openiap.dev/docs/types/discount-offer class ProductAndroidOneTimePurchaseOfferDetail { const ProductAndroidOneTimePurchaseOfferDetail({ this.discountDisplayInfo, @@ -2893,7 +2894,7 @@ class ProductIOS extends Product implements ProductCommon { /// Standardized subscription offers. /// Cross-platform type with iOS-specific fields using suffix. /// Note: iOS does not support one-time product discounts. - /// @see https://openiap.dev/docs/types#subscription-offer + /// @see https://openiap.dev/docs/types/subscription-offer final List? subscriptionOffers; final String title; final ProductType type; @@ -2969,17 +2970,17 @@ class ProductSubscriptionAndroid extends ProductSubscription implements ProductC final String currency; final String? debugDescription; final String description; - /// Standardized discount offers for one-time products. - /// Cross-platform type with Android-specific fields using suffix. - /// @see https://openiap.dev/docs/types#discount-offer + /// Nullable compatibility field. Google Play does not return one-time purchase + /// offer details for subscription products; use subscriptionOffers below. final List? discountOffers; final String? displayName; final String displayPrice; final String id; final String nameAndroid; - /// One-time purchase offer details including discounts (Android) - /// Returns all eligible offers. Available in Google Play Billing Library 8.0+ - /// @deprecated Use discountOffers instead for cross-platform compatibility. + /// Legacy nullable compatibility field. Google Play does not populate one-time + /// purchase offer details for subscription products. + /// @deprecated One-time offers belong to ProductAndroid.discountOffers; + /// subscriptions use subscriptionOffers. final List? oneTimePurchaseOfferDetailsAndroid; final IapPlatform platform; final double? price; @@ -2993,7 +2994,7 @@ class ProductSubscriptionAndroid extends ProductSubscription implements ProductC final List subscriptionOfferDetailsAndroid; /// Standardized subscription offers. /// Cross-platform type with Android-specific fields using suffix. - /// @see https://openiap.dev/docs/types#subscription-offer + /// @see https://openiap.dev/docs/types/subscription-offer final List subscriptionOffers; final String title; final ProductType type; @@ -3045,7 +3046,7 @@ class ProductSubscriptionAndroid extends ProductSubscription implements ProductC /// Subscription offer details (Android). /// @deprecated Use the standardized SubscriptionOffer type instead for cross-platform compatibility. -/// @see https://openiap.dev/docs/types#subscription-offer +/// @see https://openiap.dev/docs/types/subscription-offer class ProductSubscriptionAndroidOfferDetails { const ProductSubscriptionAndroidOfferDetails({ required this.basePlanId, @@ -3147,7 +3148,7 @@ class ProductSubscriptionIOS extends ProductSubscription implements ProductCommo final SubscriptionInfoIOS? subscriptionInfoIOS; /// Standardized subscription offers. /// Cross-platform type with iOS-specific fields using suffix. - /// @see https://openiap.dev/docs/types#subscription-offer + /// @see https://openiap.dev/docs/types/subscription-offer final List? subscriptionOffers; final String? subscriptionPeriodNumberIOS; final SubscriptionPeriodIOS? subscriptionPeriodUnitIOS; @@ -3915,8 +3916,7 @@ class SubscriptionInfoIOS { /// - iOS: Introductory offers, promotional offers with server-side signatures /// - Android: Offer tokens with pricing phases /// -/// @see https://openiap.dev/docs/types/ios#discount-offer -/// @see https://openiap.dev/docs/types/android#subscription-offer +/// @see https://openiap.dev/docs/types/subscription-offer class SubscriptionOffer { const SubscriptionOffer({ this.basePlanIdAndroid, @@ -4042,7 +4042,7 @@ class SubscriptionOffer { /// iOS subscription offer details. /// @deprecated Use the standardized SubscriptionOffer type instead for cross-platform compatibility. -/// @see https://openiap.dev/docs/types#subscription-offer +/// @see https://openiap.dev/docs/types/subscription-offer class SubscriptionOfferIOS { const SubscriptionOfferIOS({ required this.displayPrice, diff --git a/packages/gql/src/generated/types.gd b/packages/gql/src/generated/types.gd index 6322882b8..17db2f5a1 100644 --- a/packages/gql/src/generated/types.gd +++ b/packages/gql/src/generated/types.gd @@ -1005,7 +1005,7 @@ class DiscountDisplayInfoAndroid: dict["discountAmount"] = discount_amount return dict -## Discount information returned from the store. @deprecated Use the standardized SubscriptionOffer type instead for cross-platform compatibility. @see https://openiap.dev/docs/types#subscription-offer +## Discount information returned from the store. @deprecated Use the standardized SubscriptionOffer type instead for cross-platform compatibility. @see https://openiap.dev/docs/types/subscription-offer class DiscountIOS: var identifier: String = "" var type: String = "" @@ -1056,7 +1056,7 @@ class DiscountIOS: dict["localizedPrice"] = localized_price return dict -## Standardized one-time product discount offer. Provides a unified interface for one-time purchase discounts across platforms. Currently supported on Android (Google Play Billing 8.0+). iOS does not support one-time purchase discounts in the same way. @see https://openiap.dev/docs/features/discount +## Standardized one-time product discount offer. Provides a platform-neutral OpenIAP shape for Google Play one-time product purchase options and offers. Currently populated only on Android (Google Play Billing 8.0+). iOS does not populate this type. @see https://openiap.dev/docs/features/discount class DiscountOffer: ## Unique identifier for the offer. var id: Variant = null @@ -1078,7 +1078,7 @@ class DiscountOffer: var percentage_discount_android: Variant = null ## [Android] Fixed discount amount in micro-units. var discount_amount_micros_android: Variant = null - ## [Android] Formatted discount amount string (e.g., "$5.00 OFF"). + ## [Android] Formatted discount amount including its currency sign (e.g., "$5.00"). var formatted_discount_amount_android: Variant = null ## [Android] Valid time window for the offer. var valid_time_window_android: ValidTimeWindowAndroid @@ -1190,7 +1190,7 @@ class DiscountOffer: dict["purchaseOptionIdAndroid"] = purchase_option_id_android return dict -## iOS DiscountOffer (output type). @deprecated Use the standardized SubscriptionOffer type instead for cross-platform compatibility. @see https://openiap.dev/docs/types#subscription-offer +## iOS DiscountOffer (output type). @deprecated Use the standardized SubscriptionOffer type instead for cross-platform compatibility. @see https://openiap.dev/docs/types/subscription-offer class DiscountOfferIOS: ## Discount identifier var identifier: String = "" @@ -1612,7 +1612,7 @@ class ProductAndroid: var name_android: String = "" ## Product-level status code indicating fetch result (Android 8.0+) var product_status_android: Variant = null - ## Standardized discount offers for one-time products. + ## Standardized Android one-time product purchase options and offers. var discount_offers: Array[DiscountOffer] = [] ## Standardized subscription offers. var subscription_offers: Array[SubscriptionOffer] = [] @@ -1766,7 +1766,7 @@ class ProductAndroid: dict["subscriptionOfferDetailsAndroid"] = null return dict -## One-time purchase offer details (Android). Available in Google Play Billing Library 8.0+ @deprecated Use the standardized DiscountOffer type instead for cross-platform compatibility. @see https://openiap.dev/docs/types#discount-offer +## One-time purchase offer details (Android). Available in Google Play Billing Library 8.0+ @deprecated Use the standardized DiscountOffer type for Android one-time offers. @see https://openiap.dev/docs/types/discount-offer class ProductAndroidOneTimePurchaseOfferDetail: ## Offer ID var offer_id: Variant = null @@ -2034,11 +2034,11 @@ class ProductSubscriptionAndroid: var name_android: String = "" ## Product-level status code indicating fetch result (Android 8.0+) var product_status_android: Variant = null - ## Standardized discount offers for one-time products. + ## Nullable compatibility field. Google Play does not return one-time purchase var discount_offers: Array[DiscountOffer] = [] ## Standardized subscription offers. var subscription_offers: Array[SubscriptionOffer] = [] - ## One-time purchase offer details including discounts (Android) + ## Legacy nullable compatibility field. Google Play does not populate one-time var one_time_purchase_offer_details_android: Array[ProductAndroidOneTimePurchaseOfferDetail] = [] ## @deprecated Use subscriptionOffers instead for cross-platform compatibility. var subscription_offer_details_android: Array[ProductSubscriptionAndroidOfferDetails] = [] @@ -2188,7 +2188,7 @@ class ProductSubscriptionAndroid: dict["subscriptionOfferDetailsAndroid"] = null return dict -## Subscription offer details (Android). @deprecated Use the standardized SubscriptionOffer type instead for cross-platform compatibility. @see https://openiap.dev/docs/types#subscription-offer +## Subscription offer details (Android). @deprecated Use the standardized SubscriptionOffer type instead for cross-platform compatibility. @see https://openiap.dev/docs/types/subscription-offer class ProductSubscriptionAndroidOfferDetails: var base_plan_id: String = "" var offer_id: Variant = null @@ -3281,7 +3281,7 @@ class SubscriptionInfoIOS: dict["subscriptionPeriod"] = subscription_period return dict -## Standardized subscription discount/promotional offer. Provides a unified interface for subscription offers across iOS and Android. Both platforms support subscription offers with different implementations: - iOS: Introductory offers, promotional offers with server-side signatures - Android: Offer tokens with pricing phases @see https://openiap.dev/docs/types/ios#discount-offer @see https://openiap.dev/docs/types/android#subscription-offer +## Standardized subscription discount/promotional offer. Provides a unified interface for subscription offers across iOS and Android. Both platforms support subscription offers with different implementations: - iOS: Introductory offers, promotional offers with server-side signatures - Android: Offer tokens with pricing phases @see https://openiap.dev/docs/types/subscription-offer class SubscriptionOffer: ## Unique identifier for the offer. var id: String = "" @@ -3435,7 +3435,7 @@ class SubscriptionOffer: dict["installmentPlanDetailsAndroid"] = installment_plan_details_android return dict -## iOS subscription offer details. @deprecated Use the standardized SubscriptionOffer type instead for cross-platform compatibility. @see https://openiap.dev/docs/types#subscription-offer +## iOS subscription offer details. @deprecated Use the standardized SubscriptionOffer type instead for cross-platform compatibility. @see https://openiap.dev/docs/types/subscription-offer class SubscriptionOfferIOS: var display_price: String = "" var id: String = "" diff --git a/packages/gql/src/generated/types.ts b/packages/gql/src/generated/types.ts index 25c7de813..5866e2db8 100644 --- a/packages/gql/src/generated/types.ts +++ b/packages/gql/src/generated/types.ts @@ -328,7 +328,7 @@ export interface DiscountDisplayInfoAndroid { /** * Discount information returned from the store. * @deprecated Use the standardized SubscriptionOffer type instead for cross-platform compatibility. - * @see https://openiap.dev/docs/types#subscription-offer + * @see https://openiap.dev/docs/types/subscription-offer */ export interface DiscountIOS { identifier: string; @@ -343,10 +343,11 @@ export interface DiscountIOS { /** * Standardized one-time product discount offer. - * Provides a unified interface for one-time purchase discounts across platforms. + * Provides a platform-neutral OpenIAP shape for Google Play one-time product + * purchase options and offers. * - * Currently supported on Android (Google Play Billing 8.0+). - * iOS does not support one-time purchase discounts in the same way. + * Currently populated only on Android (Google Play Billing 8.0+). + * iOS does not populate this type. * * @see https://openiap.dev/docs/features/discount */ @@ -360,7 +361,7 @@ export interface DiscountOffer { discountAmountMicrosAndroid?: (string | null); /** Formatted display price string (e.g., "$4.99") */ displayPrice: string; - /** [Android] Formatted discount amount string (e.g., "$5.00 OFF"). */ + /** [Android] Formatted discount amount including its currency sign (e.g., "$5.00"). */ formattedDiscountAmountAndroid?: (string | null); /** * [Android] Original full price in micro-units before discount. @@ -418,7 +419,7 @@ export interface DiscountOffer { /** * iOS DiscountOffer (output type). * @deprecated Use the standardized SubscriptionOffer type instead for cross-platform compatibility. - * @see https://openiap.dev/docs/types#subscription-offer + * @see https://openiap.dev/docs/types/subscription-offer */ export interface DiscountOfferIOS { /** Discount identifier */ @@ -1093,9 +1094,9 @@ export interface ProductAndroid extends ProductCommon { debugDescription?: (string | null); description: string; /** - * Standardized discount offers for one-time products. - * Cross-platform type with Android-specific fields using suffix. - * @see https://openiap.dev/docs/types#discount-offer + * Standardized Android one-time product purchase options and offers. + * Native metadata uses Android-suffixed fields. + * @see https://openiap.dev/docs/types/discount-offer */ discountOffers?: (DiscountOffer[] | null); displayName?: (string | null); @@ -1105,7 +1106,7 @@ export interface ProductAndroid extends ProductCommon { /** * One-time purchase offer details including discounts (Android) * Returns all eligible offers. Available in Google Play Billing Library 8.0+ - * @deprecated Use discountOffers instead for cross-platform compatibility. + * @deprecated Use the standardized discountOffers field instead. * @deprecated Use discountOffers instead */ oneTimePurchaseOfferDetailsAndroid?: (ProductAndroidOneTimePurchaseOfferDetail[] | null); @@ -1127,7 +1128,7 @@ export interface ProductAndroid extends ProductCommon { /** * Standardized subscription offers. * Cross-platform type with Android-specific fields using suffix. - * @see https://openiap.dev/docs/types#subscription-offer + * @see https://openiap.dev/docs/types/subscription-offer */ subscriptionOffers?: (SubscriptionOffer[] | null); title: string; @@ -1137,8 +1138,8 @@ export interface ProductAndroid extends ProductCommon { /** * One-time purchase offer details (Android). * Available in Google Play Billing Library 8.0+ - * @deprecated Use the standardized DiscountOffer type instead for cross-platform compatibility. - * @see https://openiap.dev/docs/types#discount-offer + * @deprecated Use the standardized DiscountOffer type for Android one-time offers. + * @see https://openiap.dev/docs/types/discount-offer */ export interface ProductAndroidOneTimePurchaseOfferDetail { /** @@ -1218,7 +1219,7 @@ export interface ProductIOS extends ProductCommon { * Standardized subscription offers. * Cross-platform type with iOS-specific fields using suffix. * Note: iOS does not support one-time product discounts. - * @see https://openiap.dev/docs/types#subscription-offer + * @see https://openiap.dev/docs/types/subscription-offer */ subscriptionOffers?: (SubscriptionOffer[] | null); title: string; @@ -1250,9 +1251,8 @@ export interface ProductSubscriptionAndroid extends ProductCommon { debugDescription?: (string | null); description: string; /** - * Standardized discount offers for one-time products. - * Cross-platform type with Android-specific fields using suffix. - * @see https://openiap.dev/docs/types#discount-offer + * Nullable compatibility field. Google Play does not return one-time purchase + * offer details for subscription products; use subscriptionOffers below. */ discountOffers?: (DiscountOffer[] | null); displayName?: (string | null); @@ -1260,10 +1260,11 @@ export interface ProductSubscriptionAndroid extends ProductCommon { id: string; nameAndroid: string; /** - * One-time purchase offer details including discounts (Android) - * Returns all eligible offers. Available in Google Play Billing Library 8.0+ - * @deprecated Use discountOffers instead for cross-platform compatibility. - * @deprecated Use discountOffers instead + * Legacy nullable compatibility field. Google Play does not populate one-time + * purchase offer details for subscription products. + * @deprecated One-time offers belong to ProductAndroid.discountOffers; + * subscriptions use subscriptionOffers. + * @deprecated Use subscriptionOffers instead */ oneTimePurchaseOfferDetailsAndroid?: (ProductAndroidOneTimePurchaseOfferDetail[] | null); platform: 'android'; @@ -1284,7 +1285,7 @@ export interface ProductSubscriptionAndroid extends ProductCommon { /** * Standardized subscription offers. * Cross-platform type with Android-specific fields using suffix. - * @see https://openiap.dev/docs/types#subscription-offer + * @see https://openiap.dev/docs/types/subscription-offer */ subscriptionOffers: SubscriptionOffer[]; title: string; @@ -1294,7 +1295,7 @@ export interface ProductSubscriptionAndroid extends ProductCommon { /** * Subscription offer details (Android). * @deprecated Use the standardized SubscriptionOffer type instead for cross-platform compatibility. - * @see https://openiap.dev/docs/types#subscription-offer + * @see https://openiap.dev/docs/types/subscription-offer */ export interface ProductSubscriptionAndroidOfferDetails { basePlanId: string; @@ -1347,7 +1348,7 @@ export interface ProductSubscriptionIOS extends ProductCommon { /** * Standardized subscription offers. * Cross-platform type with iOS-specific fields using suffix. - * @see https://openiap.dev/docs/types#subscription-offer + * @see https://openiap.dev/docs/types/subscription-offer */ subscriptionOffers?: (SubscriptionOffer[] | null); subscriptionPeriodNumberIOS?: (string | null); @@ -2126,8 +2127,7 @@ export interface SubscriptionInfoIOS { * - iOS: Introductory offers, promotional offers with server-side signatures * - Android: Offer tokens with pricing phases * - * @see https://openiap.dev/docs/types/ios#discount-offer - * @see https://openiap.dev/docs/types/android#subscription-offer + * @see https://openiap.dev/docs/types/subscription-offer */ export interface SubscriptionOffer { /** @@ -2202,7 +2202,7 @@ export interface SubscriptionOffer { /** * iOS subscription offer details. * @deprecated Use the standardized SubscriptionOffer type instead for cross-platform compatibility. - * @see https://openiap.dev/docs/types#subscription-offer + * @see https://openiap.dev/docs/types/subscription-offer */ export interface SubscriptionOfferIOS { displayPrice: string; diff --git a/packages/gql/src/type-android.graphql b/packages/gql/src/type-android.graphql index 373c8d5d5..fc2ad015c 100644 --- a/packages/gql/src/type-android.graphql +++ b/packages/gql/src/type-android.graphql @@ -138,8 +138,8 @@ type DiscountDisplayInfoAndroid { """ One-time purchase offer details (Android). Available in Google Play Billing Library 8.0+ -@deprecated Use the standardized DiscountOffer type instead for cross-platform compatibility. -@see https://openiap.dev/docs/types#discount-offer +@deprecated Use the standardized DiscountOffer type for Android one-time offers. +@see https://openiap.dev/docs/types/discount-offer """ type ProductAndroidOneTimePurchaseOfferDetail @deprecated(reason: "Use DiscountOffer type instead") { @@ -217,7 +217,7 @@ type InstallmentPlanDetailsAndroid { """ Subscription offer details (Android). @deprecated Use the standardized SubscriptionOffer type instead for cross-platform compatibility. -@see https://openiap.dev/docs/types#subscription-offer +@see https://openiap.dev/docs/types/subscription-offer """ type ProductSubscriptionAndroidOfferDetails @deprecated(reason: "Use SubscriptionOffer type instead") { @@ -258,18 +258,18 @@ type ProductAndroid implements ProductCommon { """ productStatusAndroid: ProductStatusAndroid - # Standardized cross-platform fields + # Standardized offer fields """ - Standardized discount offers for one-time products. - Cross-platform type with Android-specific fields using suffix. - @see https://openiap.dev/docs/types#discount-offer + Standardized Android one-time product purchase options and offers. + Native metadata uses Android-suffixed fields. + @see https://openiap.dev/docs/types/discount-offer """ discountOffers: [DiscountOffer!] """ Standardized subscription offers. Cross-platform type with Android-specific fields using suffix. - @see https://openiap.dev/docs/types#subscription-offer + @see https://openiap.dev/docs/types/subscription-offer """ subscriptionOffers: [SubscriptionOffer!] @@ -277,7 +277,7 @@ type ProductAndroid implements ProductCommon { """ One-time purchase offer details including discounts (Android) Returns all eligible offers. Available in Google Play Billing Library 8.0+ - @deprecated Use discountOffers instead for cross-platform compatibility. + @deprecated Use the standardized discountOffers field instead. """ oneTimePurchaseOfferDetailsAndroid: [ProductAndroidOneTimePurchaseOfferDetail!] @deprecated(reason: "Use discountOffers instead") @@ -312,29 +312,29 @@ type ProductSubscriptionAndroid implements ProductCommon { """ productStatusAndroid: ProductStatusAndroid - # Standardized cross-platform fields + # Standardized offer fields """ - Standardized discount offers for one-time products. - Cross-platform type with Android-specific fields using suffix. - @see https://openiap.dev/docs/types#discount-offer + Nullable compatibility field. Google Play does not return one-time purchase + offer details for subscription products; use subscriptionOffers below. """ discountOffers: [DiscountOffer!] """ Standardized subscription offers. Cross-platform type with Android-specific fields using suffix. - @see https://openiap.dev/docs/types#subscription-offer + @see https://openiap.dev/docs/types/subscription-offer """ subscriptionOffers: [SubscriptionOffer!]! # Deprecated platform-specific fields """ - One-time purchase offer details including discounts (Android) - Returns all eligible offers. Available in Google Play Billing Library 8.0+ - @deprecated Use discountOffers instead for cross-platform compatibility. + Legacy nullable compatibility field. Google Play does not populate one-time + purchase offer details for subscription products. + @deprecated One-time offers belong to ProductAndroid.discountOffers; + subscriptions use subscriptionOffers. """ oneTimePurchaseOfferDetailsAndroid: [ProductAndroidOneTimePurchaseOfferDetail!] - @deprecated(reason: "Use discountOffers instead") + @deprecated(reason: "Use subscriptionOffers instead") """ @deprecated Use subscriptionOffers instead for cross-platform compatibility. """ diff --git a/packages/gql/src/type-ios.graphql b/packages/gql/src/type-ios.graphql index 0c2e85c0c..52a081a46 100644 --- a/packages/gql/src/type-ios.graphql +++ b/packages/gql/src/type-ios.graphql @@ -61,7 +61,7 @@ type SubscriptionPeriodValueIOS { """ iOS subscription offer details. @deprecated Use the standardized SubscriptionOffer type instead for cross-platform compatibility. -@see https://openiap.dev/docs/types#subscription-offer +@see https://openiap.dev/docs/types/subscription-offer """ type SubscriptionOfferIOS @deprecated(reason: "Use SubscriptionOffer type instead") { @@ -122,7 +122,7 @@ type ProductIOS implements ProductCommon { Standardized subscription offers. Cross-platform type with iOS-specific fields using suffix. Note: iOS does not support one-time product discounts. - @see https://openiap.dev/docs/types#subscription-offer + @see https://openiap.dev/docs/types/subscription-offer """ subscriptionOffers: [SubscriptionOffer!] @@ -164,7 +164,7 @@ type ProductSubscriptionIOS implements ProductCommon { """ Standardized subscription offers. Cross-platform type with iOS-specific fields using suffix. - @see https://openiap.dev/docs/types#subscription-offer + @see https://openiap.dev/docs/types/subscription-offer """ subscriptionOffers: [SubscriptionOffer!] @@ -205,7 +205,7 @@ type ProductSubscriptionIOS implements ProductCommon { """ Discount information returned from the store. @deprecated Use the standardized SubscriptionOffer type instead for cross-platform compatibility. -@see https://openiap.dev/docs/types#subscription-offer +@see https://openiap.dev/docs/types/subscription-offer """ type DiscountIOS @deprecated(reason: "Use SubscriptionOffer type instead") { identifier: String! @@ -536,7 +536,7 @@ type SubscriptionStatusIOS { """ iOS DiscountOffer (output type). @deprecated Use the standardized SubscriptionOffer type instead for cross-platform compatibility. -@see https://openiap.dev/docs/types#subscription-offer +@see https://openiap.dev/docs/types/subscription-offer """ type DiscountOfferIOS @deprecated(reason: "Use SubscriptionOffer type instead") { diff --git a/packages/gql/src/type.graphql b/packages/gql/src/type.graphql index a63d5e56d..46b2b22ff 100644 --- a/packages/gql/src/type.graphql +++ b/packages/gql/src/type.graphql @@ -602,10 +602,11 @@ type SubscriptionPeriod { """ Standardized one-time product discount offer. -Provides a unified interface for one-time purchase discounts across platforms. +Provides a platform-neutral OpenIAP shape for Google Play one-time product +purchase options and offers. -Currently supported on Android (Google Play Billing 8.0+). -iOS does not support one-time purchase discounts in the same way. +Currently populated only on Android (Google Play Billing 8.0+). +iOS does not populate this type. @see https://openiap.dev/docs/features/discount """ @@ -672,7 +673,7 @@ type DiscountOffer { discountAmountMicrosAndroid: String """ - [Android] Formatted discount amount string (e.g., "$5.00 OFF"). + [Android] Formatted discount amount including its currency sign (e.g., "$5.00"). """ formattedDiscountAmountAndroid: String @@ -715,8 +716,7 @@ Both platforms support subscription offers with different implementations: - iOS: Introductory offers, promotional offers with server-side signatures - Android: Offer tokens with pricing phases -@see https://openiap.dev/docs/types/ios#discount-offer -@see https://openiap.dev/docs/types/android#subscription-offer +@see https://openiap.dev/docs/types/subscription-offer """ type SubscriptionOffer { """ diff --git a/scripts/audit-docs.test.ts b/scripts/audit-docs.test.ts index 5fe9ce83c..0f865f522 100644 --- a/scripts/audit-docs.test.ts +++ b/scripts/audit-docs.test.ts @@ -1,5 +1,34 @@ import { describe, expect, test } from 'bun:test'; -import { auditActiveCodeExampleSource } from './audit-docs'; +import { + auditActiveCodeExampleSource, + auditCanonicalOfferDocs, + type CanonicalOfferDocsSources, +} from './audit-docs'; + +const VALID_DISCOUNT_OFFER_TYPE_BLOCKS = `{\` +type DiscountOfferType = 'introductory' | 'promotional' | 'one-time'; +\`} +{\` +enum DiscountOfferType: String { + case introductory = "introductory" + case promotional = "promotional" + case oneTime = "one-time" +} +\`} +{\` +enum class DiscountOfferType(val rawValue: String) { + Introductory("introductory"), + Promotional("promotional"), + OneTime("one-time") +} +\`} +{\` +enum DiscountOfferType { + Introductory('introductory'), + Promotional('promotional'), + OneTime('one-time'); +} +\`}`; describe('active docs code-example audit', () => { test('flags recurring cross-language phantom patterns', () => { @@ -61,4 +90,280 @@ props.sku = "premium"\`}`; expect.objectContaining({ rule: 'R11', line: 1 }), ]); }); + + test('flags obsolete Kotlin and KMP requestPurchase named arguments', () => { + const source = [ + '{`iapStore.requestPurchase(activity = activity, props = request)`}', + '{`kmpIAP.requestPurchase(props = request)`}', + ].join('\n'); + + expect(auditActiveCodeExampleSource('/tmp/active.tsx', source)).toEqual([ + expect.objectContaining({ rule: 'R11' }), + expect.objectContaining({ rule: 'R11' }), + ]); + }); + + test('accepts current Kotlin and KMP requestPurchase calls', () => { + const source = [ + '{`iapStore.requestPurchase(request)`}', + '{`kmpIAP.requestPurchase(RequestPurchaseProps(...))`}', + ].join('\n'); + + expect(auditActiveCodeExampleSource('/tmp/active.tsx', source)).toEqual([]); + }); +}); + +const validOfferDocsSources = ( + overrides: Partial> = {} +): CanonicalOfferDocsSources => ({ + discountOffer: { + file: '/tmp/discount-offer.tsx', + source: + overrides.discountOffer ?? + `

    DiscountOffer represents one-time products on Android via ProductDetails.OneTimePurchaseOfferDetails.

    +${VALID_DISCOUNT_OFFER_TYPE_BLOCKS}`, + }, + subscriptionOffer: { + file: '/tmp/subscription-offer.tsx', + source: + overrides.subscriptionOffer ?? + '

    SubscriptionOffer maps to Product.SubscriptionOffer and ProductDetails.SubscriptionOfferDetails.

    ', + }, + searchData: { + file: '/tmp/searchData.ts', + source: + overrides.searchData ?? + `export const apiData = [ + { + id: 'discount-offer', + title: 'DiscountOffer', + category: 'Types', + path: '/docs/types/discount-offer', + }, + { + id: 'subscription-offer', + title: 'SubscriptionOffer', + category: 'Types', + path: '/docs/types/subscription-offer', + }, +];`, + }, +}); + +describe('canonical offer docs audit', () => { + test('accepts canonical one-time, subscription, and search semantics', () => { + expect(auditCanonicalOfferDocs(validOfferDocsSources())).toEqual([]); + }); + + test('flags missing one-time Android native semantics', () => { + const drifts = auditCanonicalOfferDocs( + validOfferDocsSources({ + discountOffer: `

    A generic cross-platform discount.

    +${VALID_DISCOUNT_OFFER_TYPE_BLOCKS}`, + }) + ); + + expect(drifts).toEqual([ + expect.objectContaining({ + rule: 'R12', + message: expect.stringContaining('OneTimePurchaseOfferDetails'), + }), + expect.objectContaining({ + rule: 'R12', + message: expect.stringContaining('one-time product offers'), + }), + ]); + }); + + test('flags subscription mappings and invented WinBack claims on DiscountOffer', () => { + const drifts = auditCanonicalOfferDocs( + validOfferDocsSources({ + discountOffer: `

    Android one-time products use OneTimePurchaseOfferDetails.

    +

    Maps to Product.SubscriptionOffer and SubscriptionOfferDetails.

    +

    WinBack is supported.

    +${VALID_DISCOUNT_OFFER_TYPE_BLOCKS}`, + }) + ); + + expect(drifts).toEqual([ + expect.objectContaining({ + line: 2, + message: expect.stringContaining('Product.SubscriptionOffer'), + }), + expect.objectContaining({ + line: 2, + message: expect.stringContaining('SubscriptionOfferDetails'), + }), + expect.objectContaining({ + line: 3, + message: expect.stringContaining('WinBack'), + }), + ]); + }); + + test('flags an invented WinBack claim on SubscriptionOffer', () => { + const drifts = auditCanonicalOfferDocs( + validOfferDocsSources({ + subscriptionOffer: + '

    SubscriptionOffer maps to Product.SubscriptionOffer and ProductDetails.SubscriptionOfferDetails, and includes WinBack.

    ', + }) + ); + + expect(drifts).toEqual([ + expect.objectContaining({ + rule: 'R12', + line: 1, + message: expect.stringContaining('WinBack'), + }), + ]); + }); + + test('requires both native subscription offer mappings', () => { + for (const [subscriptionOffer, missingType] of [ + [ + '

    SubscriptionOffer maps to ProductDetails.SubscriptionOfferDetails.

    ', + 'Product.SubscriptionOffer', + ], + [ + '

    SubscriptionOffer maps to Product.SubscriptionOffer.

    ', + 'ProductDetails.SubscriptionOfferDetails', + ], + ['

    A generic subscription offer.

    ', 'Product.SubscriptionOffer'], + ] as const) { + const drifts = auditCanonicalOfferDocs( + validOfferDocsSources({ subscriptionOffer }) + ); + + expect(drifts).toContainEqual( + expect.objectContaining({ + rule: 'R12', + message: expect.stringContaining(missingType), + }) + ); + } + }); + + test('flags incorrect DiscountOfferType wire casing and extra members', () => { + for (const declaration of [ + "type DiscountOfferType = 'Introductory' | 'Promotional' | 'OneTime';", + "type DiscountOfferType = 'introductory' | 'promotional' | 'one-time' | 'legacy';", + ]) { + const discountOffer = VALID_DISCOUNT_OFFER_TYPE_BLOCKS.replace( + /\{`[\s\S]*?`}<\/CodeBlock>/, + `{\` +${declaration} +\`}` + ); + const drifts = auditCanonicalOfferDocs( + validOfferDocsSources({ + discountOffer: `

    DiscountOffer represents one-time products on Android via ProductDetails.OneTimePurchaseOfferDetails.

    +${discountOffer}`, + }) + ); + + expect(drifts).toEqual([ + expect.objectContaining({ + rule: 'R12', + line: 3, + message: expect.stringContaining( + "exactly the generated wire values 'introductory', 'promotional', and 'one-time'" + ), + }), + ]); + } + }); + + test('flags incorrect generated-language DiscountOfferType wire values', () => { + for (const [language, brokenBlock] of [ + [ + 'swift', + `{\` +enum DiscountOfferType: String { + case introductory = "introductory" + case promotional = "promotional" + case oneTime = "OneTime" +} +\`}`, + ], + [ + 'kotlin', + `{\` +enum class DiscountOfferType(val rawValue: String) { + Introductory("introductory"), + Promotional("promotional"), + OneTime("OneTime") +} +\`}`, + ], + [ + 'dart', + `{\` +enum DiscountOfferType { + Introductory('introductory'), + Promotional('promotional'), + OneTime('OneTime'); +} +\`}`, + ], + ] as const) { + const discountOffer = VALID_DISCOUNT_OFFER_TYPE_BLOCKS.replace( + new RegExp( + `\\{\\\`[\\s\\S]*?\\\`\\}` + ), + brokenBlock + ); + const drifts = auditCanonicalOfferDocs( + validOfferDocsSources({ + discountOffer: `

    DiscountOffer represents one-time products on Android via ProductDetails.OneTimePurchaseOfferDetails.

    +${discountOffer}`, + }) + ); + + expect(drifts).toEqual([ + expect.objectContaining({ + rule: 'R12', + message: expect.stringContaining(`${language} snippet`), + }), + ]); + } + }); + + test('flags legacy native search routes and missing canonical entries', () => { + const legacySearchData = `export const apiData = [ + { + title: 'DiscountOffer', + path: '/docs/types/ios/discount-offer-ios', + }, + { + title: 'SubscriptionOffer', + path: '/docs/types/android/subscription-offer-android', + }, +];`; + const wrongRouteDrifts = auditCanonicalOfferDocs( + validOfferDocsSources({ searchData: legacySearchData }) + ); + + expect(wrongRouteDrifts).toEqual([ + expect.objectContaining({ + line: 4, + message: expect.stringContaining('/docs/types/discount-offer'), + }), + expect.objectContaining({ + line: 8, + message: expect.stringContaining('/docs/types/subscription-offer'), + }), + ]); + + const missingEntryDrifts = auditCanonicalOfferDocs( + validOfferDocsSources({ searchData: 'export const apiData = [];' }) + ); + expect(missingEntryDrifts).toEqual([ + expect.objectContaining({ + message: expect.stringContaining('canonical DiscountOffer entry'), + }), + expect.objectContaining({ + message: expect.stringContaining('canonical SubscriptionOffer entry'), + }), + ]); + }); }); diff --git a/scripts/audit-docs.ts b/scripts/audit-docs.ts index a6ca0f002..6189a1810 100644 --- a/scripts/audit-docs.ts +++ b/scripts/audit-docs.ts @@ -23,6 +23,9 @@ * package root used by Vercel. * 7. Lints fenced active-doc code examples for a small set of recurring, * language-specific phantom API patterns (release history excluded). + * 8. Protects the canonical one-time and subscription offer pages (and their + * search entries) from being remapped to the platform-specific legacy + * pages or claiming enum members that are not in the generated schema. * * Exit code 0 = clean, 1 = at least one drift detected. * @@ -58,6 +61,18 @@ const DOC_VERSION_METADATA_FILE = resolve( REPO_ROOT, 'packages/docs/src/generated/version-metadata.json' ); +const DISCOUNT_OFFER_DOC_FILE = resolve( + REPO_ROOT, + 'packages/docs/src/pages/docs/types/discount-offer.tsx' +); +const SUBSCRIPTION_OFFER_DOC_FILE = resolve( + REPO_ROOT, + 'packages/docs/src/pages/docs/types/subscription-offer.tsx' +); +const SEARCH_DATA_FILE = resolve( + REPO_ROOT, + 'packages/docs/src/lib/searchData.ts' +); type Drift = { file: string; @@ -66,6 +81,17 @@ type Drift = { message: string; }; +type SourceFile = { + file: string; + source: string; +}; + +export type CanonicalOfferDocsSources = { + discountOffer: SourceFile; + subscriptionOffer: SourceFile; + searchData: SourceFile; +}; + type CodeExampleRule = { language?: string; pattern: RegExp; @@ -128,6 +154,13 @@ const CODE_EXAMPLE_RULES: CodeExampleRule[] = [ message: 'RequestPurchaseProps has no top-level sku; populate one request branch or use in_app().', }, + { + language: 'kotlin', + pattern: + /\b(?:iapStore|kmpIAP)\.requestPurchase\s*\(\s*(?:activity|props)\s*=/, + message: + 'Kotlin and KMP `requestPurchase` accept one positional `RequestPurchaseProps` argument; `activity` and `props` are not parameters.', + }, { pattern: /(?:console\.log|println|print|Console\.WriteLine|Log\.[a-z]+)\([^)]{0,200}\b(?:offerToken|offer_token|OfferToken)\b/, @@ -165,6 +198,336 @@ export function auditActiveCodeExampleSource( return drifts; } +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +function findSearchEntriesByTitle( + source: string, + title: string +): { line: number; path: string | null; pathLine: number }[] { + const entries: { line: number; path: string | null; pathLine: number }[] = []; + const titleRe = new RegExp( + `\\btitle:\\s*(['"])${escapeRegExp(title)}\\1`, + 'g' + ); + let titleMatch: RegExpExecArray | null; + + while ((titleMatch = titleRe.exec(source)) !== null) { + const objectStartMarker = source.lastIndexOf('\n {', titleMatch.index); + const objectStart = objectStartMarker === -1 ? 0 : objectStartMarker + 1; + const objectEndMarker = source.indexOf('\n },', titleMatch.index); + const objectEnd = + objectEndMarker === -1 + ? source.length + : objectEndMarker + '\n },'.length; + const objectSource = source.slice(objectStart, objectEnd); + const pathMatch = /\bpath:\s*(['"])([^'"]+)\1/.exec(objectSource); + + entries.push({ + line: lineNumberAt(source, titleMatch.index), + path: pathMatch?.[2] ?? null, + pathLine: pathMatch + ? lineNumberAt(source, objectStart + pathMatch.index) + : lineNumberAt(source, titleMatch.index), + }); + } + + return entries; +} + +function findTypeScriptDiscountOfferType( + source: string +): { line: number; members: string[] | null } | null { + const blockRe = + /]*\blanguage="typescript"[^>]*>\s*\{`([\s\S]*?)`\}\s*<\/CodeBlock>/g; + let blockMatch: RegExpExecArray | null; + + while ((blockMatch = blockRe.exec(source)) !== null) { + const block = blockMatch[1]; + const declaration = + /(?:export\s+)?type\s+DiscountOfferType\s*=\s*([\s\S]*?);/.exec(block); + if (!declaration) continue; + + const rawMembers = declaration[1].split('|').map((member) => member.trim()); + const members: string[] = []; + for (const rawMember of rawMembers) { + const literal = /^(['"])([^'"]+)\1$/.exec(rawMember); + if (!literal) { + return { + line: lineNumberAt( + source, + blockMatch.index + blockMatch[0].indexOf(block) + declaration.index + ), + members: null, + }; + } + members.push(literal[2]); + } + + return { + line: lineNumberAt( + source, + blockMatch.index + blockMatch[0].indexOf(block) + declaration.index + ), + members, + }; + } + + return null; +} + +type NamedOfferTypeLanguage = 'swift' | 'kotlin' | 'dart'; + +function findNamedDiscountOfferTypeMembers( + source: string, + language: NamedOfferTypeLanguage +): { line: number; members: string[] } | null { + const blockRe = new RegExp( + `]*\\blanguage="${language}"[^>]*>\\s*\\{\`([\\s\\S]*?)\`\\}\\s*
    `, + 'g' + ); + let blockMatch: RegExpExecArray | null; + + while ((blockMatch = blockRe.exec(source)) !== null) { + const block = blockMatch[1]; + const declaration = + language === 'swift' + ? /enum\s+DiscountOfferType[^{}]*\{([\s\S]*?)\}/.exec(block) + : language === 'kotlin' + ? /enum\s+class\s+DiscountOfferType(?:\([^)]*\))?\s*\{([\s\S]*?)\}/.exec( + block + ) + : /enum\s+DiscountOfferType\s*\{([\s\S]*?)\}/.exec(block); + if (!declaration) continue; + + const memberRe = + language === 'swift' + ? /\bcase\s+([A-Za-z]\w*)\s*=\s*"([^"]+)"/g + : language === 'kotlin' + ? /\b([A-Z]\w*)\s*\(\s*"([^"]+)"\s*\)/g + : /\b([A-Z]\w*)\s*\(\s*'([^']+)'\s*\)/g; + const members = Array.from(declaration[1].matchAll(memberRe), (match) => + `${match[1]}=${match[2]}` + ); + + return { + line: lineNumberAt( + source, + blockMatch.index + blockMatch[0].indexOf(block) + declaration.index + ), + members, + }; + } + + return null; +} + +/** + * Guard the semantics of the two canonical offer pages independently from the + * generated-field audit. `DiscountOffer` is the Android one-time product + * abstraction backed by `OneTimePurchaseOfferDetails`; subscription discounts + * belong to `SubscriptionOffer`. Both types share `DiscountOfferType`, so stale + * hand-written `WinBack` claims are particularly easy to reintroduce. + * + * Sources are injected to keep this check deterministic and fault-testable. + */ +export function auditCanonicalOfferDocs( + sources: CanonicalOfferDocsSources +): Drift[] { + const drifts: Drift[] = []; + const discount = sources.discountOffer; + const subscription = sources.subscriptionOffer; + + if (!/\bOneTimePurchaseOfferDetails\b/.test(discount.source)) { + drifts.push({ + file: discount.file, + line: 1, + rule: 'R12', + message: + 'DiscountOffer must reference the Android `ProductDetails.OneTimePurchaseOfferDetails` native source.', + }); + } + + const oneTimeAndroidClaim = + /\bone-time\b[\s\S]{0,240}\b(?:Android|Google Play)\b/i.test( + discount.source + ) || + /\b(?:Android|Google Play)\b[\s\S]{0,240}\bone-time\b/i.test( + discount.source + ); + if (!oneTimeAndroidClaim) { + drifts.push({ + file: discount.file, + line: 1, + rule: 'R12', + message: + 'DiscountOffer must state that it represents one-time product offers on Android/Google Play.', + }); + } + + const typeScriptOfferType = findTypeScriptDiscountOfferType(discount.source); + const expectedOfferTypeMembers = ['introductory', 'promotional', 'one-time']; + const actualOfferTypeMembers = typeScriptOfferType?.members; + const hasExactOfferTypeMembers = + actualOfferTypeMembers !== null && + actualOfferTypeMembers !== undefined && + actualOfferTypeMembers.length === expectedOfferTypeMembers.length && + expectedOfferTypeMembers.every((member) => + actualOfferTypeMembers.includes(member) + ); + if (!hasExactOfferTypeMembers) { + drifts.push({ + file: discount.file, + line: typeScriptOfferType?.line ?? 1, + rule: 'R12', + message: + "The canonical DiscountOffer TypeScript snippet must declare DiscountOfferType with exactly the generated wire values 'introductory', 'promotional', and 'one-time'.", + }); + } + + for (const [language, expectedMembers] of [ + [ + 'swift', + [ + 'introductory=introductory', + 'promotional=promotional', + 'oneTime=one-time', + ], + ], + [ + 'kotlin', + [ + 'Introductory=introductory', + 'Promotional=promotional', + 'OneTime=one-time', + ], + ], + [ + 'dart', + [ + 'Introductory=introductory', + 'Promotional=promotional', + 'OneTime=one-time', + ], + ], + ] as const) { + const declaration = findNamedDiscountOfferTypeMembers( + discount.source, + language + ); + const actualMembers = declaration?.members; + const hasExactMembers = + actualMembers !== undefined && + actualMembers.length === expectedMembers.length && + expectedMembers.every((member) => actualMembers.includes(member)); + if (hasExactMembers) continue; + + drifts.push({ + file: discount.file, + line: declaration?.line ?? 1, + rule: 'R12', + message: `The canonical DiscountOffer ${language} snippet must declare exactly the generated DiscountOfferType members and wire values.`, + }); + } + + const forbiddenDiscountClaims: { + pattern: RegExp; + message: string; + }[] = [ + { + pattern: /\bProduct\.SubscriptionOffer\b/, + message: + 'DiscountOffer must not map to StoreKit Product.SubscriptionOffer; use the canonical SubscriptionOffer page for subscription discounts.', + }, + { + pattern: /\b(?:ProductDetails\.)?SubscriptionOfferDetails\b/, + message: + 'DiscountOffer must not map to Play SubscriptionOfferDetails; it represents one-time product offers.', + }, + { + pattern: /\bWinBack\b/i, + message: + 'DiscountOffer must not claim WinBack support; WinBack is not a DiscountOfferType enum member.', + }, + ]; + + for (const claim of forbiddenDiscountClaims) { + const match = claim.pattern.exec(discount.source); + if (!match) continue; + drifts.push({ + file: discount.file, + line: lineNumberAt(discount.source, match.index), + rule: 'R12', + message: claim.message, + }); + } + + const subscriptionWinBack = /\bWinBack\b/i.exec(subscription.source); + if (subscriptionWinBack) { + drifts.push({ + file: subscription.file, + line: lineNumberAt(subscription.source, subscriptionWinBack.index), + rule: 'R12', + message: + 'SubscriptionOffer must not claim WinBack support; WinBack is not a DiscountOfferType enum member.', + }); + } + + for (const [pattern, nativeType] of [ + [/\bProduct\.SubscriptionOffer\b/, 'Product.SubscriptionOffer'], + [ + /\b(?:ProductDetails\.)?SubscriptionOfferDetails\b/, + 'ProductDetails.SubscriptionOfferDetails', + ], + ] as const) { + if (pattern.test(subscription.source)) continue; + drifts.push({ + file: subscription.file, + line: 1, + rule: 'R12', + message: `SubscriptionOffer must reference its native ${nativeType} source.`, + }); + } + + for (const [title, expectedPath] of [ + ['DiscountOffer', '/docs/types/discount-offer'], + ['SubscriptionOffer', '/docs/types/subscription-offer'], + ] as const) { + const entries = findSearchEntriesByTitle(sources.searchData.source, title); + if (entries.length === 0) { + drifts.push({ + file: sources.searchData.file, + line: 1, + rule: 'R12', + message: `Search data must include a canonical ${title} entry pointing to ${expectedPath}.`, + }); + continue; + } + + if (entries.length > 1) { + drifts.push({ + file: sources.searchData.file, + line: entries[1].line, + rule: 'R12', + message: `Search data must contain exactly one canonical ${title} entry.`, + }); + } + + for (const entry of entries) { + if (entry.path === expectedPath) continue; + drifts.push({ + file: sources.searchData.file, + line: entry.pathLine, + rule: 'R12', + message: `The canonical ${title} search entry must point to ${expectedPath}, not ${entry.path ?? 'a missing path'}.`, + }); + } + } + + return drifts; +} + async function walkTsxFiles(root: string): Promise { const out: string[] = []; async function recurse(dir: string) { @@ -914,6 +1277,22 @@ async function main() { drifts.push(...auditReleaseNotePackageLinks(RELEASE_NOTES_FILE)); drifts.push(...auditVersionMetadata()); + drifts.push( + ...auditCanonicalOfferDocs({ + discountOffer: { + file: DISCOUNT_OFFER_DOC_FILE, + source: readFileSync(DISCOUNT_OFFER_DOC_FILE, 'utf8'), + }, + subscriptionOffer: { + file: SUBSCRIPTION_OFFER_DOC_FILE, + source: readFileSync(SUBSCRIPTION_OFFER_DOC_FILE, 'utf8'), + }, + searchData: { + file: SEARCH_DATA_FILE, + source: readFileSync(SEARCH_DATA_FILE, 'utf8'), + }, + }) + ); // R5 (broken /docs links) is a hard failure; R3 (field name not in // generated types) is a warning because top-level scalar function From e388d6adfcdf8c013ab7494c280a74b74e0a67e1 Mon Sep 17 00:00:00 2001 From: Hyo Date: Fri, 24 Jul 2026 01:16:43 +0900 Subject: [PATCH 2/9] fix(docs): address offer review feedback --- libraries/expo-iap/src/types.ts | 10 +--- .../flutter_inapp_purchase/lib/types.dart | 2 +- libraries/godot-iap/addons/godot-iap/types.gd | 2 +- .../io/github/hyochan/kmpiap/openiap/Types.kt | 2 +- libraries/maui-iap/src/OpenIap.Maui/Types.cs | 2 +- libraries/react-native-iap/src/types.ts | 10 +--- packages/apple/Sources/Models/Types.swift | 2 +- .../src/main/java/dev/hyo/openiap/Types.kt | 2 +- packages/gql/scripts/fix-generated-types.mjs | 16 +++++++ .../gql/src/generated-compatibility.test.ts | 30 ++++++++++++ packages/gql/src/generated/Types.cs | 2 +- packages/gql/src/generated/Types.kt | 2 +- packages/gql/src/generated/Types.swift | 2 +- packages/gql/src/generated/types.dart | 2 +- packages/gql/src/generated/types.gd | 2 +- packages/gql/src/generated/types.ts | 10 +--- packages/gql/src/type.graphql | 2 +- scripts/audit-docs.test.ts | 19 ++++++++ scripts/audit-docs.ts | 47 ++++++++++++++----- 19 files changed, 115 insertions(+), 51 deletions(-) diff --git a/libraries/expo-iap/src/types.ts b/libraries/expo-iap/src/types.ts index 5866e2db8..a9e259cf7 100644 --- a/libraries/expo-iap/src/types.ts +++ b/libraries/expo-iap/src/types.ts @@ -349,7 +349,7 @@ export interface DiscountIOS { * Currently populated only on Android (Google Play Billing 8.0+). * iOS does not populate this type. * - * @see https://openiap.dev/docs/features/discount + * @see https://openiap.dev/docs/types/discount-offer */ export interface DiscountOffer { /** Currency code (ISO 4217, e.g., "USD") */ @@ -896,7 +896,6 @@ export interface Mutation { * then call requestPurchase with that SKU instead. In StoreKit 2, * promoted products can be purchased directly via the standard purchase flow. * See: https://openiap.dev/docs/apis/ios/request-purchase-on-promoted-product-ios - * @deprecated Use promotedProductListenerIOS + requestPurchase instead */ requestPurchaseOnPromotedProductIOS: Promise; /** @@ -1107,7 +1106,6 @@ export interface ProductAndroid extends ProductCommon { * One-time purchase offer details including discounts (Android) * Returns all eligible offers. Available in Google Play Billing Library 8.0+ * @deprecated Use the standardized discountOffers field instead. - * @deprecated Use discountOffers instead */ oneTimePurchaseOfferDetailsAndroid?: (ProductAndroidOneTimePurchaseOfferDetail[] | null); platform: 'android'; @@ -1122,7 +1120,6 @@ export interface ProductAndroid extends ProductCommon { productStatusAndroid?: (ProductStatusAndroid | null); /** * @deprecated Use subscriptionOffers instead for cross-platform compatibility. - * @deprecated Use subscriptionOffers instead */ subscriptionOfferDetailsAndroid?: (ProductSubscriptionAndroidOfferDetails[] | null); /** @@ -1212,7 +1209,6 @@ export interface ProductIOS extends ProductCommon { pricingTermsIOS?: (SubscriptionPricingTermsIOS[] | null); /** * @deprecated Use subscriptionOffers instead for cross-platform compatibility. - * @deprecated Use subscriptionOffers instead */ subscriptionInfoIOS?: (SubscriptionInfoIOS | null); /** @@ -1264,7 +1260,6 @@ export interface ProductSubscriptionAndroid extends ProductCommon { * purchase offer details for subscription products. * @deprecated One-time offers belong to ProductAndroid.discountOffers; * subscriptions use subscriptionOffers. - * @deprecated Use subscriptionOffers instead */ oneTimePurchaseOfferDetailsAndroid?: (ProductAndroidOneTimePurchaseOfferDetail[] | null); platform: 'android'; @@ -1279,7 +1274,6 @@ export interface ProductSubscriptionAndroid extends ProductCommon { productStatusAndroid?: (ProductStatusAndroid | null); /** * @deprecated Use subscriptionOffers instead for cross-platform compatibility. - * @deprecated Use subscriptionOffers instead */ subscriptionOfferDetailsAndroid: ProductSubscriptionAndroidOfferDetails[]; /** @@ -1317,7 +1311,6 @@ export interface ProductSubscriptionIOS extends ProductCommon { description: string; /** * @deprecated Use subscriptionOffers instead for cross-platform compatibility. - * @deprecated Use subscriptionOffers instead */ discountsIOS?: (DiscountIOS[] | null); displayName?: (string | null); @@ -1342,7 +1335,6 @@ export interface ProductSubscriptionIOS extends ProductCommon { subscriptionGroupIdIOS?: (string | null); /** * @deprecated Use subscriptionOffers for offer metadata and subscriptionGroupIdIOS for the App Store subscription group identifier. - * @deprecated Use subscriptionOffers for offers and subscriptionGroupIdIOS for group ID */ subscriptionInfoIOS?: (SubscriptionInfoIOS | null); /** diff --git a/libraries/flutter_inapp_purchase/lib/types.dart b/libraries/flutter_inapp_purchase/lib/types.dart index 0dffa88a4..6ec1d075f 100644 --- a/libraries/flutter_inapp_purchase/lib/types.dart +++ b/libraries/flutter_inapp_purchase/lib/types.dart @@ -2039,7 +2039,7 @@ class DiscountIOS { /// Currently populated only on Android (Google Play Billing 8.0+). /// iOS does not populate this type. /// -/// @see https://openiap.dev/docs/features/discount +/// @see https://openiap.dev/docs/types/discount-offer class DiscountOffer { const DiscountOffer({ required this.currency, diff --git a/libraries/godot-iap/addons/godot-iap/types.gd b/libraries/godot-iap/addons/godot-iap/types.gd index 17db2f5a1..e61c34f63 100644 --- a/libraries/godot-iap/addons/godot-iap/types.gd +++ b/libraries/godot-iap/addons/godot-iap/types.gd @@ -1056,7 +1056,7 @@ class DiscountIOS: dict["localizedPrice"] = localized_price return dict -## Standardized one-time product discount offer. Provides a platform-neutral OpenIAP shape for Google Play one-time product purchase options and offers. Currently populated only on Android (Google Play Billing 8.0+). iOS does not populate this type. @see https://openiap.dev/docs/features/discount +## Standardized one-time product discount offer. Provides a platform-neutral OpenIAP shape for Google Play one-time product purchase options and offers. Currently populated only on Android (Google Play Billing 8.0+). iOS does not populate this type. @see https://openiap.dev/docs/types/discount-offer class DiscountOffer: ## Unique identifier for the offer. var id: Variant = null diff --git a/libraries/kmp-iap/library/src/commonMain/kotlin/io/github/hyochan/kmpiap/openiap/Types.kt b/libraries/kmp-iap/library/src/commonMain/kotlin/io/github/hyochan/kmpiap/openiap/Types.kt index a501815bb..daceea9b6 100644 --- a/libraries/kmp-iap/library/src/commonMain/kotlin/io/github/hyochan/kmpiap/openiap/Types.kt +++ b/libraries/kmp-iap/library/src/commonMain/kotlin/io/github/hyochan/kmpiap/openiap/Types.kt @@ -2254,7 +2254,7 @@ public data class DiscountIOS( * Currently populated only on Android (Google Play Billing 8.0+). * iOS does not populate this type. * - * @see https://openiap.dev/docs/features/discount + * @see https://openiap.dev/docs/types/discount-offer */ public data class DiscountOffer( /** diff --git a/libraries/maui-iap/src/OpenIap.Maui/Types.cs b/libraries/maui-iap/src/OpenIap.Maui/Types.cs index 1da78ba5c..b3b1db753 100644 --- a/libraries/maui-iap/src/OpenIap.Maui/Types.cs +++ b/libraries/maui-iap/src/OpenIap.Maui/Types.cs @@ -2972,7 +2972,7 @@ public sealed record DiscountIOS /// Currently populated only on Android (Google Play Billing 8.0+). /// iOS does not populate this type. /// -/// @see https://openiap.dev/docs/features/discount +/// @see https://openiap.dev/docs/types/discount-offer public sealed record DiscountOffer { /// Currency code (ISO 4217, e.g., "USD") diff --git a/libraries/react-native-iap/src/types.ts b/libraries/react-native-iap/src/types.ts index 5866e2db8..a9e259cf7 100644 --- a/libraries/react-native-iap/src/types.ts +++ b/libraries/react-native-iap/src/types.ts @@ -349,7 +349,7 @@ export interface DiscountIOS { * Currently populated only on Android (Google Play Billing 8.0+). * iOS does not populate this type. * - * @see https://openiap.dev/docs/features/discount + * @see https://openiap.dev/docs/types/discount-offer */ export interface DiscountOffer { /** Currency code (ISO 4217, e.g., "USD") */ @@ -896,7 +896,6 @@ export interface Mutation { * then call requestPurchase with that SKU instead. In StoreKit 2, * promoted products can be purchased directly via the standard purchase flow. * See: https://openiap.dev/docs/apis/ios/request-purchase-on-promoted-product-ios - * @deprecated Use promotedProductListenerIOS + requestPurchase instead */ requestPurchaseOnPromotedProductIOS: Promise; /** @@ -1107,7 +1106,6 @@ export interface ProductAndroid extends ProductCommon { * One-time purchase offer details including discounts (Android) * Returns all eligible offers. Available in Google Play Billing Library 8.0+ * @deprecated Use the standardized discountOffers field instead. - * @deprecated Use discountOffers instead */ oneTimePurchaseOfferDetailsAndroid?: (ProductAndroidOneTimePurchaseOfferDetail[] | null); platform: 'android'; @@ -1122,7 +1120,6 @@ export interface ProductAndroid extends ProductCommon { productStatusAndroid?: (ProductStatusAndroid | null); /** * @deprecated Use subscriptionOffers instead for cross-platform compatibility. - * @deprecated Use subscriptionOffers instead */ subscriptionOfferDetailsAndroid?: (ProductSubscriptionAndroidOfferDetails[] | null); /** @@ -1212,7 +1209,6 @@ export interface ProductIOS extends ProductCommon { pricingTermsIOS?: (SubscriptionPricingTermsIOS[] | null); /** * @deprecated Use subscriptionOffers instead for cross-platform compatibility. - * @deprecated Use subscriptionOffers instead */ subscriptionInfoIOS?: (SubscriptionInfoIOS | null); /** @@ -1264,7 +1260,6 @@ export interface ProductSubscriptionAndroid extends ProductCommon { * purchase offer details for subscription products. * @deprecated One-time offers belong to ProductAndroid.discountOffers; * subscriptions use subscriptionOffers. - * @deprecated Use subscriptionOffers instead */ oneTimePurchaseOfferDetailsAndroid?: (ProductAndroidOneTimePurchaseOfferDetail[] | null); platform: 'android'; @@ -1279,7 +1274,6 @@ export interface ProductSubscriptionAndroid extends ProductCommon { productStatusAndroid?: (ProductStatusAndroid | null); /** * @deprecated Use subscriptionOffers instead for cross-platform compatibility. - * @deprecated Use subscriptionOffers instead */ subscriptionOfferDetailsAndroid: ProductSubscriptionAndroidOfferDetails[]; /** @@ -1317,7 +1311,6 @@ export interface ProductSubscriptionIOS extends ProductCommon { description: string; /** * @deprecated Use subscriptionOffers instead for cross-platform compatibility. - * @deprecated Use subscriptionOffers instead */ discountsIOS?: (DiscountIOS[] | null); displayName?: (string | null); @@ -1342,7 +1335,6 @@ export interface ProductSubscriptionIOS extends ProductCommon { subscriptionGroupIdIOS?: (string | null); /** * @deprecated Use subscriptionOffers for offer metadata and subscriptionGroupIdIOS for the App Store subscription group identifier. - * @deprecated Use subscriptionOffers for offers and subscriptionGroupIdIOS for group ID */ subscriptionInfoIOS?: (SubscriptionInfoIOS | null); /** diff --git a/packages/apple/Sources/Models/Types.swift b/packages/apple/Sources/Models/Types.swift index a46c07cd5..9db6f0e54 100644 --- a/packages/apple/Sources/Models/Types.swift +++ b/packages/apple/Sources/Models/Types.swift @@ -857,7 +857,7 @@ public struct DiscountIOS: Codable { /// Currently populated only on Android (Google Play Billing 8.0+). /// iOS does not populate this type. /// -/// @see https://openiap.dev/docs/features/discount +/// @see https://openiap.dev/docs/types/discount-offer public struct DiscountOffer: Codable { /// Currency code (ISO 4217, e.g., "USD") public var currency: String diff --git a/packages/google/openiap/src/main/java/dev/hyo/openiap/Types.kt b/packages/google/openiap/src/main/java/dev/hyo/openiap/Types.kt index 8a910f5b0..7353d22a2 100644 --- a/packages/google/openiap/src/main/java/dev/hyo/openiap/Types.kt +++ b/packages/google/openiap/src/main/java/dev/hyo/openiap/Types.kt @@ -2126,7 +2126,7 @@ public data class DiscountIOS( * Currently populated only on Android (Google Play Billing 8.0+). * iOS does not populate this type. * - * @see https://openiap.dev/docs/features/discount + * @see https://openiap.dev/docs/types/discount-offer */ public data class DiscountOffer( /** diff --git a/packages/gql/scripts/fix-generated-types.mjs b/packages/gql/scripts/fix-generated-types.mjs index adafa4ba5..b23a8cdd3 100644 --- a/packages/gql/scripts/fix-generated-types.mjs +++ b/packages/gql/scripts/fix-generated-types.mjs @@ -708,4 +708,20 @@ if (helperBlocks.length > 0) { content += helperBlocks.join('\n'); } +// graphql-codegen appends the directive reason after the schema description. +// When the description already carries a richer @deprecated explanation for +// the non-TypeScript generators, keep that first tag and drop later duplicates. +content = content.replace(/\/\*\*[\s\S]*?\*\//g, (block) => { + let hasDeprecatedTag = false; + return block + .split('\n') + .filter((line) => { + if (!/@deprecated\b/.test(line)) return true; + if (hasDeprecatedTag) return false; + hasDeprecatedTag = true; + return true; + }) + .join('\n'); +}); + writeFileSync(targetPath, content); diff --git a/packages/gql/src/generated-compatibility.test.ts b/packages/gql/src/generated-compatibility.test.ts index 0de6990e8..c1fcd1546 100644 --- a/packages/gql/src/generated-compatibility.test.ts +++ b/packages/gql/src/generated-compatibility.test.ts @@ -6,6 +6,36 @@ function generated(name: string): string { } describe("generated compatibility", () => { + it("keeps the canonical DiscountOffer type reference in the schema", () => { + const schema = readFileSync( + new URL("./type.graphql", import.meta.url), + "utf8", + ); + const typeIndex = schema.indexOf("type DiscountOffer {"); + const descriptionEnd = schema.lastIndexOf('"""', typeIndex); + const descriptionStart = schema.lastIndexOf('"""', descriptionEnd - 1); + const description = schema.slice(descriptionStart, descriptionEnd); + + expect(description).toContain( + "@see https://openiap.dev/docs/types/discount-offer", + ); + expect(description).not.toContain( + "@see https://openiap.dev/docs/features/discount", + ); + }); + + it("emits one deprecation tag per TypeScript doc block", () => { + const typescript = generated("types.ts"); + const duplicateBlocks = ( + typescript.match(/\/\*\*[\s\S]*?\*\//g) ?? [] + ).filter((block) => (block.match(/@deprecated\b/g) ?? []).length > 1); + + expect(duplicateBlocks).toEqual([]); + expect(typescript).toContain( + "@deprecated One-time offers belong to ProductAndroid.discountOffers;", + ); + }); + it("preserves the published MAUI 1.x string signatures", () => { const csharp = generated("Types.cs"); diff --git a/packages/gql/src/generated/Types.cs b/packages/gql/src/generated/Types.cs index 1da78ba5c..b3b1db753 100644 --- a/packages/gql/src/generated/Types.cs +++ b/packages/gql/src/generated/Types.cs @@ -2972,7 +2972,7 @@ public sealed record DiscountIOS /// Currently populated only on Android (Google Play Billing 8.0+). /// iOS does not populate this type. /// -/// @see https://openiap.dev/docs/features/discount +/// @see https://openiap.dev/docs/types/discount-offer public sealed record DiscountOffer { /// Currency code (ISO 4217, e.g., "USD") diff --git a/packages/gql/src/generated/Types.kt b/packages/gql/src/generated/Types.kt index a222be9b9..6c94d8e94 100644 --- a/packages/gql/src/generated/Types.kt +++ b/packages/gql/src/generated/Types.kt @@ -2252,7 +2252,7 @@ public data class DiscountIOS( * Currently populated only on Android (Google Play Billing 8.0+). * iOS does not populate this type. * - * @see https://openiap.dev/docs/features/discount + * @see https://openiap.dev/docs/types/discount-offer */ public data class DiscountOffer( /** diff --git a/packages/gql/src/generated/Types.swift b/packages/gql/src/generated/Types.swift index a46c07cd5..9db6f0e54 100644 --- a/packages/gql/src/generated/Types.swift +++ b/packages/gql/src/generated/Types.swift @@ -857,7 +857,7 @@ public struct DiscountIOS: Codable { /// Currently populated only on Android (Google Play Billing 8.0+). /// iOS does not populate this type. /// -/// @see https://openiap.dev/docs/features/discount +/// @see https://openiap.dev/docs/types/discount-offer public struct DiscountOffer: Codable { /// Currency code (ISO 4217, e.g., "USD") public var currency: String diff --git a/packages/gql/src/generated/types.dart b/packages/gql/src/generated/types.dart index 0dffa88a4..6ec1d075f 100644 --- a/packages/gql/src/generated/types.dart +++ b/packages/gql/src/generated/types.dart @@ -2039,7 +2039,7 @@ class DiscountIOS { /// Currently populated only on Android (Google Play Billing 8.0+). /// iOS does not populate this type. /// -/// @see https://openiap.dev/docs/features/discount +/// @see https://openiap.dev/docs/types/discount-offer class DiscountOffer { const DiscountOffer({ required this.currency, diff --git a/packages/gql/src/generated/types.gd b/packages/gql/src/generated/types.gd index 17db2f5a1..e61c34f63 100644 --- a/packages/gql/src/generated/types.gd +++ b/packages/gql/src/generated/types.gd @@ -1056,7 +1056,7 @@ class DiscountIOS: dict["localizedPrice"] = localized_price return dict -## Standardized one-time product discount offer. Provides a platform-neutral OpenIAP shape for Google Play one-time product purchase options and offers. Currently populated only on Android (Google Play Billing 8.0+). iOS does not populate this type. @see https://openiap.dev/docs/features/discount +## Standardized one-time product discount offer. Provides a platform-neutral OpenIAP shape for Google Play one-time product purchase options and offers. Currently populated only on Android (Google Play Billing 8.0+). iOS does not populate this type. @see https://openiap.dev/docs/types/discount-offer class DiscountOffer: ## Unique identifier for the offer. var id: Variant = null diff --git a/packages/gql/src/generated/types.ts b/packages/gql/src/generated/types.ts index 5866e2db8..a9e259cf7 100644 --- a/packages/gql/src/generated/types.ts +++ b/packages/gql/src/generated/types.ts @@ -349,7 +349,7 @@ export interface DiscountIOS { * Currently populated only on Android (Google Play Billing 8.0+). * iOS does not populate this type. * - * @see https://openiap.dev/docs/features/discount + * @see https://openiap.dev/docs/types/discount-offer */ export interface DiscountOffer { /** Currency code (ISO 4217, e.g., "USD") */ @@ -896,7 +896,6 @@ export interface Mutation { * then call requestPurchase with that SKU instead. In StoreKit 2, * promoted products can be purchased directly via the standard purchase flow. * See: https://openiap.dev/docs/apis/ios/request-purchase-on-promoted-product-ios - * @deprecated Use promotedProductListenerIOS + requestPurchase instead */ requestPurchaseOnPromotedProductIOS: Promise; /** @@ -1107,7 +1106,6 @@ export interface ProductAndroid extends ProductCommon { * One-time purchase offer details including discounts (Android) * Returns all eligible offers. Available in Google Play Billing Library 8.0+ * @deprecated Use the standardized discountOffers field instead. - * @deprecated Use discountOffers instead */ oneTimePurchaseOfferDetailsAndroid?: (ProductAndroidOneTimePurchaseOfferDetail[] | null); platform: 'android'; @@ -1122,7 +1120,6 @@ export interface ProductAndroid extends ProductCommon { productStatusAndroid?: (ProductStatusAndroid | null); /** * @deprecated Use subscriptionOffers instead for cross-platform compatibility. - * @deprecated Use subscriptionOffers instead */ subscriptionOfferDetailsAndroid?: (ProductSubscriptionAndroidOfferDetails[] | null); /** @@ -1212,7 +1209,6 @@ export interface ProductIOS extends ProductCommon { pricingTermsIOS?: (SubscriptionPricingTermsIOS[] | null); /** * @deprecated Use subscriptionOffers instead for cross-platform compatibility. - * @deprecated Use subscriptionOffers instead */ subscriptionInfoIOS?: (SubscriptionInfoIOS | null); /** @@ -1264,7 +1260,6 @@ export interface ProductSubscriptionAndroid extends ProductCommon { * purchase offer details for subscription products. * @deprecated One-time offers belong to ProductAndroid.discountOffers; * subscriptions use subscriptionOffers. - * @deprecated Use subscriptionOffers instead */ oneTimePurchaseOfferDetailsAndroid?: (ProductAndroidOneTimePurchaseOfferDetail[] | null); platform: 'android'; @@ -1279,7 +1274,6 @@ export interface ProductSubscriptionAndroid extends ProductCommon { productStatusAndroid?: (ProductStatusAndroid | null); /** * @deprecated Use subscriptionOffers instead for cross-platform compatibility. - * @deprecated Use subscriptionOffers instead */ subscriptionOfferDetailsAndroid: ProductSubscriptionAndroidOfferDetails[]; /** @@ -1317,7 +1311,6 @@ export interface ProductSubscriptionIOS extends ProductCommon { description: string; /** * @deprecated Use subscriptionOffers instead for cross-platform compatibility. - * @deprecated Use subscriptionOffers instead */ discountsIOS?: (DiscountIOS[] | null); displayName?: (string | null); @@ -1342,7 +1335,6 @@ export interface ProductSubscriptionIOS extends ProductCommon { subscriptionGroupIdIOS?: (string | null); /** * @deprecated Use subscriptionOffers for offer metadata and subscriptionGroupIdIOS for the App Store subscription group identifier. - * @deprecated Use subscriptionOffers for offers and subscriptionGroupIdIOS for group ID */ subscriptionInfoIOS?: (SubscriptionInfoIOS | null); /** diff --git a/packages/gql/src/type.graphql b/packages/gql/src/type.graphql index 46b2b22ff..ae3dd6fed 100644 --- a/packages/gql/src/type.graphql +++ b/packages/gql/src/type.graphql @@ -608,7 +608,7 @@ purchase options and offers. Currently populated only on Android (Google Play Billing 8.0+). iOS does not populate this type. -@see https://openiap.dev/docs/features/discount +@see https://openiap.dev/docs/types/discount-offer """ type DiscountOffer { """ diff --git a/scripts/audit-docs.test.ts b/scripts/audit-docs.test.ts index 0f865f522..96798f48e 100644 --- a/scripts/audit-docs.test.ts +++ b/scripts/audit-docs.test.ts @@ -366,4 +366,23 @@ ${discountOffer}`, }), ]); }); + + test('finds canonical search paths across indentation and nested formatting', () => { + const reformattedSearchData = `export const apiData = [ +\t{ +\t\tmetadata: { +\t\t\tpath: '/internal/discount-offer-metadata', +\t\t}, +\t\ttitle: 'DiscountOffer', +\t\tpath: '/docs/types/discount-offer', +\t}, + { metadata: { path: '/internal/subscription-offer-metadata' }, title: 'SubscriptionOffer', path: '/docs/types/subscription-offer' }, +];`; + + expect( + auditCanonicalOfferDocs( + validOfferDocsSources({ searchData: reformattedSearchData }) + ) + ).toEqual([]); + }); }); diff --git a/scripts/audit-docs.ts b/scripts/audit-docs.ts index 6189a1810..7996a912f 100644 --- a/scripts/audit-docs.ts +++ b/scripts/audit-docs.ts @@ -202,6 +202,36 @@ function escapeRegExp(value: string): string { return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); } +function findEnclosingBraceBlock( + source: string, + index: number +): { body: string; bodyStart: number } | null { + let openBraceIndex = source.lastIndexOf('{', index); + while (openBraceIndex !== -1) { + const body = extractBraceBlock(source, openBraceIndex); + if (body !== null) { + const closeBraceIndex = openBraceIndex + body.length + 1; + if (index <= closeBraceIndex) { + return { body, bodyStart: openBraceIndex + 1 }; + } + } + openBraceIndex = source.lastIndexOf('{', openBraceIndex - 1); + } + return null; +} + +function findTopLevelSearchPath( + objectBody: string +): { path: string; index: number } | null { + const pathRe = /\bpath:\s*(['"])([^'"]+)\1/g; + let pathMatch: RegExpExecArray | null; + while ((pathMatch = pathRe.exec(objectBody)) !== null) { + if (findEnclosingBraceBlock(objectBody, pathMatch.index)) continue; + return { path: pathMatch[2], index: pathMatch.index }; + } + return null; +} + function findSearchEntriesByTitle( source: string, title: string @@ -214,21 +244,14 @@ function findSearchEntriesByTitle( let titleMatch: RegExpExecArray | null; while ((titleMatch = titleRe.exec(source)) !== null) { - const objectStartMarker = source.lastIndexOf('\n {', titleMatch.index); - const objectStart = objectStartMarker === -1 ? 0 : objectStartMarker + 1; - const objectEndMarker = source.indexOf('\n },', titleMatch.index); - const objectEnd = - objectEndMarker === -1 - ? source.length - : objectEndMarker + '\n },'.length; - const objectSource = source.slice(objectStart, objectEnd); - const pathMatch = /\bpath:\s*(['"])([^'"]+)\1/.exec(objectSource); + const object = findEnclosingBraceBlock(source, titleMatch.index); + const pathMatch = object ? findTopLevelSearchPath(object.body) : null; entries.push({ line: lineNumberAt(source, titleMatch.index), - path: pathMatch?.[2] ?? null, - pathLine: pathMatch - ? lineNumberAt(source, objectStart + pathMatch.index) + path: pathMatch?.path ?? null, + pathLine: object && pathMatch + ? lineNumberAt(source, object.bodyStart + pathMatch.index) : lineNumberAt(source, titleMatch.index), }); } From b04653c089079febf2030d995923d75060219e47 Mon Sep 17 00:00:00 2001 From: Hyo Date: Fri, 24 Jul 2026 01:22:23 +0900 Subject: [PATCH 3/9] fix(docs): harden offer audit guards --- packages/gql/scripts/fix-generated-types.mjs | 17 +------ .../gql/scripts/generated-doc-comments.mjs | 25 ++++++++++ .../gql/src/generated-compatibility.test.ts | 5 +- .../gql/src/generated-doc-comments.test.mjs | 23 +++++++++ scripts/audit-docs.test.ts | 47 +++++++++++++++++++ scripts/audit-docs.ts | 47 +++++++++++++++++++ 6 files changed, 148 insertions(+), 16 deletions(-) create mode 100644 packages/gql/scripts/generated-doc-comments.mjs create mode 100644 packages/gql/src/generated-doc-comments.test.mjs diff --git a/packages/gql/scripts/fix-generated-types.mjs b/packages/gql/scripts/fix-generated-types.mjs index b23a8cdd3..d41a44d16 100644 --- a/packages/gql/scripts/fix-generated-types.mjs +++ b/packages/gql/scripts/fix-generated-types.mjs @@ -2,6 +2,7 @@ import { readFileSync, writeFileSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; import { dirname, resolve } from 'node:path'; import { parse } from 'graphql'; +import { dedupeDeprecatedJSDocTags } from './generated-doc-comments.mjs'; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); @@ -708,20 +709,6 @@ if (helperBlocks.length > 0) { content += helperBlocks.join('\n'); } -// graphql-codegen appends the directive reason after the schema description. -// When the description already carries a richer @deprecated explanation for -// the non-TypeScript generators, keep that first tag and drop later duplicates. -content = content.replace(/\/\*\*[\s\S]*?\*\//g, (block) => { - let hasDeprecatedTag = false; - return block - .split('\n') - .filter((line) => { - if (!/@deprecated\b/.test(line)) return true; - if (hasDeprecatedTag) return false; - hasDeprecatedTag = true; - return true; - }) - .join('\n'); -}); +content = dedupeDeprecatedJSDocTags(content); writeFileSync(targetPath, content); diff --git a/packages/gql/scripts/generated-doc-comments.mjs b/packages/gql/scripts/generated-doc-comments.mjs new file mode 100644 index 000000000..c71a0d7c2 --- /dev/null +++ b/packages/gql/scripts/generated-doc-comments.mjs @@ -0,0 +1,25 @@ +const JSDOC_BLOCK = /\/\*\*[\s\S]*?\*\//g; +const DEPRECATED_TAG_LINE = /^\s*(?:\/\*\*|\*)\s*@deprecated\b/; + +/** + * Keep the first real `@deprecated` JSDoc tag in each block. + * + * GraphQL codegen appends the directive reason after the schema description. + * Descriptions also carry richer deprecation guidance for non-TypeScript + * generators, so TypeScript can receive two tags. Prose that merely mentions + * `@deprecated` must remain untouched. + */ +export function dedupeDeprecatedJSDocTags(source) { + return source.replace(JSDOC_BLOCK, (block) => { + let hasDeprecatedTag = false; + return block + .split('\n') + .filter((line) => { + if (!DEPRECATED_TAG_LINE.test(line)) return true; + if (hasDeprecatedTag) return false; + hasDeprecatedTag = true; + return true; + }) + .join('\n'); + }); +} diff --git a/packages/gql/src/generated-compatibility.test.ts b/packages/gql/src/generated-compatibility.test.ts index c1fcd1546..8d1a04cc9 100644 --- a/packages/gql/src/generated-compatibility.test.ts +++ b/packages/gql/src/generated-compatibility.test.ts @@ -28,7 +28,10 @@ describe("generated compatibility", () => { const typescript = generated("types.ts"); const duplicateBlocks = ( typescript.match(/\/\*\*[\s\S]*?\*\//g) ?? [] - ).filter((block) => (block.match(/@deprecated\b/g) ?? []).length > 1); + ).filter( + (block) => + (block.match(/^\s*(?:\/\*\*|\*)\s*@deprecated\b/gm) ?? []).length > 1, + ); expect(duplicateBlocks).toEqual([]); expect(typescript).toContain( diff --git a/packages/gql/src/generated-doc-comments.test.mjs b/packages/gql/src/generated-doc-comments.test.mjs new file mode 100644 index 000000000..f68a4e630 --- /dev/null +++ b/packages/gql/src/generated-doc-comments.test.mjs @@ -0,0 +1,23 @@ +import { describe, expect, it } from 'vitest'; +import { dedupeDeprecatedJSDocTags } from '../scripts/generated-doc-comments.mjs'; + +describe('generated TypeScript documentation comments', () => { + it('deduplicates tag lines without treating prose mentions as tags', () => { + const source = `/** + * Explains the @deprecated directive in prose. + * @deprecated Keep the detailed reason. + * @deprecated Drop the shorter duplicate. + */ +export interface Legacy {} + +/** @deprecated Keep this single-line tag. */ +export interface OtherLegacy {}`; + + const result = dedupeDeprecatedJSDocTags(source); + + expect(result).toContain('Explains the @deprecated directive in prose.'); + expect(result).toContain('@deprecated Keep the detailed reason.'); + expect(result).not.toContain('@deprecated Drop the shorter duplicate.'); + expect(result).toContain('/** @deprecated Keep this single-line tag. */'); + }); +}); diff --git a/scripts/audit-docs.test.ts b/scripts/audit-docs.test.ts index 96798f48e..9d0cb37d9 100644 --- a/scripts/audit-docs.test.ts +++ b/scripts/audit-docs.test.ts @@ -385,4 +385,51 @@ ${discountOffer}`, ) ).toEqual([]); }); + + test('ignores braces inside search strings and comments', () => { + const edgeCases = [ + `export const apiData = [ + { + title: 'DiscountOffer', + description: 'Placeholder {value', + path: '/docs/types/discount-offer', + }, + { + title: 'SubscriptionOffer', + description: "Quoted } delimiter", + path: '/docs/types/subscription-offer', + }, +];`, + `export const apiData = [ + { + title: 'DiscountOffer', + // Ignore an unmatched { + path: '/docs/types/discount-offer', + }, + { + title: 'SubscriptionOffer', + // Ignore an unmatched } + path: '/docs/types/subscription-offer', + }, +];`, + `export const apiData = [ + { + title: 'DiscountOffer', + /* Ignore an unmatched { */ + path: '/docs/types/discount-offer', + }, + { + title: 'SubscriptionOffer', + /* Ignore an unmatched } */ + path: '/docs/types/subscription-offer', + }, +];`, + ]; + + for (const searchData of edgeCases) { + expect( + auditCanonicalOfferDocs(validOfferDocsSources({ searchData })) + ).toEqual([]); + } + }); }); diff --git a/scripts/audit-docs.ts b/scripts/audit-docs.ts index 7996a912f..25364590e 100644 --- a/scripts/audit-docs.ts +++ b/scripts/audit-docs.ts @@ -643,8 +643,55 @@ function extractBraceBlock(src: string, openBraceIdx: number): string | null { if (src[openBraceIdx] !== '{') return null; let depth = 1; let i = openBraceIdx + 1; + let quote: "'" | '"' | '`' | null = null; + let escaped = false; + let inLineComment = false; + let inBlockComment = false; + while (i < src.length && depth > 0) { const ch = src[i]; + const next = src[i + 1]; + + if (inLineComment) { + if (ch === '\n') inLineComment = false; + i += 1; + continue; + } + if (inBlockComment) { + if (ch === '*' && next === '/') { + inBlockComment = false; + i += 2; + } else { + i += 1; + } + continue; + } + if (quote !== null) { + if (escaped) { + escaped = false; + } else if (ch === '\\') { + escaped = true; + } else if (ch === quote) { + quote = null; + } + i += 1; + continue; + } + if (ch === '/' && next === '/') { + inLineComment = true; + i += 2; + continue; + } + if (ch === '/' && next === '*') { + inBlockComment = true; + i += 2; + continue; + } + if (ch === "'" || ch === '"' || ch === '`') { + quote = ch; + i += 1; + continue; + } if (ch === '{') depth += 1; else if (ch === '}') depth -= 1; i += 1; From 5b0e8b5613ded91eb71f58eaff47903ffb1b3383 Mon Sep 17 00:00:00 2001 From: Hyo Date: Fri, 24 Jul 2026 01:29:13 +0900 Subject: [PATCH 4/9] fix(docs): stop brace lookup loop --- scripts/audit-docs.test.ts | 7 +++++++ scripts/audit-docs.ts | 3 ++- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/scripts/audit-docs.test.ts b/scripts/audit-docs.test.ts index 9d0cb37d9..e9910409a 100644 --- a/scripts/audit-docs.test.ts +++ b/scripts/audit-docs.test.ts @@ -2,6 +2,7 @@ import { describe, expect, test } from 'bun:test'; import { auditActiveCodeExampleSource, auditCanonicalOfferDocs, + findEnclosingBraceBlock, type CanonicalOfferDocsSources, } from './audit-docs'; @@ -113,6 +114,12 @@ props.sku = "premium"\`}`; }); }); +describe('brace-block lookup', () => { + test('stops after checking a non-enclosing brace at index zero', () => { + expect(findEnclosingBraceBlock('{} title', 3)).toBeNull(); + }); +}); + const validOfferDocsSources = ( overrides: Partial> = {} ): CanonicalOfferDocsSources => ({ diff --git a/scripts/audit-docs.ts b/scripts/audit-docs.ts index 25364590e..c2d5a9d67 100644 --- a/scripts/audit-docs.ts +++ b/scripts/audit-docs.ts @@ -202,7 +202,7 @@ function escapeRegExp(value: string): string { return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); } -function findEnclosingBraceBlock( +export function findEnclosingBraceBlock( source: string, index: number ): { body: string; bodyStart: number } | null { @@ -215,6 +215,7 @@ function findEnclosingBraceBlock( return { body, bodyStart: openBraceIndex + 1 }; } } + if (openBraceIndex === 0) break; openBraceIndex = source.lastIndexOf('{', openBraceIndex - 1); } return null; From 9607e702587e31ba1a6e239b0046f38c454c4061 Mon Sep 17 00:00:00 2001 From: Hyo Date: Fri, 24 Jul 2026 03:20:48 +0900 Subject: [PATCH 5/9] fix(docs): harden offer audit parsing Parse documented TypeScript and search metadata structurally, tighten generated enum and Kotlin example guards, and run the audit fixtures in pre-commit and Docs CI. --- .github/workflows/ci.yml | 7 + .husky/pre-commit | 10 +- scripts/audit-docs.test.ts | 289 +++++++++++++++++++++++++++++++++- scripts/audit-docs.ts | 308 ++++++++++++++++++++++++++++--------- 4 files changed, 536 insertions(+), 78 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 386afba8e..b9030b9ba 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -99,6 +99,8 @@ jobs: - '.github/workflows/ci.yml' docs: - 'packages/docs/**' + - 'scripts/audit-docs.ts' + - 'scripts/audit-docs.test.ts' - '.github/workflows/ci.yml' web: - 'packages/docs/**' @@ -330,6 +332,11 @@ jobs: working-directory: packages/docs run: bun run typecheck + - name: Audit docs consistency + run: | + bun test scripts/audit-docs.test.ts + bun run audit:docs + - name: Lint working-directory: packages/docs run: bun run lint diff --git a/.husky/pre-commit b/.husky/pre-commit index 557cf5e33..4745e777d 100755 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -141,14 +141,18 @@ if git diff --cached --name-only --diff-filter=ACMR \ fi fi -# Paths-aware docs typecheck/audit. The kit integration brought React 19 into +# Paths-aware docs typecheck/audit. Audit-script changes run the same checks so +# the guard cannot be edited without exercising its own fixtures. The kit +# integration brought React 19 into # the workspace alongside docs's React 18, which previously caused # @types/react hoisting to break docs's tsc only in CI. Both are now on # React 19, but if either drifts again we want to know on commit. -if git diff --cached --name-only --diff-filter=ACMR | grep -q '^packages/docs/'; then - echo "📘 docs-touched commit — running typecheck + audit + format…" +if git diff --cached --name-only --diff-filter=ACMR \ + | grep -qE '^(packages/docs/|scripts/audit-docs(\.test)?\.ts$)'; then + echo "📘 docs/audit-touched commit — running typecheck + audit + format…" bun install --frozen-lockfile bun run --filter @hyodotdev/openiap-docs typecheck + bun test scripts/audit-docs.test.ts bun run audit:docs ( cd packages/docs && bunx prettier --check "src/**/*.{ts,tsx,css}" diff --git a/scripts/audit-docs.test.ts b/scripts/audit-docs.test.ts index e9910409a..feabe8005 100644 --- a/scripts/audit-docs.test.ts +++ b/scripts/audit-docs.test.ts @@ -2,7 +2,7 @@ import { describe, expect, test } from 'bun:test'; import { auditActiveCodeExampleSource, auditCanonicalOfferDocs, - findEnclosingBraceBlock, + extractBraceBlock, type CanonicalOfferDocsSources, } from './audit-docs'; @@ -96,11 +96,19 @@ props.sku = "premium"\`}`; const source = [ '{`iapStore.requestPurchase(activity = activity, props = request)`}', '{`kmpIAP.requestPurchase(props = request)`}', + '{`openIapStore.requestPurchase(activity = activity, props = request)`}', + '{`OpenIapStore().requestPurchase(props = request)`}', + '{`stores[0].requestPurchase(props = request)`}', + '{`store./* current instance */requestPurchase(props = request)`}', ].join('\n'); expect(auditActiveCodeExampleSource('/tmp/active.tsx', source)).toEqual([ expect.objectContaining({ rule: 'R11' }), expect.objectContaining({ rule: 'R11' }), + expect.objectContaining({ rule: 'R11' }), + expect.objectContaining({ rule: 'R11' }), + expect.objectContaining({ rule: 'R11' }), + expect.objectContaining({ rule: 'R11' }), ]); }); @@ -108,15 +116,20 @@ props.sku = "premium"\`}`; const source = [ '{`iapStore.requestPurchase(request)`}', '{`kmpIAP.requestPurchase(RequestPurchaseProps(...))`}', + '{`requestPurchase(props = validLocalProps)`}', ].join('\n'); expect(auditActiveCodeExampleSource('/tmp/active.tsx', source)).toEqual([]); }); }); -describe('brace-block lookup', () => { - test('stops after checking a non-enclosing brace at index zero', () => { - expect(findEnclosingBraceBlock('{} title', 3)).toBeNull(); +describe('brace-block parsing', () => { + test('handles nested template literals without closing the object early', () => { + const source = '{ description: `outer ${`}`}`, path: true }'; + + expect(extractBraceBlock(source, 0)).toBe( + ' description: `outer ${`}`}`, path: true ' + ); }); }); @@ -280,6 +293,71 @@ ${discountOffer}`, } }); + test('accepts a multiline TypeScript union with leading delimiters', () => { + const discountOffer = VALID_DISCOUNT_OFFER_TYPE_BLOCKS.replace( + /\{`[\s\S]*?`}<\/CodeBlock>/, + `{\` +type DiscountOfferType = + | 'introductory' + | 'promotional' + | 'one-time'; +\`}` + ); + + expect( + auditCanonicalOfferDocs( + validOfferDocsSources({ + discountOffer: `

    DiscountOffer represents one-time products on Android via ProductDetails.OneTimePurchaseOfferDetails.

    +${discountOffer}`, + }) + ) + ).toEqual([]); + }); + + test('accepts a parenthesized TypeScript union', () => { + const discountOffer = VALID_DISCOUNT_OFFER_TYPE_BLOCKS.replace( + /\{`[\s\S]*?`}<\/CodeBlock>/, + `{\` +type DiscountOfferType = ( + | 'introductory' + | 'promotional' + | 'one-time' +); +\`}` + ); + + expect( + auditCanonicalOfferDocs( + validOfferDocsSources({ + discountOffer: `

    DiscountOffer represents one-time products on Android via ProductDetails.OneTimePurchaseOfferDetails.

    +${discountOffer}`, + }) + ) + ).toEqual([]); + }); + + test('ignores commented TypeScript declarations', () => { + const discountOffer = VALID_DISCOUNT_OFFER_TYPE_BLOCKS.replace( + /\{`[\s\S]*?`}<\/CodeBlock>/, + `{\` +// type DiscountOfferType = 'introductory' | 'promotional' | 'one-time'; +\`}` + ); + const drifts = auditCanonicalOfferDocs( + validOfferDocsSources({ + discountOffer: `

    DiscountOffer represents one-time products on Android via ProductDetails.OneTimePurchaseOfferDetails.

    +${discountOffer}`, + }) + ); + + expect(drifts).toEqual([ + expect.objectContaining({ + rule: 'R12', + message: expect.stringContaining('TypeScript snippet'), + }), + ]); + }); + test('flags incorrect generated-language DiscountOfferType wire values', () => { for (const [language, brokenBlock] of [ [ @@ -335,6 +413,97 @@ ${discountOffer}`, } }); + test('flags unmatched extra generated-language enum members', () => { + for (const [language, brokenBlock] of [ + [ + 'swift', + `{\` +enum DiscountOfferType: String { + case introductory = "introductory" + case promotional = "promotional" + case oneTime = "one-time", + legacy = "legacy" +} +\`}`, + ], + [ + 'kotlin', + `{\` +enum class DiscountOfferType(val rawValue: String) { + Introductory("introductory"), + Promotional("promotional"), + OneTime("one-time"), + Legacy +} +\`}`, + ], + [ + 'dart', + `{\` +enum DiscountOfferType { + Introductory('introductory'), + Promotional('promotional'), + OneTime('one-time'), + Legacy("legacy"); +} +\`}`, + ], + ] as const) { + const discountOffer = VALID_DISCOUNT_OFFER_TYPE_BLOCKS.replace( + new RegExp( + `\\{\\\`[\\s\\S]*?\\\`\\}` + ), + brokenBlock + ); + const drifts = auditCanonicalOfferDocs( + validOfferDocsSources({ + discountOffer: `

    DiscountOffer represents one-time products on Android via ProductDetails.OneTimePurchaseOfferDetails.

    +${discountOffer}`, + }) + ); + + expect(drifts).toEqual([ + expect.objectContaining({ + rule: 'R12', + message: expect.stringContaining(`${language} snippet`), + }), + ]); + } + }); + + test('accepts valid Swift combined cases and Dart double quotes', () => { + const swiftCombined = `{\` +enum DiscountOfferType: String { + case introductory = "introductory", + promotional = "promotional", + oneTime = "one-time" +} +\`}`; + const dartDoubleQuoted = `{\` +enum DiscountOfferType { + Introductory("introductory"), + Promotional("promotional"), + OneTime("one-time"); +} +\`}`; + const discountOffer = VALID_DISCOUNT_OFFER_TYPE_BLOCKS.replace( + /\{`[\s\S]*?`}<\/CodeBlock>/, + swiftCombined + ).replace( + /\{`[\s\S]*?`}<\/CodeBlock>/, + dartDoubleQuoted + ); + + expect( + auditCanonicalOfferDocs( + validOfferDocsSources({ + discountOffer: `

    DiscountOffer represents one-time products on Android via ProductDetails.OneTimePurchaseOfferDetails.

    +${discountOffer}`, + }) + ) + ).toEqual([]); + }); + test('flags legacy native search routes and missing canonical entries', () => { const legacySearchData = `export const apiData = [ { @@ -374,6 +543,49 @@ ${discountOffer}`, ]); }); + test('ignores commented search entries and path-like description strings', () => { + const commentedEntries = `export const apiData = []; +// { title: 'DiscountOffer', path: '/docs/types/discount-offer' } +/* { title: 'SubscriptionOffer', path: '/docs/types/subscription-offer' } */`; + const commentedDrifts = auditCanonicalOfferDocs( + validOfferDocsSources({ searchData: commentedEntries }) + ); + expect(commentedDrifts).toEqual([ + expect.objectContaining({ + message: expect.stringContaining('canonical DiscountOffer entry'), + }), + expect.objectContaining({ + message: expect.stringContaining('canonical SubscriptionOffer entry'), + }), + ]); + + const misleadingDescriptions = `export const apiData = [ + { + title: 'DiscountOffer', + description: "path: '/docs/types/discount-offer'", + path: '/wrong-discount-path', + }, + { + title: 'SubscriptionOffer', + description: "path: '/docs/types/subscription-offer'", + path: '/wrong-subscription-path', + }, +];`; + const pathDrifts = auditCanonicalOfferDocs( + validOfferDocsSources({ searchData: misleadingDescriptions }) + ); + expect(pathDrifts).toEqual([ + expect.objectContaining({ + line: 5, + message: expect.stringContaining('/wrong-discount-path'), + }), + expect.objectContaining({ + line: 10, + message: expect.stringContaining('/wrong-subscription-path'), + }), + ]); + }); + test('finds canonical search paths across indentation and nested formatting', () => { const reformattedSearchData = `export const apiData = [ \t{ @@ -393,6 +605,75 @@ ${discountOffer}`, ).toEqual([]); }); + test('accepts parenthesized and typed apiData array initializers', () => { + const entries = `[ + { title: 'DiscountOffer', path: '/docs/types/discount-offer' }, + { title: 'SubscriptionOffer', path: '/docs/types/subscription-offer' }, +]`; + for (const searchData of [ + `export const apiData = (${entries});`, + `export const apiData = ${entries} as const;`, + `export const apiData = ${entries} satisfies readonly SearchItem[];`, + ]) { + expect( + auditCanonicalOfferDocs(validOfferDocsSources({ searchData })) + ).toEqual([]); + } + }); + + test('accepts wrapped apiData elements and string properties', () => { + const searchData = `export const apiData = [ + ({ + title: ('DiscountOffer' as const), + path: '/docs/types/discount-offer' as const, + }), + ({ + title: 'SubscriptionOffer' as const, + path: ('/docs/types/subscription-offer'), + } as const), +];`; + + expect( + auditCanonicalOfferDocs(validOfferDocsSources({ searchData })) + ).toEqual([]); + }); + + test('ignores nested apiData shadow declarations', () => { + const searchData = `export const apiData = [ + { title: 'DiscountOffer', path: '/docs/types/discount-offer' }, + { title: 'SubscriptionOffer', path: '/docs/types/subscription-offer' }, +]; +function shadow() { + const apiData = []; + return apiData; +}`; + + expect( + auditCanonicalOfferDocs(validOfferDocsSources({ searchData })) + ).toEqual([]); + }); + + test('parses search entries after nested template literals', () => { + const searchData = [ + 'export const apiData = [', + ' {', + " title: 'DiscountOffer',", + ' description: `outer ${`}`}`,', + " path: '/docs/types/discount-offer',", + ' },', + ' {', + " title: 'SubscriptionOffer',", + ' description: `outer ${`}`}`,', + " path: '/docs/types/subscription-offer',", + ' },', + '];', + ].join('\n'); + + expect( + auditCanonicalOfferDocs(validOfferDocsSources({ searchData })) + ).toEqual([]); + }); + test('ignores braces inside search strings and comments', () => { const edgeCases = [ `export const apiData = [ diff --git a/scripts/audit-docs.ts b/scripts/audit-docs.ts index c2d5a9d67..d87e593d2 100644 --- a/scripts/audit-docs.ts +++ b/scripts/audit-docs.ts @@ -35,6 +35,7 @@ import { readFileSync, statSync } from 'node:fs'; import { readdir } from 'node:fs/promises'; import { dirname, join, relative, resolve } from 'node:path'; +import ts from 'typescript'; const REPO_ROOT = resolve(import.meta.dir, '..'); const DOC_ROOTS = [ @@ -156,8 +157,7 @@ const CODE_EXAMPLE_RULES: CodeExampleRule[] = [ }, { language: 'kotlin', - pattern: - /\b(?:iapStore|kmpIAP)\.requestPurchase\s*\(\s*(?:activity|props)\s*=/, + pattern: /\.\s*requestPurchase\s*\(\s*(?:activity|props)\s*=/, message: 'Kotlin and KMP `requestPurchase` accept one positional `RequestPurchaseProps` argument; `activity` and `props` are not parameters.', }, @@ -181,11 +181,13 @@ export function auditActiveCodeExampleSource( while ((blockMatch = blockRe.exec(src)) !== null) { const language = blockMatch[1]; const block = blockMatch[2]; + const auditedBlock = + language === 'kotlin' ? stripCommentsPreservingLayout(block) : block; const blockOffset = blockMatch.index + blockMatch[0].indexOf(block); for (const rule of CODE_EXAMPLE_RULES) { if (rule.language && rule.language !== language) continue; - const violation = rule.pattern.exec(block); + const violation = rule.pattern.exec(auditedBlock); if (!violation) continue; drifts.push({ file: filePath, @@ -198,62 +200,91 @@ export function auditActiveCodeExampleSource( return drifts; } -function escapeRegExp(value: string): string { - return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); -} - -export function findEnclosingBraceBlock( - source: string, - index: number -): { body: string; bodyStart: number } | null { - let openBraceIndex = source.lastIndexOf('{', index); - while (openBraceIndex !== -1) { - const body = extractBraceBlock(source, openBraceIndex); - if (body !== null) { - const closeBraceIndex = openBraceIndex + body.length + 1; - if (index <= closeBraceIndex) { - return { body, bodyStart: openBraceIndex + 1 }; - } - } - if (openBraceIndex === 0) break; - openBraceIndex = source.lastIndexOf('{', openBraceIndex - 1); - } - return null; -} - -function findTopLevelSearchPath( - objectBody: string -): { path: string; index: number } | null { - const pathRe = /\bpath:\s*(['"])([^'"]+)\1/g; - let pathMatch: RegExpExecArray | null; - while ((pathMatch = pathRe.exec(objectBody)) !== null) { - if (findEnclosingBraceBlock(objectBody, pathMatch.index)) continue; - return { path: pathMatch[2], index: pathMatch.index }; - } - return null; -} - function findSearchEntriesByTitle( source: string, title: string ): { line: number; path: string | null; pathLine: number }[] { const entries: { line: number; path: string | null; pathLine: number }[] = []; - const titleRe = new RegExp( - `\\btitle:\\s*(['"])${escapeRegExp(title)}\\1`, - 'g' + const sourceFile = ts.createSourceFile( + 'searchData.ts', + source, + ts.ScriptTarget.Latest, + true, + ts.ScriptKind.TS ); - let titleMatch: RegExpExecArray | null; + let apiData: ts.ArrayLiteralExpression | null = null; + + const unwrapExpression = (expression: ts.Expression): ts.Expression => { + let current = expression; + while (true) { + if ( + ts.isParenthesizedExpression(current) || + ts.isAsExpression(current) || + ts.isTypeAssertionExpression(current) || + ts.isSatisfiesExpression(current) + ) { + current = current.expression; + continue; + } + return current; + } + }; + for (const statement of sourceFile.statements) { + if (!ts.isVariableStatement(statement)) continue; + for (const declaration of statement.declarationList.declarations) { + if ( + !ts.isIdentifier(declaration.name) || + declaration.name.text !== 'apiData' || + !declaration.initializer + ) { + continue; + } + const initializer = unwrapExpression(declaration.initializer); + if (ts.isArrayLiteralExpression(initializer)) apiData = initializer; + } + } - while ((titleMatch = titleRe.exec(source)) !== null) { - const object = findEnclosingBraceBlock(source, titleMatch.index); - const pathMatch = object ? findTopLevelSearchPath(object.body) : null; + if (!apiData) return entries; + const propertyName = ( + property: ts.ObjectLiteralElementLike + ): string | null => { + if (!property.name) return null; + if (ts.isIdentifier(property.name) || ts.isStringLiteral(property.name)) { + return property.name.text; + } + return null; + }; + const stringValue = ( + property: ts.ObjectLiteralElementLike + ): string | null => { + if (!ts.isPropertyAssignment(property)) return null; + const initializer = unwrapExpression(property.initializer); + return ts.isStringLiteralLike(initializer) ? initializer.text : null; + }; + + for (const element of apiData.elements) { + const entry = unwrapExpression(element); + if (!ts.isObjectLiteralExpression(entry)) continue; + const titleProperty = entry.properties.find( + (property) => propertyName(property) === 'title' + ); + if (!titleProperty || stringValue(titleProperty) !== title) continue; + const pathProperty = entry.properties.find( + (property) => propertyName(property) === 'path' + ); + const line = + sourceFile.getLineAndCharacterOfPosition( + titleProperty.getStart(sourceFile) + ).line + 1; entries.push({ - line: lineNumberAt(source, titleMatch.index), - path: pathMatch?.path ?? null, - pathLine: object && pathMatch - ? lineNumberAt(source, object.bodyStart + pathMatch.index) - : lineNumberAt(source, titleMatch.index), + line, + path: pathProperty ? stringValue(pathProperty) : null, + pathLine: pathProperty + ? sourceFile.getLineAndCharacterOfPosition( + pathProperty.getStart(sourceFile) + ).line + 1 + : line, }); } @@ -269,30 +300,50 @@ function findTypeScriptDiscountOfferType( while ((blockMatch = blockRe.exec(source)) !== null) { const block = blockMatch[1]; - const declaration = - /(?:export\s+)?type\s+DiscountOfferType\s*=\s*([\s\S]*?);/.exec(block); + const sourceFile = ts.createSourceFile( + 'discount-offer-snippet.ts', + block, + ts.ScriptTarget.Latest, + true, + ts.ScriptKind.TS + ); + const declaration = sourceFile.statements.find( + (statement): statement is ts.TypeAliasDeclaration => + ts.isTypeAliasDeclaration(statement) && + statement.name.text === 'DiscountOfferType' + ); if (!declaration) continue; - const rawMembers = declaration[1].split('|').map((member) => member.trim()); + let offerType: ts.TypeNode = declaration.type; + while (ts.isParenthesizedTypeNode(offerType)) offerType = offerType.type; + const rawMembers = ts.isUnionTypeNode(offerType) + ? offerType.types + : [offerType]; const members: string[] = []; for (const rawMember of rawMembers) { - const literal = /^(['"])([^'"]+)\1$/.exec(rawMember); - if (!literal) { + if ( + !ts.isLiteralTypeNode(rawMember) || + !ts.isStringLiteral(rawMember.literal) + ) { return { line: lineNumberAt( source, - blockMatch.index + blockMatch[0].indexOf(block) + declaration.index + blockMatch.index + + blockMatch[0].indexOf(block) + + declaration.getStart(sourceFile) ), members: null, }; } - members.push(literal[2]); + members.push(rawMember.literal.text); } return { line: lineNumberAt( source, - blockMatch.index + blockMatch[0].indexOf(block) + declaration.index + blockMatch.index + + blockMatch[0].indexOf(block) + + declaration.getStart(sourceFile) ), members, }; @@ -303,10 +354,70 @@ function findTypeScriptDiscountOfferType( type NamedOfferTypeLanguage = 'swift' | 'kotlin' | 'dart'; +function stripCommentsPreservingLayout(source: string): string { + const output = source.split(''); + let quote: "'" | '"' | null = null; + let escaped = false; + let inLineComment = false; + let inBlockComment = false; + + for (let i = 0; i < source.length; i += 1) { + const ch = source[i]; + const next = source[i + 1]; + + if (inLineComment) { + if (ch === '\n') { + inLineComment = false; + } else { + output[i] = ' '; + } + continue; + } + if (inBlockComment) { + if (ch !== '\n') output[i] = ' '; + if (ch === '*' && next === '/') { + output[i + 1] = ' '; + inBlockComment = false; + i += 1; + } + continue; + } + if (quote !== null) { + if (escaped) { + escaped = false; + } else if (ch === '\\') { + escaped = true; + } else if (ch === quote) { + quote = null; + } + continue; + } + if (ch === "'" || ch === '"') { + quote = ch; + continue; + } + if (ch === '/' && next === '/') { + output[i] = ' '; + output[i + 1] = ' '; + inLineComment = true; + i += 1; + continue; + } + if (ch === '/' && next === '*') { + output[i] = ' '; + output[i + 1] = ' '; + inBlockComment = true; + i += 1; + } + } + + return output.join(''); +} + function findNamedDiscountOfferTypeMembers( source: string, language: NamedOfferTypeLanguage -): { line: number; members: string[] } | null { +): { line: number; members: string[] | null } | null { const blockRe = new RegExp( `]*\\blanguage="${language}"[^>]*>\\s*\\{\`([\\s\\S]*?)\`\\}\\s*
    `, 'g' @@ -315,32 +426,38 @@ function findNamedDiscountOfferTypeMembers( while ((blockMatch = blockRe.exec(source)) !== null) { const block = blockMatch[1]; + const code = stripCommentsPreservingLayout(block); const declaration = language === 'swift' - ? /enum\s+DiscountOfferType[^{}]*\{([\s\S]*?)\}/.exec(block) + ? /enum\s+DiscountOfferType[^{}]*\{([\s\S]*?)\}/.exec(code) : language === 'kotlin' ? /enum\s+class\s+DiscountOfferType(?:\([^)]*\))?\s*\{([\s\S]*?)\}/.exec( - block + code ) - : /enum\s+DiscountOfferType\s*\{([\s\S]*?)\}/.exec(block); + : /enum\s+DiscountOfferType\s*\{([\s\S]*?)\}/.exec(code); if (!declaration) continue; const memberRe = language === 'swift' - ? /\bcase\s+([A-Za-z]\w*)\s*=\s*"([^"]+)"/g + ? /(?:\bcase|,)\s*([A-Za-z]\w*)\s*=\s*"([^"]+)"/g : language === 'kotlin' ? /\b([A-Z]\w*)\s*\(\s*"([^"]+)"\s*\)/g - : /\b([A-Z]\w*)\s*\(\s*'([^']+)'\s*\)/g; - const members = Array.from(declaration[1].matchAll(memberRe), (match) => - `${match[1]}=${match[2]}` + : /\b([A-Z]\w*)\s*\(\s*(['"])([^'"]+)\2\s*\)/g; + const memberSection = + language === 'swift' ? declaration[1] : declaration[1].split(';', 1)[0]; + const members = Array.from( + memberSection.matchAll(memberRe), + (match) => `${match[1]}=${match[language === 'dart' ? 3 : 2]}` ); + const unmatchedMembers = + memberSection.replace(memberRe, '').replace(/[\s,;]/g, '').length > 0; return { line: lineNumberAt( source, blockMatch.index + blockMatch[0].indexOf(block) + declaration.index ), - members, + members: unmatchedMembers ? null : members, }; } @@ -442,6 +559,7 @@ export function auditCanonicalOfferDocs( ); const actualMembers = declaration?.members; const hasExactMembers = + actualMembers !== null && actualMembers !== undefined && actualMembers.length === expectedMembers.length && expectedMembers.every((member) => actualMembers.includes(member)); @@ -640,14 +758,19 @@ function buildTypeIndex(): Map< return index; } -function extractBraceBlock(src: string, openBraceIdx: number): string | null { +export function extractBraceBlock( + src: string, + openBraceIdx: number +): string | null { if (src[openBraceIdx] !== '{') return null; let depth = 1; let i = openBraceIdx + 1; - let quote: "'" | '"' | '`' | null = null; + let quote: "'" | '"' | null = null; let escaped = false; let inLineComment = false; let inBlockComment = false; + let inTemplate = false; + const templateStack: { interpolationDepth: number | null }[] = []; while (i < src.length && depth > 0) { const ch = src[i]; @@ -678,6 +801,33 @@ function extractBraceBlock(src: string, openBraceIdx: number): string | null { i += 1; continue; } + if (inTemplate) { + if (escaped) { + escaped = false; + i += 1; + continue; + } + if (ch === '\\') { + escaped = true; + i += 1; + continue; + } + if (ch === '`') { + templateStack.pop(); + inTemplate = false; + i += 1; + continue; + } + if (ch === '$' && next === '{') { + depth += 1; + templateStack[templateStack.length - 1].interpolationDepth = depth; + inTemplate = false; + i += 2; + continue; + } + i += 1; + continue; + } if (ch === '/' && next === '/') { inLineComment = true; i += 2; @@ -688,13 +838,29 @@ function extractBraceBlock(src: string, openBraceIdx: number): string | null { i += 2; continue; } - if (ch === "'" || ch === '"' || ch === '`') { + if (ch === "'" || ch === '"') { quote = ch; i += 1; continue; } + if (ch === '`') { + templateStack.push({ interpolationDepth: null }); + inTemplate = true; + i += 1; + continue; + } if (ch === '{') depth += 1; - else if (ch === '}') depth -= 1; + else if (ch === '}') { + const template = templateStack[templateStack.length - 1]; + const closesInterpolation = + template?.interpolationDepth !== null && + template?.interpolationDepth === depth; + depth -= 1; + if (closesInterpolation) { + template.interpolationDepth = null; + inTemplate = true; + } + } i += 1; } if (depth !== 0) return null; From a2b8b0540cd524406f45f8d2e0726f187680e8dd Mon Sep 17 00:00:00 2001 From: Hyo Date: Fri, 24 Jul 2026 03:38:11 +0900 Subject: [PATCH 6/9] test(docs): use positional Kotlin purchase call Keep the accepted audit fixture aligned with the Kotlin and KMP requestPurchase contract so invalid named arguments cannot be mistaken for current SDK usage. --- scripts/audit-docs.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/audit-docs.test.ts b/scripts/audit-docs.test.ts index feabe8005..00c867299 100644 --- a/scripts/audit-docs.test.ts +++ b/scripts/audit-docs.test.ts @@ -116,7 +116,7 @@ props.sku = "premium"\`}
    `; const source = [ '{`iapStore.requestPurchase(request)`}', '{`kmpIAP.requestPurchase(RequestPurchaseProps(...))`}', - '{`requestPurchase(props = validLocalProps)`}', + '{`requestPurchase(validLocalProps)`}', ].join('\n'); expect(auditActiveCodeExampleSource('/tmp/active.tsx', source)).toEqual([]); From 5df5505bc8102f932a6cf82f3718b8ec87f9a0d0 Mon Sep 17 00:00:00 2001 From: Hyo Date: Fri, 24 Jul 2026 10:24:36 +0900 Subject: [PATCH 7/9] refactor(gql): enforce generated contract SSOT Derive schema inventories, deprecation metadata, generated sync paths, language support, and agent context inputs from canonical manifests. Fail closed on generation drift across CI and platform refreshers, remove redundant generator pipelines, and harden offer documentation audits with focused regression coverage. --- .claude/commands/review-pr.md | 2 +- .claude/commands/verify-all.md | 30 +- .claude/guides/04-apple-package.md | 9 +- .claude/guides/06-gql-package.md | 47 +- .github/pr-previews/openiap-codegen-ssot.jpg | Bin 0 -> 71372 bytes .github/workflows/ci.yml | 111 +- .husky/pre-commit | 43 +- AGENTS.md | 19 +- CONTRIBUTING.md | 38 +- bun.lock | 58 +- knowledge/_claude-context/context.md | 204 +++- knowledge/internal/04-platform-packages.md | 63 +- knowledge/internal/07-docs-consistency.md | 139 ++- libraries/expo-iap/CLAUDE.md | 2 +- libraries/expo-iap/CONTRIBUTING.md | 12 +- libraries/expo-iap/scripts/update-types.mjs | 193 +-- libraries/expo-iap/src/types.ts | 173 ++- libraries/flutter_inapp_purchase/CLAUDE.md | 4 +- .../flutter_inapp_purchase/lib/types.dart | 57 +- .../scripts/generate-type.sh | 94 +- .../scripts/update-types.mjs | 104 -- libraries/godot-iap/CLAUDE.md | 7 +- libraries/godot-iap/CONTRIBUTING.md | 4 +- libraries/godot-iap/addons/godot-iap/types.gd | 469 ++++---- libraries/godot-iap/scripts/generate-types.sh | 95 +- libraries/kmp-iap/CLAUDE.md | 7 + .../io/github/hyochan/kmpiap/openiap/Types.kt | 75 +- libraries/kmp-iap/scripts/generate-types.sh | 157 ++- libraries/kmp-iap/scripts/update-types.mjs | 76 -- libraries/maui-iap/CLAUDE.md | 13 +- libraries/maui-iap/README.md | 1 - libraries/maui-iap/src/OpenIap.Maui/Types.cs | 55 +- libraries/react-native-iap/CLAUDE.md | 2 +- .../react-native-iap/scripts/update-types.mjs | 171 ++- libraries/react-native-iap/src/types.ts | 173 ++- package.json | 2 +- packages/apple/.github/workflows/test.yml | 6 +- packages/apple/Sources/Models/Types.swift | 61 +- packages/apple/scripts/generate-types.sh | 40 - packages/docs/CONVENTION.md | 9 +- packages/docs/public/llms-full.txt | 18 +- packages/docs/public/llms.txt | 18 +- packages/docs/src/pages/introduction.tsx | 16 +- packages/google/CONTRIBUTING.md | 2 +- packages/google/CONVENTION.md | 4 +- .../src/main/java/dev/hyo/openiap/Types.kt | 262 ++++- packages/google/scripts/generate-types.sh | 244 +--- packages/google/scripts/post-process-types.sh | 204 ---- .../gql/.github/workflows/generate-types.yml | 32 - .../gql/.github/workflows/release-types.yml | 60 - packages/gql/.gitignore | 4 - packages/gql/CONVENTION.md | 69 +- packages/gql/README.md | 127 +- packages/gql/codegen.ts | 37 +- packages/gql/codegen/README.md | 10 +- packages/gql/codegen/core/generated-header.ts | 6 + packages/gql/codegen/core/parser.ts | 116 +- packages/gql/codegen/core/schema-linter.ts | 193 +-- packages/gql/codegen/core/template-engine.ts | 315 ----- packages/gql/codegen/core/transformer.ts | 355 ++++-- packages/gql/codegen/core/types.ts | 88 +- packages/gql/codegen/core/utils.ts | 290 ++--- packages/gql/codegen/index.ts | 96 +- packages/gql/codegen/plugins/base-plugin.ts | 84 +- packages/gql/codegen/plugins/csharp.ts | 176 +-- packages/gql/codegen/plugins/dart.ts | 171 +-- packages/gql/codegen/plugins/gdscript.ts | 171 +-- packages/gql/codegen/plugins/kotlin.ts | 164 +-- packages/gql/codegen/plugins/swift.ts | 95 +- packages/gql/codegen/templates/dart/enum.hbs | 28 - .../gql/codegen/templates/dart/header.hbs | 4 - packages/gql/codegen/templates/dart/input.hbs | 27 - .../gql/codegen/templates/dart/interface.hbs | 13 - .../gql/codegen/templates/dart/object.hbs | 34 - .../templates/dart/operation-helpers.hbs | 13 - .../templates/dart/operation-interface.hbs | 11 - .../codegen/templates/dart/result-union.hbs | 12 - packages/gql/codegen/templates/dart/union.hbs | 34 - .../gql/codegen/templates/gdscript/enum.hbs | 10 - .../gql/codegen/templates/gdscript/header.hbs | 7 - .../gql/codegen/templates/gdscript/input.hbs | 29 - .../codegen/templates/gdscript/interface.hbs | 29 - .../gql/codegen/templates/gdscript/object.hbs | 30 - .../codegen/templates/gdscript/operation.hbs | 17 - .../templates/gdscript/result-union.hbs | 12 - .../gql/codegen/templates/gdscript/union.hbs | 23 - .../gql/codegen/templates/kotlin/enum.hbs | 31 - .../gql/codegen/templates/kotlin/header.hbs | 7 - .../gql/codegen/templates/kotlin/input.hbs | 47 - .../codegen/templates/kotlin/interface.hbs | 15 - .../gql/codegen/templates/kotlin/object.hbs | 33 - .../templates/kotlin/operation-helpers.hbs | 15 - .../templates/kotlin/operation-interface.hbs | 15 - .../codegen/templates/kotlin/result-union.hbs | 11 - .../gql/codegen/templates/kotlin/union.hbs | 29 - packages/gql/codegen/templates/swift/enum.hbs | 27 - .../gql/codegen/templates/swift/header.hbs | 6 - .../gql/codegen/templates/swift/input.hbs | 25 - .../gql/codegen/templates/swift/interface.hbs | 11 - .../gql/codegen/templates/swift/object.hbs | 14 - .../templates/swift/operation-helpers.hbs | 25 - .../templates/swift/operation-protocol.hbs | 19 - .../codegen/templates/swift/result-union.hbs | 8 - .../gql/codegen/templates/swift/union.hbs | 24 - packages/gql/custom-input-contracts.ts | 111 ++ packages/gql/generated-sync-manifest.mjs | 144 +++ packages/gql/generators/dart/README.md | 17 - packages/gql/generators/dart/build.yaml | 17 - packages/gql/generators/dart/pubspec.yaml | 23 - packages/gql/generators/kotlin/README.md | 36 - packages/gql/generators/swift/README.md | 18 - .../gql/generators/swift/generate-swift.sh | 26 - packages/gql/package.json | 7 +- packages/gql/schema-deprecations.mjs | 217 ++++ packages/gql/schema-files.mjs | 19 + packages/gql/schema-markers.mjs | 235 ++++ packages/gql/schema-source-utils.mjs | 75 ++ .../gql/scripts/assert-generated-staged.mjs | 21 + .../assert-generation-inputs-staged.mjs | 31 + .../gql/scripts/custom-generated-guards.mjs | 587 ++++++++++ packages/gql/scripts/fix-generated-types.mjs | 670 +++++------ .../gql/scripts/generated-doc-comments.mjs | 168 ++- .../scripts/generated-sync-materializer.mjs | 15 + .../scripts/kotlin-platform-postprocess.mjs | 185 +++ .../standalone-generated-refreshers.test.mjs | 316 +++++ packages/gql/scripts/sync-to-platforms.mjs | 234 +--- .../gql/scripts/verify-generated-sync.mjs | 38 + packages/gql/src/api-ios.graphql | 8 +- packages/gql/src/codegen-defaults.test.ts | 216 ++-- packages/gql/src/codegen-entrypoint.test.ts | 39 + .../gql/src/custom-generated-guards.test.mjs | 434 +++++++ .../gql/src/deprecation-transformer.test.ts | 465 ++++++++ packages/gql/src/error.graphql | 9 +- .../gql/src/generated-compatibility.test.ts | 594 ++++++++-- .../gql/src/generated-doc-comments.test.mjs | 116 +- packages/gql/src/generated-gdscript.test.ts | 19 +- .../gql/src/generated-sync-manifest.test.mjs | 418 +++++++ .../gql/src/generated-sync-verifier.test.mjs | 50 + packages/gql/src/generated/Types.cs | 55 +- packages/gql/src/generated/Types.kt | 75 +- packages/gql/src/generated/Types.swift | 61 +- packages/gql/src/generated/types.dart | 57 +- packages/gql/src/generated/types.gd | 469 ++++---- packages/gql/src/generated/types.ts | 173 ++- .../src/kotlin-platform-postprocess.test.mjs | 121 ++ packages/gql/src/schema-contract.test.ts | 55 + packages/gql/src/schema-deprecations.test.mjs | 178 +++ packages/gql/src/schema-files.test.mjs | 14 + packages/gql/src/schema-linter.test.ts | 276 ++++- packages/gql/src/schema-markers.test.mjs | 323 +++++ packages/gql/src/schema-source-utils.test.mjs | 45 + packages/gql/src/schema.graphql | 9 + packages/gql/src/type-android.graphql | 55 +- packages/gql/src/type-ios.graphql | 40 +- packages/gql/src/type.graphql | 36 +- scripts/agent/compile-context.ts | 71 +- scripts/agent/context-files.ts | 154 +++ scripts/agent/tests/compile-context.test.ts | 71 +- scripts/agent/tests/llms-content.test.ts | 12 +- scripts/assert-clean-worktree.mjs | 47 + scripts/assert-clean-worktree.test.mjs | 46 + scripts/audit-docs.test.ts | 496 +++++--- scripts/audit-docs.ts | 1042 ++++++++--------- scripts/audit-non-godot-parity.mjs | 141 +-- scripts/sync-versions.sh | 37 +- 165 files changed, 10028 insertions(+), 6656 deletions(-) create mode 100644 .github/pr-previews/openiap-codegen-ssot.jpg delete mode 100644 libraries/flutter_inapp_purchase/scripts/update-types.mjs delete mode 100644 libraries/kmp-iap/scripts/update-types.mjs delete mode 100755 packages/apple/scripts/generate-types.sh delete mode 100755 packages/google/scripts/post-process-types.sh delete mode 100644 packages/gql/.github/workflows/generate-types.yml delete mode 100644 packages/gql/.github/workflows/release-types.yml create mode 100644 packages/gql/codegen/core/generated-header.ts delete mode 100644 packages/gql/codegen/core/template-engine.ts delete mode 100644 packages/gql/codegen/templates/dart/enum.hbs delete mode 100644 packages/gql/codegen/templates/dart/header.hbs delete mode 100644 packages/gql/codegen/templates/dart/input.hbs delete mode 100644 packages/gql/codegen/templates/dart/interface.hbs delete mode 100644 packages/gql/codegen/templates/dart/object.hbs delete mode 100644 packages/gql/codegen/templates/dart/operation-helpers.hbs delete mode 100644 packages/gql/codegen/templates/dart/operation-interface.hbs delete mode 100644 packages/gql/codegen/templates/dart/result-union.hbs delete mode 100644 packages/gql/codegen/templates/dart/union.hbs delete mode 100644 packages/gql/codegen/templates/gdscript/enum.hbs delete mode 100644 packages/gql/codegen/templates/gdscript/header.hbs delete mode 100644 packages/gql/codegen/templates/gdscript/input.hbs delete mode 100644 packages/gql/codegen/templates/gdscript/interface.hbs delete mode 100644 packages/gql/codegen/templates/gdscript/object.hbs delete mode 100644 packages/gql/codegen/templates/gdscript/operation.hbs delete mode 100644 packages/gql/codegen/templates/gdscript/result-union.hbs delete mode 100644 packages/gql/codegen/templates/gdscript/union.hbs delete mode 100644 packages/gql/codegen/templates/kotlin/enum.hbs delete mode 100644 packages/gql/codegen/templates/kotlin/header.hbs delete mode 100644 packages/gql/codegen/templates/kotlin/input.hbs delete mode 100644 packages/gql/codegen/templates/kotlin/interface.hbs delete mode 100644 packages/gql/codegen/templates/kotlin/object.hbs delete mode 100644 packages/gql/codegen/templates/kotlin/operation-helpers.hbs delete mode 100644 packages/gql/codegen/templates/kotlin/operation-interface.hbs delete mode 100644 packages/gql/codegen/templates/kotlin/result-union.hbs delete mode 100644 packages/gql/codegen/templates/kotlin/union.hbs delete mode 100644 packages/gql/codegen/templates/swift/enum.hbs delete mode 100644 packages/gql/codegen/templates/swift/header.hbs delete mode 100644 packages/gql/codegen/templates/swift/input.hbs delete mode 100644 packages/gql/codegen/templates/swift/interface.hbs delete mode 100644 packages/gql/codegen/templates/swift/object.hbs delete mode 100644 packages/gql/codegen/templates/swift/operation-helpers.hbs delete mode 100644 packages/gql/codegen/templates/swift/operation-protocol.hbs delete mode 100644 packages/gql/codegen/templates/swift/result-union.hbs delete mode 100644 packages/gql/codegen/templates/swift/union.hbs create mode 100644 packages/gql/custom-input-contracts.ts create mode 100644 packages/gql/generated-sync-manifest.mjs delete mode 100644 packages/gql/generators/dart/README.md delete mode 100644 packages/gql/generators/dart/build.yaml delete mode 100644 packages/gql/generators/dart/pubspec.yaml delete mode 100644 packages/gql/generators/kotlin/README.md delete mode 100644 packages/gql/generators/swift/README.md delete mode 100755 packages/gql/generators/swift/generate-swift.sh create mode 100644 packages/gql/schema-deprecations.mjs create mode 100644 packages/gql/schema-files.mjs create mode 100644 packages/gql/schema-markers.mjs create mode 100644 packages/gql/schema-source-utils.mjs create mode 100644 packages/gql/scripts/assert-generated-staged.mjs create mode 100644 packages/gql/scripts/assert-generation-inputs-staged.mjs create mode 100644 packages/gql/scripts/custom-generated-guards.mjs create mode 100644 packages/gql/scripts/generated-sync-materializer.mjs create mode 100644 packages/gql/scripts/kotlin-platform-postprocess.mjs create mode 100644 packages/gql/scripts/standalone-generated-refreshers.test.mjs create mode 100644 packages/gql/scripts/verify-generated-sync.mjs create mode 100644 packages/gql/src/codegen-entrypoint.test.ts create mode 100644 packages/gql/src/custom-generated-guards.test.mjs create mode 100644 packages/gql/src/deprecation-transformer.test.ts create mode 100644 packages/gql/src/generated-sync-manifest.test.mjs create mode 100644 packages/gql/src/generated-sync-verifier.test.mjs create mode 100644 packages/gql/src/kotlin-platform-postprocess.test.mjs create mode 100644 packages/gql/src/schema-contract.test.ts create mode 100644 packages/gql/src/schema-deprecations.test.mjs create mode 100644 packages/gql/src/schema-files.test.mjs create mode 100644 packages/gql/src/schema-markers.test.mjs create mode 100644 packages/gql/src/schema-source-utils.test.mjs create mode 100644 scripts/agent/context-files.ts create mode 100644 scripts/assert-clean-worktree.mjs create mode 100644 scripts/assert-clean-worktree.test.mjs diff --git a/.claude/commands/review-pr.md b/.claude/commands/review-pr.md index 2cffa5d7c..f61f2aa85 100644 --- a/.claude/commands/review-pr.md +++ b/.claude/commands/review-pr.md @@ -27,7 +27,7 @@ Based on changed files, run these checks BEFORE committing: When reviewing, check these project-specific rules: - **iOS functions**: Must end with `IOS` suffix (e.g., `syncIOS`) - **Android functions in packages/google**: NO `Android` suffix (it's Android-only) -- **Generated files**: Do NOT edit `packages/apple/Sources/Models/Types.swift` or `packages/google/openiap/src/main/Types.kt` +- **Generated files**: Do NOT edit `packages/apple/Sources/Models/Types.swift` or `packages/google/openiap/src/main/java/dev/hyo/openiap/Types.kt` See [CLAUDE.md](../../CLAUDE.md) and [knowledge/internal/](../../knowledge/internal/) for full conventions. diff --git a/.claude/commands/verify-all.md b/.claude/commands/verify-all.md index 44e267acb..ba4729164 100644 --- a/.claude/commands/verify-all.md +++ b/.claude/commands/verify-all.md @@ -27,7 +27,6 @@ set -euo pipefail # Regenerate the schema SSOT, run codegen tests, and sync every wrapper first. (cd packages/gql && bun run generate && bun run test) -bash scripts/sync-versions.sh # Docs formatting, typecheck, and production bundle (cd packages/docs && bun run format:check && bun run build) @@ -193,34 +192,18 @@ git diff --check ### 2. Type Consistency -Verify `DuplicatePurchase` (and any new ErrorCode) exists in ALL generated types: +Verify the manifest-owned generated graph and cross-SDK contracts: ```bash set -euo pipefail -missing=0 -for f in packages/gql/src/generated/types.ts \ - libraries/react-native-iap/src/types.ts \ - libraries/expo-iap/src/types.ts \ - libraries/flutter_inapp_purchase/lib/types.dart \ - libraries/godot-iap/addons/godot-iap/types.gd \ - packages/apple/Sources/Models/Types.swift \ - packages/google/openiap/src/main/java/dev/hyo/openiap/Types.kt \ - libraries/maui-iap/src/OpenIap.Maui/Types.cs; do - expected='DuplicatePurchase' - if [[ "$f" = *.gd ]]; then - expected='DUPLICATE_PURCHASE' - fi - if grep -q "$expected" "$f"; then - echo "$f: OK" - else - echo "$f: MISSING" >&2 - missing=1 - fi -done -exit "$missing" +(cd packages/gql && bun run test) +bun run audit:parity ``` +The GQL suite derives source/target paths from +`packages/gql/generated-sync-manifest.mjs`; do not add a hard-coded file loop. + Also verify `COMMON_ERROR_CODE_MAP` in react-native-iap and expo-iap includes all ErrorCode entries: - `libraries/react-native-iap/src/utils/errorMapping.ts` @@ -334,7 +317,6 @@ the complete platform matrix in step 1. set -euo pipefail (cd packages/gql && bun run generate && bun run test) -bash scripts/sync-versions.sh (cd packages/docs && bun run format:check && bun run build) (cd packages/apple && swift test) (cd packages/google && ./gradlew \ diff --git a/.claude/guides/04-apple-package.md b/.claude/guides/04-apple-package.md index 03c8943a6..75acbd8c3 100644 --- a/.claude/guides/04-apple-package.md +++ b/.claude/guides/04-apple-package.md @@ -23,8 +23,6 @@ packages/apple/ │ ├── ProductManager.swift # Thread-safe product caching │ └── IapStatus.swift # UI status for SwiftUI ├── Tests/ -├── scripts/ -│ └── generate-types.sh # Type generation script └── openiap-versions.json # Version management ``` @@ -60,11 +58,8 @@ Types.swift is auto-generated from GraphQL schema. **Never edit Types.swift directly!** ```bash -# Generate types -./scripts/generate-types.sh - -# Or with specific version -OPENIAP_GQL_VERSION=1.0.10 ./scripts/generate-types.sh +# From the monorepo root: generate all languages and sync manifest targets +cd packages/gql && bun run generate ``` ## Version Management diff --git a/.claude/guides/06-gql-package.md b/.claude/guides/06-gql-package.md index fb5e8adff..834191161 100644 --- a/.claude/guides/06-gql-package.md +++ b/.claude/guides/06-gql-package.md @@ -1,43 +1,12 @@ # GQL Package Guide -Location: `packages/gql/` +The GraphQL package's canonical instructions live in: -## Overview +- `packages/gql/CONVENTION.md` — schema organization, marker/deprecation + contracts, supported generation commands, and generated-file rules. +- `knowledge/internal/04-platform-packages.md` — platform sync and SDK parity. +- `knowledge/internal/07-docs-consistency.md` — documentation and generated + API SSOT requirements. -Central GraphQL schema generating types for all platforms. - -## Directory Structure - -```text -packages/gql/ -├── src/ -│ ├── api.graphql # Main API schema -│ ├── api-ios.graphql # iOS-specific extensions -│ ├── api-android.graphql # Android-specific extensions -│ └── generated/ -│ ├── types.ts # TypeScript -│ ├── Types.swift # Swift -│ ├── Types.kt # Kotlin -│ └── types.dart # Dart -└── package.json -``` - -## Type Generation - -```bash -cd packages/gql - -bun run generate # All types -bun run generate:swift # Swift only -bun run generate:kotlin # Kotlin only -bun run sync # Copy to packages -``` - -## Deprecation in GraphQL - -```graphql -validateReceipt(options: ReceiptValidationProps!): ReceiptValidationResult! - @deprecated(reason: "Use verifyPurchase") -``` - -Generates appropriate annotations for each platform. +Do not duplicate those rules here. Read all three before changing +`packages/gql/`, then use the repository-owned `bun run generate` workflow. diff --git a/.github/pr-previews/openiap-codegen-ssot.jpg b/.github/pr-previews/openiap-codegen-ssot.jpg new file mode 100644 index 0000000000000000000000000000000000000000..7437211d473d154ea0278308928971eaff5704ee GIT binary patch literal 71372 zcmeFZ1yo$kvM9U>5J>PK!6Ct22be(u!GpUD1d@TEgUuj;5C|j?2(Cecy9Wk$cXxLk zBmx8iJic?zedl}sUH9H||M%{F|62d)S-W?2S9f)F@7`UzyQ=19=4J&Tex{(T0AOGM z0LJYPxLL!PQI?f8)6~{bP*#=yE20Ou4OsjD0CzyTXe&NtG&C}1#98@kjX!WNU-*I%V_=qyXb1m-oh5QFuT>C;Fo`bVNNc8ln=QrBLTN}{iCfv!XF&tTRQ0J+}?3- zFIoTrXakDC(?9xuo4yT>82}*m696zH{+{2@*x=o z1vMQD12qj3Ed>Q5HzN}(8wV!`6$1}H4?8~#I|ut8L@;o0aPH&YC%u24l%1M_n*Bc= zZdw2m+&jV8J(w7efjcA^m?RiC9RS_!aNohixDEf(TL26D4(44PjC;7ZXaiyZ0~7NO zCN?%U7BCY_ptjBn9Ork@qvS#^ ztrRS*vM*izVk*9ZEc}lZGFDIYURT@G)HlPe zeXzWday|9(20$kL;-h*#c&)sQe#LjD+#E%Ow)h)4-DEY}#4Yv?Li0_P{JC(Kdih}k z6*?0({F=y(XkjkL7G<#Nic=ikFOqU1+a>1?o z$z0cJv}Srf_9oBfpq8m@wyAl#f=^Eg>j37BurnCLLV3;&z*<^qYy5FLJ8>HSBtW-0QwX=*pohG^?(7aYh#J+B;B3X}q%H@N<#CdpH zV4IgfCokK6RV=G%7tn!;lBR0AqA=^2D*a}&J_k*qlWVR3JF9lrlPii5V%-3@9Hl*e zeCILa1DJze$?$G(sgB^V|x9pfr*98d33RqU$=TG;V4Y{=r=_1i~+Rg zW4UaF^7)#|Akr@?XwY|UDX*J`V0CwMUVvaMZfUx)fAszM5%)ie3LS5N9Rd35X|)@m zsnepc^fpOib!jSkeMwPvr8(r2qtE`aVl3{=Ccm&Gwisi2f3Npn0C|c2V)Lo`+`Pzo ztl>A8Am(Gy*v zFT1f(kGU)P!o3W;!QBLy%%$M9Ch|4B?5TAgvPvg;VV$mCqerlX>DKg6H=2|$&5dF2 z@i*;jkKrCA1;VO1sdKv)NMXUDQ-Kf13azZtQsd9Nzx=lFyU)~=lNVf&$oY|;0l$a{ z*2Eeap}$ZfxZ73*n))P3vq*A)6l^&udN(KlDst-f4?rxI-)Awnr{7ItDXcq5cG3TV zI~p7^VQvRbXg&3BPq_g;rsjzsWUO$WORel_U)$^Hc^80Lww};su(nLuu(Y;#nK!HB zGCMZy=Tyr$p=QuO%G&yZ(gS4&e#%A2x}E;;f*z>jgzXf@7_T5pUI>+kyv+ru(X#65 zgifMNICRU;3t#@_J^xb_sB$~~xSCFVc6G=&MR80V0NS`(-z&lhpEZiiAmuGmEfPz) zf3@nH^)YOwT$ddmT^8N|KTG$1{!YI(THYrjyaDQ(ZvbPWZRUSf=RYYp{!vl$4+)t6 zy|%G)P$^Z&sFrWC7B&uwS}TeZuF%NGcKLlxkl-1Z9Vh#;gs*w8`qth0{Xj92F9g>= z;~ipLtM#pJfZS^kn4((om91AAWSz-#zR(9~zX2}!{5HP1R~ryDhTZ@V%H(Pebk7Xl zPTT++T({!s2AF(edjotUzX76efW0CuKKf1#S1Ys=oxh9|!*dy~BedHM@Uq5dbD<~_ z+Fh=*2)#6Io+)AY<<9me%wK9b{?CjrRr6QDt2aQrONCs67rEr*+CKf0QM=}ELsz7y zSw6G3(C`(xABV>&r&nR&yHFzk@L+_Wc`x+MSC`|KyfU>8qS4(JzNwPnfm$}MN>d7d6a>f57!R(23{Uw28+S>w8mhP9ibwucUL@M4dZh*Gd-wP>C zW;Z~f{%fJ%&s_e3y;qdaJJt`EenPrT>9`(9dex6E_&6rdmF**#@f!c@lgYbY0;#j&}anHw3oPU#6f11VDnl}LV(a*p@pT%4H#Xbh5n?&D+ZU93M_p8Yp;9|rF z{I4thA@Tl1ywxpX2e* z>F^)D4cTgP!evsn`I!L}_;t#()KlU4T<2eY=ZGyWcS+b`x!GX-5ZQTs$9c>k{=wRE zuFSbr<7=u!ZGMZ_3?2HF|ER?I?=NTOgxqT#PqxJA_&24hnK9l+0<7qThaLa@?ESyk z7XCv_%FNGN>h$h4f1rBTY3!PNchj{%7Q)FaYL z-=)M#UyQa5xTw|KT0WdkUX?Y66o1aRjdpwjKeXwj?w11mb5dlq{U-=DGdr^$mE%5Eci*#HRWip%{t+7%@$Wkj2B1ki zpt$mM&NDw9k)kZm9ecp%G>{_#c{B~*w4Pt(`cl%|Z@fb%JXRga2J&Nn`4I<=ju^pk zYe&@m((y^n5&y(iSm4D=WFO0tsEraU}WJ9pF2(8<+JC8!*`-4AiuU5d}j z_5A460)GfcEQT(Kwg3kbPs@r>hUk+82j`CdP;Q6ZFvu%iaXH7x5=5&<>!a-Vgb73Y zUr;9V_Hl}%y+W@;N15oml)9_I?TITg3xw$^;X#~KMp`;^tX_N6Q+>UUf=+5g7pZZ_ zQ6lpWn*#lmAA{_;5y?3(J$*WAb`K^{d-av5**%VBLyq+9xR>puNL%LG`sf9zgXBU^ zD0XV%k!y8hq`tnQ_LNSmp0>?TIR&R_f{+A8^JH&wGFlZKY&F%jg7j~?4a$5XVjFhJ z5OI}vK-k*dUd4`Bvhb7>-JvrzKFKZX%=in95L+C=i_poCGUaw{hukBnH$`Z-`_wf% zR*9)fFEjH~#gk~i7s{FU2zmusBSgB;hMLe~*9MVa?0IYXH7;9G)|b%CMA`?&hV5Tx zs4*lpo(DHYU6O+mx0UGhFFwVDq91_Usi{>0#QTH9zu=!2*_;R1dO2kTXS@Jl88!k6 z&!@y8iPMpJn9*gDyHw`t;NjHQgasM%HgyZu%>y=htJxJ5D20TDu7Ndb>S^&efa!#T zSD){`-iIpCPm)doRsMN#o&1%2L;_ixezlU1_86i2@|+Y3?{&&scdW6w5_>Z37m2UOt=d7^ld zIX!(Bzw(x$8bi5Z_ib3YH-lborC8QS;Z#UWYOZ;fENbarZbF3WXg|KtqbmT}}4N2r8YO!A@l~iO1^14Is>IQhV_X&=9 zfG*6=Nk}}*Ny#vHaN3zhJRbTr%6+d6$M66-O*jmy9Sb^&h8oHpIdTf535T9B8TdT^ zeNdYx7FU3yR~s8EKdF7slF!1CWj!-MX6z`h)lj8_R7ITCTTLc1)}h2^v%9jJ%3b4< z$sq};E!ZX*=QQ!fr=c`$^-^z6#U5EE^+{9|y5%n1(AP48_Bx=OfTOM`cDR$D7&)P; z{9?1esrKgQ+tG>Sb*LlEqHDmElHG8>O{fO~W=Ex?+cK9rH8#>B*9f|E*Q;rh_c>dB zl!qnyLHTifZF0?Hn5$%+$r`3Kb7+OTzEJzp1LRN1?9t;vP~x+Bx;AkYIoHP}_iQ#2 zh8^cFc`scIcs~@@sD< z4jMHr9U2?vH)X3urzR_y;cDNgMZovrY~+)pJHq32LbPE~f*k1smfgJg5bwC?E3J1w zl!oc!x9`QJdbT@Y*bc-T;h9GWd+p>R`vy$GZyUwA^+4A z@hRN~SxFus(=R8z>~3x7b(r8OXzAFlXJQ0VgT>K&%LLx;_EhdrGgqCRTB5@I4&Br1 zX{fEMnGLC!%Z8nWQ@bZI((w!(b`4onBhZrx%Ry1I+OQ=EJzvoXpd3S-yK_+5S2C$(H#`A7y=;{mJ{iWvBKvO<+`TCr* zgmv}enx;09CK+y@C_8g1uET7zvz2;MuD!Nv`&u`HD~x*eqU2F3ij#v8md|0Pe{J^> zJA{##pYSq#5}LKC{t+dEA4}SekB^M%S?LnUWEo-uu&77fE3V>}jqrVSq0BXk44|p= zJ=p?GpFqm%pRExxj3LBXsv39#0{v@`IsI<8k3*D;!UMqq@r|v;^vs2|9uBr#o!%AS z_0-@6S-yR8B@^`MM}sgG7eQISIPDK4*Y73KZYj_ol=79K7HEd5R1x4ZCcf^dGmuzTghUh0V6{cvy9PJ zjEcsp^|dQ%->Is02sN#z4PH9&Gf@1}jBNvoW&K)}7*wxc{043Hyp+xq@zZc#LbML- zIoJi}{mt{DXx;PG_(F=OCI6cUT#KmB>-ssqA~Z*#WfJatNjy6zL=Uy6ptIPCar44R zZINVwVjD~XSjz(OJtj6R!cQ#@29V3-(5OwQjscSb_3kF}JyFiH2#8( zWxZ66DV^hf?l_5DV6$N_ohIGOK^cnai^C=Df4Ii9{?r_UX69MyzW0Jh9)fLfm~MdC z*YSk9(Bd1wR(G6&+RNVHp|$-|Z&2MwhvFe&bjoG?6M4@%O&zJ!fK8~H`VHX8ZiS#e zYQX&Pyy%5~bnN&Xj|q$J+fhE!=lP|?sAoZJ0sAUXSjE;MK`b|brZGR^d~pM(4ZE1Y z86MOArRo`&iu*^UYEi-f)()&atR<99ifykU=&ZhQ$BXn(uOzbzeAk`wWHndJ5b7{Z z15Mqx^wy*DXb&NUNSeJR+_O(G9VL1RBC900R47Evhf)Q_xS$eqC~g2nRue=nvuk_# z_zp^zoQU}O^-zgHjm@H#DS@KI4uSn}2my7VdT#DYgK0JAR<~}~)I@+@ge^6T8*6LN zln~lM!;JhJ^n*pYjy#X4>{|Blqp-*h`RM30tJxih2Wz|6OifgN70RQtFKQQL@=y$_ zp{L^zmQZ$SPWjHR0vd9D#&Vsr{`%7N>YehX6sNJ#2NfC+zn4k}V|)$%!+ONK)YX-S zu(~tX(v8#oShxoB7O(x`TmLmdH!f(2>tW6Hh$~#D@EhHnYKfo)K4u|!w?g6WS*0_9 zeeo)nC3<|K*D0&MJW_v3vm#~nJ+UmbxqQ0R;{sc16NFs0d80>eXEw81t~Lu&09x%? zX2eb#i_X_Gj{Ei}$C}u>OqK5WaoT%@mIuGM zz>;)lR!7`=!^vyq0SQeqcKY6_L4_R67{A05({}=+!GIz2De)Jlh-uq3n<@cDw+t26juUzvl ztxeXCAIHzI9Xlqv{qLZ-Q@aH^ODUc9DA@m9HCj`If*@`m(Z~Nt)%Fi)ZPY8(RUV#fyc6vd%Wg#Agj2aKg@ z=q|R!c?BW0>zS#s;T@#l@W>kaQ3vGFG+c4eF<>8xAEOZl=gqXSz)?~6HQ6Q^_g*-x z-Sf}0j2g$zNvMh%t*{Md(wU>vl`Dz}q!3mdrvVU68ymc~qc;Hb+^5r${M#xyZnnk1 z=3NMDO-DPE;lXC{YieJxIOftD{X&bP&?Eb>F}l#cDB1elRgVHLSVmaYRq709zE&l2b}&WXq2*UkxmdN$T#Fl@ z)Kq3MOoSM%#+1p@I>aa_V5SnP11&?kFrR5jiLKTCsJjte@)|TS2k#YXTpJu_+R$#j z#NNi4-2V0-5w}O^B|cjt6{5F`H;u*XDHGw9CrVB{=XJDK6a}ADk&$MP(bQX+JTxu* zr&QFK{#BMG%Q=MemNI$utr9L>dtDV^13bX&!*x!)@e0T z{{WdC^}8z$5~TJyJI{hUNz(b^iqVC_VMX>lfjY7nGvK*^d;4XQPiI|McWkKi=WGv0 zLCgmMnmZl#r{6=w>hs2%q#3mD>I!u5hWU=kJ4=-4q__I_=EBz_ZKYKW`rgZ|T>U8zpjjQJis{>DvyNrH zA+G7v?aUBdR08dZI zO49S}s6^R8+w(l)KU=$1W4jic-5HkZJWC!0cwT19x!z$l=-KWiT@5qGaAtC+&yXti={F*+0+jp8A4CgCKs07ixxKOuaJ(cfJqL2u!o-6LbuG8S-Q3ybBeAZBJqMNH zWg0`-eyJ6ZNBYx=8NZg?z})oxhQ@AKm^BLp^MRy#Dk|6@uJ>AkkufE#A`e$Y2?^eU z?tTHhhJ&;;h!^T<@@q#wF5>1Q-jnPj%>in&T&-KP9Ix`# zzh|Pdj4D<;LnbG~aB?=yPKx8$&m*d0wwf|FogzDr`v7wy#x^49-^3;+B0U!+3SQ_o zPf```K>=`a=nx6N(rGod@%Fp?7IS+Ir(?WF9S>zBzTeLrNKDgf*l|3PVKbbE)$WFb z)-mo{md)E{S1HpXk1LFWGEyO&fvRIu0;K?W_Oyo?9$90Ij!V)zrLm9H`s4$tJyr=? zFgEOIKjj^|w|lECUCHYv)>qMzpUdLT%}Gk0(pIF!4ioEjh+ClxbV+)>W}v1aPmVPS z+1+-PPE`;Tq?HChMKdt0=%r@_CThy?;IKH)hg#j5+l^7JYMQ%AS%C5HdM9-g+zWRW z3;lj?0+8?oUusT9gf4YX-^!L`OI$$pA`cB_WNQ5f1LrzD6C+YiQ{pf|6(ch4ptNL* zRbTvMg6mKA2J`QAB_pFnV<>kL*Gch1VPEramk57#^_F$SnjiYK-x?WL{_Jq7-^U)^ zFkb)b@ykW2!(=a}=Ja8gS*T-W_2~y@VeIH}Oe+r(wD-BzlBS_0d9ogQTMT*zHthzF?t=PV8sk&{ud z#+ln~ooNY@WF~Zq${&3?4Gv*!*k;QIg#A2|4=b)h6$#qv!XAHDWRb7=HY@alq7Ou} zm`4C5ZCb}#B44o6vyvIb52+=9&no_Yp9zq(S}FE(P6jeKv}y{|%xcngP6 zcL96_Pj?@mx0=w2F{A8UQa2m+mF!xOh*Gve@o4)p84v zJ~CTe&0$?gg@uecOi#=(b{|@$dgI80Y19i`GN1i4S#VF>*=%Zl2E;yoz+<$ghi^|v~c0G`*L7p ze8AE|gOCZM`wfYIlV{4bTwica=@0!x&hxxHQ#Fmg0HuW;+fOrXvU4M@4t&(s>gA^$5nwja^NWNPMh%)~;(%$t5*j0Jf=fNp!M56+84hoCnM?`c- z<~M3QB{rUf=B;H_jkPRmDTvQaN8&n;}=wWhPKwJ$qsKXmC!*p06*)i^)Rp<=G;1{=A@q>jQ0-d`e=f z4u5be{-TjpYFMQXR>_b3_AH@*HLo^;!Td#AnOuk`}*|(EDiC6GK@kXeTWkANg9Y= zQ_vMLnCRBPA{p3H#Om_+u|m-h;J4y=d%)SG1QD|>xwQ$KJGx|eZfuW#I` zd3xc|8@*St7cGPynI9@>Ve~WB?oLNCu(nFKy*?wRI9%I9-WX8jw~5bq3E>xC597D( z#Uf;5gvqo=O*3t675z5ZRZUb`nU+c!mMT_g^{qMsfl~a<%Q|&r z*ma;}f4t>XRkSSEtB#14y;pQRbAr>t)w%vD&8Ac1f2qL@11yQB@};*5Gd3b z7n2#&9i7decXB*vbV$q0Eu66O*s5J)G+s?fNW`&8Tbtl~JBcAT$XIt47 zA)VVSKPnkjSAMVUf?zZm2=GjybhIs!SaVoq_$rodLdLU@^VBpo%Z`mopFZ*m;1Jq7 z+F00+bCWa>LXl;@Tgk+W6{0dtt2P$2mY{+}4$jALqoByZfLiqAxE3W2-3G6(AzEh; z{<&3kCMzz7BO^2aO>z5UBAY3n;-+(Eq>0yIY zIu9_msnPG{Vd1lY6>xqfevOdnV4CG!rh`N`>49#07o#2gx*wZsnc2|#)Z`emz8Xsy zpEg{uqIPlLK}&|*B=WO4uYe-0YG~19s38G>zss=X)khfB53iyw1YzGwRpt`k38}Pa zTXBcKL22-Jxo?i9oj*i#$9?7z9xRFrjn>toP953HV6k{FR-PgCc?L7(jO~$J*@>`z z9VJak)pzOX0QmsQUN`5!bscCEnhJ+OPvhVF1l2U7am^nI78wOs@m0AkjSsp(nO;sQ zq$a8prOrSdrc~6(>oQWljaC$J8g5y%<#!n|^VogDf4hSv_Ad04Cbs`mv3ZR0&}k<) z8%M636+om+Ehf>*Wdq%4C>+?&2kDqE)wd{D9 zSr*@eW2;4I5bXT{;@`3lz0{h zdkTRiy98%%r`VD_FR&wDXo=mtFTb%u6hDp&{&`r8wn;zLvpIcmsFxA#!WOY&L$`b7 znZw(k-mTx183;^Yi-yk2fh@x=6W7E$FMJlEtSr68u-rLgiCCT*u~ zObwh$A3Kn}(i`gIu%Z5~)6Zn-#V?AG^;h@&foK?UH8E0DSNow_HXaI`l+CnyqPRSBG%Y+LxJX5Ikhj5y^?Ncyn>RwV}KUkRD!x|aY8y8YLXk(}DyrLvCW-yPYc;OUh z`%qtyEAX+1l$8)k+(QRr1*IF{s8uzrSCL#7_3PKS(RO5ExT0`KRm5g49mrm^`-~TY z+J_qV8tI9k`uQZ+RTRuZ!80B_hs_I%;#(kaM_&!jJ#N&qbkFnxQ75@ThF0uG6kGJ>$SdS532*k{L07fP0dw#YR8+ zTlwN8dC5Kx_m9U$xo{A7cx1BQRAcrE4zUvW3|c;K504cJDz&d2*Bonm^#<9cXcrsu zg5|;mlj@+7fa%4OvJ9f_N9Ck|2{tsbXFY@O0fjC1qk{z^+6Or5jZYVy%TS3J5_-H* zY)u&Sc|U4U`VajS)N#86nmjkP=lBTI?>@76VfTfB2OTFOL9szgQA09;4dG=i4l~+> z+bbw1PuC9F(Pa-}30E`CGCsg?;D>PQR+Hi(+Q|++%dm8|y=F^Ir4Yg2=yHyygJS;Z zHEH{N$e8b{>PlD?GDsU9ThcCq7&7s6k2M|swAH*=o& z{kmEw=kQi$vX)v}QWE|3Lq zrEHbr+)rD+M>TAe3rHp!7#`e!L{Cp(AhCjp>QiKTF-$t#@NEPzV+){v$K*r(PUPn9 zFX?J0yhLgZbl27?+s#aege237(>*Jzn#m~0&GD9pt|sPJG~vz?HlX-jOkDBr7%R-~ z=+Wu5S1C;oOE@A7ds=%a?To2Mt9>y{=R7jFTq~dU((W4fQjVC?M-~x#7eiVW=)kQ^ zPvl?iJ}z!t5T4R(WsV)K$WU5Kia}mhP^=KyX}QtTg)+j#557tWkX6~|X4D{*3S7B| z-xyPmQJa`7TW`E5*$)ejFv~eOd1{s8^bzQ;6YHPD=)u^qZIPV4K$=l`=Y>5z)d!Ud zohFAeU8tR1f3KG3)L(V-@Jy-{dpl;G3+E~@K@)h*XV0mq7=IJ=4881DKj1j1)RE}p zeUQkeMkm1sm$G;hZp};)4`3qvT)fJY3-TSKly-3w!SJp-J)v{DKsr2RP9>Zyu?Yzw34+=LP#)+M9sGJGHV)WKARPEsN??{(yw(z-he z5iiO#D}DyM^y~7Ph#p0y+T<^FRq~59+o*Wn=-LQi~xDgAnuY*hX0T4kkf{kC6b5>>nw5toiIWSPA* zckd7)N~A44XH7gC9669@m-@?8s`fE#p_gtFjd;hyhUpyU-)fuHWWvEAFG0$$uqg9k zRA4yVpz(I3M4vn*wqKnyw)tYJWkiz734uLf7OVd33QBt*+GQ3Uwo=FG#Z&UNTwgFD zW+Vw28zl>pP3)FXhhb_hh&sJ-f^56?&)6k6*4*2O$`IK>;H8bJuhUeu6VmfY>cy@H zy-G!e8RPVfs;S$w=fLj8JOmFBz7pO&EzfoKB{m#(tZ19Ns9E*Pju=%G$PGvz3InUQ zeyFR9Tk&tYT$kLp7vX|!7nP8VSH#A|{3IEhn5YdQjKTLiK@;;IIHq*%#}_nA>jrgH zR0>)r;eGB&>Jg(LW&A@$IEOACaG^h(dKv z88natwaw;GxM8p#;;JX~{qxxbz+zuke&F`Sea4Arz8+p5VeG6X?E1!vfLnf4UKr^t z+^|tb3B!Ei5=&4w?o{vw`GYS9&hke1aA3$eRkWdUZ&(rj#R{`zp7-UdQ$fu%q@uz~ zMSEq$Vum#9ZrB%Tvc&zLT~(p>-$Sz=MAO1U8rL%yj4=?^;>js}c!zXpnPFAY!%DNY z?s*)t^xH7m+5{Z$GduNbdaNQgiqFMA4LFJpB8O_4Qp!-Tsk`|3>1&4&$&O2&E7%%F z9R|<3)@%db4Z#(EI15VjEv#-UQMqluy}o{@YDG5L?eIpG_%wktRPOz_989X+825!k z#EJ|Uv*A33rqu13G46&vkJrL{Qoet(`fE>PS*6{SzKXh5>#PiK+Dh)=RPl|8qkLGZ zg#{1Kkm$m$*z)ZzLqkCAZLQ8}C^;llE_oL}#6B>g8HM+541K%q)4Z(}QO+XAOyE(p zL)5~1;=xnn;TGHN^mN3xwo>dIox&5ym=m3z=Tn1f+OL95U~8v|E4V#kdm{G-Ct{xZ zPU^R^7+6)w2vPn@y_?DJ06AD7E&DAniEtM$Htj7J$sWaBN?th^03#t`5hR=Gi>hk-`6ysj~-vqu~_mDjIpU`asQO> zEakZlW)-_5UhQRqxB)CYiWsYn_w(~gcrua19}7}ILM+Y?D2=D_Nuz{{tPaK63hI`( z^BYj8*x^sncs8rkfnxkZ3YRc8<}Zc8A5`3mv*1ED+uU&RrSq-`r{{p1+XC?jE|}i( z?nOb8NyT@jY87=l%%uLXm&Pp1D(~FXQxmX$4(;U|=|11TJ%#!vhr=?l8uB3&W_Xxh zj7c!0zGA8WFV0c_1*YQvCHCk4pC!u0-tsOKA5iu+xxN8vxNFCLPhSMzuhdW8{lnTF z{Lkb+N8_K9;h*oq|Bsh~K++UaFJly;q>SEs^9axP41YGbz)o(u)pSD?JnOow4!p>l2g&D`?)y;Tq^1YU#~EAUZ`iuTxQxQ2*|azZ-B(6nd)|@O$!qUAenWPPCtA?}ml^Eu-{ zZK?dSXErd8(C>HY2%ND3;*nUsPEsH4#{9)40h*Pa7o13)z!)3z{UL5P0vFcroG1K4 ztY-*fdE3_CJOrQ&_#weyGn#qYgpqG!nB~(r&3MLl9!HzX>PVj_7~!oV%LQNI5WNnk zPCTmdyN}ybEftp@6FDh$530tL+@)$LOS`qsD4x#5#I#2tB+JZ8kSN-b|&g)sFy~5bAU1ilV%$&TeC=vW*{2E_K0rJ)oPo8(yp}1}m6*sG_Wu(0t zNk$T16ZYM=X^|&(Dlt!9-KO?g#PKl)W7(ZJWFT+q8nj)Q7#~Tv*ZI zOzSxl>U+PH7=lB5KUt6{OB4m;E;E;k*-?%uO99x$|BGkwpC`%)&FxE%goPl6d@U`MwSlsqg_g(c3CK4dkkc6%E=s-m2ntr){mCO+ETB{ zb28VVRUi^I~?M zH;ay0Ky@@RtQD51u-Z=5y3CYO5+2I6>{_pH!m-P>UI!{F$WACB0T(ltiORlnNMXdM z^CL`c_T#Bq&}t()J?mCc_Oc5Z%t;=mwG2m?^?Ox=31(ODV?~r#!s0%h+6PPZwT-lvYT__&(~0guHllP@ zuC8esc=0QYci5tyBfpNJq<86>W)KV1qC?bo&t-PZY^OMlIUK6@;zU*@mHiUMGU8`5 zBm#(kl_BzHAkgub>TdV>u#dCrHtFJmf7+yHyAEc`s~kGOq3uBgabhy#xh>%O^@5G; zb!f=6T{dz=Td6-q+@>Jkq}%2BRC%#YS5cs-k`uV+iCRp(;vBYX1=Li92UUBhKP5u# zIlCS?hMP3{9w#6r$iX>+u)|d*g&Z|*wesD)9{PtVqppcXdYF*VnuTg;F|Ql+>ZJ)V zB;EbAPWha(Q=^MkQ^lBvBG}WaT|-a%#RDmQA=+NZyLho=$7F(d$+m|&pgL4o-Pgc* zrHgc3j>FM`m0n!NUIuR0E({xSbh*(98!bd#ym66dAON-ed}_QO+rlU;M+D>x9>gMc zX<7Bdm2R4=F^w;w_2S&k&)eG=b@2<%nh^Pn6Wi_e&|-k_q{G5b0s?!*W(uMmP`nZ9EkV1Xxok#AMmEQ zEPZf#X|G?yGvU^+nMW=tt3a=^F`lc140E1&0QAtLrQUj$J~{QA@~S3$^tRfgB8&}w zW*0$vrXZnr71rK9Vp1bAh(swHNglEnSQU~J!7?8x{b5{e{~}WhES^mAq2t7L$ILNm zvh!YU7R(5nn`)SqLKqbKjpj<(bQ;5%P7(5PhhEjPt{#6TjVyg{BX=L2I3-fT4L(zs!@A62Jb_V0Jiu z6oi6Wv1^tte`!IhVdH$WQ#Ng_mm|N>hO@X*l$ts?({P5f{e)~0BwRiy9&+biYs)PB zQmtO*^}2C=DD6Qa_(jfjlt7jpTmtTPcc%RvgCBvJjSreM$mfVN{HmKH8GZu@85f)I z(T|N)qYT!Ya`gSPZU9W->LXsmRK9@bAo!WrVrsCm&F1#d-mBX1A`dD@Azq#{Up6JG zUX#wY3a?OaH8^x27W4pOMDJ6yO&h(X>0aGWSCuSyQ9@riZjv{vLKd}P^xRYn>qGJM zfUo$hwKLOVw}y?(SFM@6rr{*-+W8cx5>dr5#d|3y(_wlN{nzsYq<$q83`4zeTN^!4h>p5Q#M7ARr@ zckz!do~P{`{LqJ4G&%EFR`#o5tLjd~9eTB6W=$M4t`qWDJIsef-v)e7Q_lu6Ut1l9f`n@pwq^bOB5w$ zbIj_+EPA@{e&4g4(P8`pFHYKfB*~W@fD4XCYmezP#j-VQrLJE0!7PKGDU?nn<7+p2 zP^kIa=i5p``JIF93N3{1u&d_Ziy2SrXjv2|F5-{qMC^Y$zID@QFEm;O60rDw4sOk zw2HiUX`S1%bVX;8IS)I|L1A?!EyTjsrQ8ZWpy=Z_t@0rhYKfI#YEx^H2an@ri2Qbn>YByY8K?>w z60Tve%$}$975Hh&Kyo};yujpMnwy0hnVx6)P-5A%wuh=Mb+)lj48mwx$Iiv^j{h>p zt0(fQn5FB(1!umc{b)^y?~03{cw1A+Iykq&1TO5Bq}LR@va^u~7h;~+E}!#%hZqt) z+`*V3%;P&x-L-R{-NxT7(-BIUhf;8|O86INYMxdv;}G`D!pof&1HuEitEN46I16}R zDnI8u|&2p89uKL~Qu5>q}ZCbPe zGw_ZeglB4q%T+Q1&$4UMpNTmJF^a+=FT?v!XO45^CYCQjgBZbi}T(eK^v=4VT!+GL+0mWV6lnQqY z9cWF#Go?e}?@*p9x4qd9vU%CCc`|MMU8E=Nv?xZ0>w>*Y(c|@moHc|`X;$Frdot71 z5S?4Y9VM#tD2gcW(dc(5=^g)V>gh3UGi&?fPC9r=CxgXk{-DJ-E1@SmjF%d0PM)BG zcJ#|SKjS&}7nL#uvc;8raM@Q8wZ;7W0p|6TnD*=9d{WF&%3zk~i|29B#*(a$n)hDS++IzNUD9qr2Fl=*H@9A_LMoWnl}-cUK3lva5d8)FPR4K>eBI7o z0hNMEOw{2XRn#}5B@JjMp>mFBTN$+ASkItNqT$aDe74_ zI6S)$#6N+5Irth8uy`(cb!UsdTZnpYYs10<#<~4b%n#ZLB4D!Nu!jFIzLW& zFj4kO-X?ulE7|Wu;SBd7)0ulz8XPAw&+&nq^={2Wen9Y`W&^$Wo=(zoY)YyREkAYN zN_fE*MgSu9;v3P6jCI()W=3VDdd&2wD|^6|K+QQvB87(sDGjb$PT%JTDe!EKU{|{K*^J8!rch57#j(pu&??N!$YGI^FGNa`=S)^f9ASmiQ{rwx}TB zxjhI(5D%p*ry}sM^X+PFNy{w>>4a)gz5B5l1frF`Uv*U=^ z_50V8ogh>nr?IVJzCTB(%FH%su4`UJKq&OWPX)<=O z^S5w({CKr|CgsfdaDhw4Qo_JUds@vgm!#X)Ed#6M%Y4x6(Ks>U<4KnR-`n~LXO`Wn z*v8a*&wJ8X-nE9>QBK`)XUmj%624G+{_ToR{!?I$XH*m99PU(~qC;vjrNWi$bT{oD z{;N1Y=_460HH+0RbIEKLapPRujU(etwxim|JC^Z0gfK*wuQW4l*lGw51utc#rqdu# z*9l!yMM*`DbrPLr>qL1stvUfqojW3k4N*opvuRiFJb~n1-8RnWsnV+y`TQanrL<(3 z9j{D?IY+=u8kr}Kbx@n6LDs8idzIvb8Zpg-a^(a=T7I{k!L(pATQ!*PB9mzuck}GT z-nP2071BPm{aEZyA=hV@&GmZd;~j?d8OGZBT{lQvvG8v?9v^C_D^%UFgGl1JYUR(7 zc-rx=(B!GWkgArdb#)Q8MnsH>EEf6XSB)26Ct7u(iB?^w72L?pxCU^u>5$jd#gcIC zpl8*B^}L3IYjJv|rgj-hOqw|YGl-2jc_VofO9kCG48^r9>FBqcXO#;rh|)eaKUi5) z@VvDlIH;3i^A9*6)BjBM3KSbV73`V+Dyl%?*4s&{2=gTZ?HJj_&tKb*{)U(JsLT-U zww(a3P*$1+NgP$9&hA#f4F%Yz`#a^go{3HhvQir><<0hESw>c?DEEr5mwuD+YIoAq zKXxZYu>Y7E)vj=xsBriDJxqhFO&C+4^@{l<4+`@?xS(0=8*|VbGiA=i_m*7?k9! ztv}FFB0Z90m>-ESofjIGei|v>PSKJ1m(}H&gcdGM((9?UuUVeO2OYXN!#qOzT3zhX zDOvn?g>S8rX^wq?ZeOj^YO%wrmmcUCk?;QbF)yX3qYzQ_+vZm@407ao|1Itf|G;0C z`Lo{u9?K2v53YaS{zH@Q`JYpo=708};NCpD0Wf~Y;eowxfEFJXzdO83qiV3UOR0b} zm&&AAWV|waJHxGVaPwoP+l3MsyPM(duP(B)<6;m4^m6eU*ES|LD8o7$`r%Hq1Y7558w7pE0VTdTz58;p$>tDWj6Yss+RIrSWYeVDHnK+N&hfc-LG+&2brBD-*eA+|6zm?M#xBce$O+%bAIMWt;zK+1W+c)gT&!6YGkxV=}ePZhI!-W zHeG2-%99%e`8x5#PSCZgFyv5YgBA1zcE&-1U`xv%xSL!fs~D?1<^8$annKmfi0&mm z7)LKPeKWsaR)5_Z!pH3zToU1@`z>ublZB@$NbrEQj$o<1?&%Z;*Pk6j(@^q#d<2uw zD&sSs6q2uL(fuCH7=w+{0^`nW0{L!N|04VbObcK64VbgwJA<16NxvR8*kbRi7Z>|@ zRd+|;t)embMGf(pvyX=Btp91pI3_>ZW;=D(tSPB%unkO>2r^?;5Ngi-(!QafDizw9 zXv>XR7Cbb+qQm?3CzU#4MugY=NQ+g>E%2$@f|oL0|s z6QO$SB~26R8`m1O^MOYKT2@+XIp7`RA<`4{@t8f}qn?!2ZsY)0YjzO+dtZ629q=K& zJ7r>e!JX-fcKU7j60H8q zxCPQ9Teo1XY2YZnodW>>4WL55jSYQVy`#|yD?o_8v3Mdv>|g+;++I#Oc^*yqR2Q@r z#S})6L|NH-TZwYJCC08iYOSilfXu&1xX2|-(o8{uyZJ9hTIqE?Qh(`ZAj3GU>~ zqVzjCdbL1KkmGj|M19qp(5>excJbE(zP??CV~ys~JMPsdmV2)@|8MDk&U* z=Z^PUugM%VJVbFldmC0Q=}US~^FGhMb8^+i&VD8-DanocW-FT*FUI?F45}A`YPoyU zLBgxGr52T8QXzPoM;5;wP&~!Es8zbKH*JZePk*JyaBDln(nv5&Wi@`!HOa@(BZmDP zkrUL_!Muh9=96DlpiC97fiOUPb7Qrz9En6J>8MEs`peAPF8G4Z;v8~dZDSA5G>hun z=0qPPzZxYr5PLd@0&G5oO7KVV*vk;zOd?^tg>awCU|lhY$d>1IFS$GWr8L-!Sf(Xi z(pn&sq@q}HEj+h;8Ds*c(%a3EliwmK1NM@9($8DFCh93F)!Av0%8EQ8>w0kxOY+Nu5n9~Jm+^_l@PCZ>^W66ca>tc$8WyNk73Y5elT#KtKB;x)RP;H*~kVbP}i8X-USPIIYRhc@SJX0sW1n%U(G0E3N?AL6@xIww33>i`&V7tYc zs)3zhUaV@jx}a{x^X8lEAy&DW0o<9Ui}#^v>zxZu%RuqKBP0IYvB<&#c!F49RmAo5 zZErc2WFXZ~-vwD?3!~kON_OfP+~Bz8t%ru{W^NI!Ws9$B27DYL?Rc%nrNCerf_kNs z&>#>Hl8(V4!nt}k#I6S_NUL=VdUHtl0{PMC9UW-RzFXU~qkVIMVie5W=k?B~Js11q z^90(CVDfvyoKR3$7ny(GDM;9>09BPMLrJ_feQuCKipEm6TX~*VbZgrR9Er=t;i3w1 z5rsTuLJxDoI4LRGXgO&Lo(alo3&`S+E^t@97!WWENP2lW(QJchR z^gV;{nTx(qpH|N1yhh0>@EMM6mk^G`h-nBe#~O5pG#59ejFYyer^B2o6JgP0ur{+L29C4(w1EnhwI5PFl&nPFr{UoRcx*R3h5WP2~L zp2tQ=K>;RYfr(kxGBJm8Q&~U0{sx80P$nB2K|mn4#Cu4ihm;!B%C9gdO;22Yo}eqA z3@b@Sx05)01-{sqzky&Z9m+pS;3(8q*s0G`&nGa(?LSiZ0rqL0+deP_8%t?~m??aU zbZPGod+q1ba!y5a@Xo3vYuXHS6Y4qVT2`7$==t{39k}vVw#~}a7-Xj|WhZUYMs*-wW7-a_L?()f!UWdjB$~Wf;Qan4jslx6TP}n#|Bvp=AJR0*2_Dnzq z_oDTCIRVO_s|Kk9pPh)7+!y8TOq~K>->$3(a(c|z>g{5!8<6B&*t~C18nqXTO^A?Z zeT70IakI8uq9UTviC1m?C(aXiCN5?(=~*a_b&VdK4Y-ZdyRtN>r+*pI1E=z;!M$_c z>+P4c!0?qb?W>>r3+Ng7s&Z!^&r*aOpXwvY9B=NkM^@AP7XwX+Q zfFx26Eyij_abjxzaarRupLYYJRkH18^Z$)6S7NF79=LJDtYA0fJ;Vp-UV7#&b+E9QOu@+F_whyZ(vz^H|*9BTk-9$TZ?&DItJ>v7*R?ZA^- zEbi@;#+;k%S;XEB4=lN5>^lF3M`#<06P;5V6VsOPS-C71o{#<nGt2GvWTYAb&Nt`LIH{m5m>kXa#VZ%b zr^KJ^rr2#9D0`U(gX%z7;~noaX#>|vH$4MK9+o6eR~6nUfm zTK3)b8^Zp?)byb?PEHp8HYJR*T>j@G8W;4X$87byyv+$kMet=~g<6u8cU6|TN`j?& zjSl}{re6L)fG>}p1HN1IgQPOjTUhDzRR4(wNM~yOqiUcY%QGbBHHh#mIlJk~n3!Y= zE%o%^V-~yiNHXKb-S)*jnPoGFV6~}#kxUnv= zOk#%j!)WR-$va5isPq+i_mpOQEmJ}O{3b=0F)hVc@&4mD74Q>wE3?sAK1V7E0)2M& zCN3P0=oRd}wroI!=E{kp-7IW7Gumniit2>GRIh&y=ATkF;@&lCbt9ckH%Ap}vp)iL zU6~Upg>V~ywR{RDrZ&o=>)*9RErDm&fY}VayLKmaiO`dR+!2OH<~5k9yCTlg3hhl# z&tt{p5-hmL$%#*@hA}pbd6a z2Ro=Bb%qe1msuacvq>JoXLTX4#+DFsi+Tn5+u36uPF`pm=1OSIC#^B^X+<^2a`N#Y zWR-Bh{i=?lm^J6LnW)`2@kQ>J$L6+RqpXMN%*br>04;%(_m;LCN4c$CsV@rS_o99S zd?TSd_3{c=TEBkUyzg^R?bj=)zKj39@eEg@&1SC`TKax&b2mLHKAlx+{HTl814!Xy zpgt$+7ILNE@j)?WoV0!ek#UWZ6%Fm$$Me{8(fUHTQ=5%G-YXzWr(*QH;}Ht&mIOs5 zqsf(Il%~EnMj07HJ416tBQMBeJ?g_=Q10fYma2eLF$$ich^IZ5#4)(CYh-YmHeyxA z{2y-0hJ%jKia}-d#CwxoH4Y*Lmv!!6Ps^H7(tn&2PJea?*}^44msN`~BVh07ly%7N z`IQOvB5V(U>%!nRkP`lFuFMeA)R1DzC7N#mbSZe=wc}6YO&DJmR_5roxav_9<^EC4 z2W~xMd!*((An<@zosPbN2aR+ zSPh(=8x^a0t%gle(duu&-8Ciy--sFN@5g?lKV^(5WsSuiL@D(O;AD~o-?mGozptlMQ3!n0c0Vl9%3Pm5_Ki7m@68-Suk>jUvC6ZU%LdYuZR*>)p~N8&B+GsCR@vs6h)Gkj+0j|uq&`_M@CHEYC%XqL8OVLsnHGasoDWAxeLu1Q2Dk$yyTcQ8n)_ZHFq% zYr5-A7}Y?ORd?F;#>p(y3<&rxCN1oxj#%m8)Aw+*6_P9m>HD)>;})i$CTO$AQ>?l3 z+Z&Bis2MwGj2%1Jrgv{H(nUlp7Z{2%q;--eurkW1x7%e<0NWWtCuQv^xZI`{ovP z81k5aw@0pO(LS0~9^K}Z*r*X!rDBgdF`e~?_v?h$4-;^!BL&6BN{UsBT`~P+v7NZU zGrK?LZ@OQFhc*y!9wLHngE{qM=4h8DDX8^${j#Ojn<6033LmP^p>XFsZb|}OoI$cb z7K3s#sC(`AY)~4mf1v0z>S_e`&^%Dd8%!WU>b`_E&yrzV4WgowviG_G_&>hcI zmH1U732^6k$#)R^!TO+NnenDj6d6EX_73Q7W%~;j4^QneHK1bDxcbjo@ zwezf!+ajENJ1F`83=L7#w*Rx*1f5Dk1aEl;mrL#^e9tQQ{ZHqB{+v~f#K?*G2=Z3g zo^4art<;?E^|iuFLSG@oQk))e`vSm~Vc7V|PrfsCs5&W@>AhWxjUF@RtfA+^S>aXD zU)|1&>yq}|4{>&amAS_(7II?=u{9&{g1#;sBN*wC((If;+7C;ixC5!iUeBfHM1o9Z zRK~@%GJuhr^*(ETzgqcr~^Q|L`^UBxP&+K$Y}ulUPb;{kAkZ#Pv*pW)MkoRL}3v*-5>V? zgTuvSY8`D3qutI7EH%8NoC7{(dZ5y%G#>uvsdYp!)fgEFK!@oldPD{NP7yI z))FbEqeoVqI=qtFfnSwzVYwp#@eGoxX**02DUJg=dR_A#N8W6xoDcn=1Zo358?H|{ z8=A4*anR{YDn?93N8kc5u~~kljemDbDxipS zbZK?5(1}JfYWmMnU#)Ig;f<vNjNB0>>0 zm5WDl8~bj4LAPonTl(CNj_0R=B<`@htx7dvXmLT?#H^~&y+Z68n3_WRL!|KK(x%;j z$O2z#fWZ3>XvyLnU&Wd6JCa9H0&2{$FYt@jJy#~|ipDkm4*tt2;R`Q@hW8`y$~maI zF)&23_63-!^J+QtUvcYFet~9$aAnJ0(rU9TzkdS}n{cx_m=x3ozoVouVy4~wwN^tM zi_JM!sg1geuAf1hEB3Qr>iHgF=i(JrwrY!a=d!<4OW1*Ha-5=H#+`VM?_O@6AQJ36 zpgV{U6SDJuL3Z{N*m{hi_R|HwC}MP5|IiM*=o_7J#PaQ*Zz@h*UK!pV#pX^fy<0jqnVZ+yS*Wt#Kcdg)AC#_8-Q94b9u%Vge^cUJve6C9*PK z1&xoCM%e8|$GUo9Cb8a~dYx>xTP=aC7VI*D)vO`xX8sSL{WRiAbAAK^$sY17TjOoK zn>!mOCphcZ8~T*|X81OeA~Fj1JSo zOI?WIT2(JGP|e6r%4;>Wm^9f;%$?Sr@~%l3yPlDk%$l z?e56xa^UZ9(Nh?=M$cnN(dh+$&glr`qSK++>|n(*5D=+gZrQi*Q=Cv+O6(SZ7nbdM zpDDCb%LXjuncJKgw!mO<<3tS6?2a4q=4StLl<&lnfTS#kJ?GBl6yAfr{KoyDAa%o$ z!!M?@{7t=s6(yAral~E3RrwU##j;6$c)99c{cdx&wYXviu~yRR0YloW#kQ5LafkP< z6eH^Q!=AhfJQF=6BI8OY{ul*%BU0bgPO@^}(VKW#?INj+x5KIX4*dnGGml zz3n}mqHm%l*-jo0g_at-MdgnuzCo@Ti-moE>$OONkWg~TV^p^|XUcXh`+Am(2N;+d zzh-}M$vsygk=}Ilj8Pe{S`#H(Fo{ios-iE#6B1Aftx?MBn_S1*3p_kC&K;eUUO{eD zI_9bUX5uLyCa*{IzhSnw=eRJHmounQVM?+Pb1%!V)Z)1q(f{1UUF)32`lvzsMw9Sn z!+$3fN|tOdbqk%vmz6zndy^~^VW%1fPtu(LGPp)DQh)aE`_LVfJK)P&pPWp@C<{PR zu5eeuALd7q{aG(ddGCe4pPmY|ml=2)%Ww@lVQ9LMqxw>$eLZOz*z3aEsL9xmXMHiZ zr9L(q6Rg_-HslqDB{5NO$mI0SWTzbp7gU3GXY_c?Ga!;`x1=)@3TlRNnzmXSkc-!< zAKjPQ$b4dfCbfngvPvNPKPXl-OpkkyvEO-}#jzRNHTfV>6F3Z#5?TvL0dU`BSXM0r z!e_(d%(|^~D9t}XZ37LsISauZs=!_uWIYIo8Q{O7^;DUce3?zY4(NGE1%MzSF={dxH5TyL2eEYP zTy7XE7D9MXcwBq9R6>0ziN@AaZ#0NS;i91XT!t}p;Nxf=y@ zSJ1&_;ZUafC%w&rfyt@gs+~Je>Tl)GaK-s%QKBG1(vTf446Du-k zvG~hl@&B-H(_A^VjWN|TKM{h-!vi>FoG%8vNqV~bJSpjAsrA6c6s)4hJa?kj(+GXR z%PEhS*d6c<2d^@xxz%L!?4UJzQ}UhxHfpaw4fX455PN5?CVRd+vqtA-U=lcBOj`H0 zVDzMWQ$>={yBDuzN#X${;9w8g&+z|ylf_vigCV}^RlRgucx;p%H}x7I?Pq&BXM`}F ziH(BM-)99EPPSL`0 zX4dGtCjVu6E?scIXq2pP^ibw_eN#sjB{mzNI+16KwH&)NEqvL=I|c&7tV8c#op4^z zo?vT+#d8cj`(j~v){tSukI{G=vlydDw#^N=4qg%(AbbtT(R%!b=K$0X=3Y$S@9UP; zy)vQDNmUd?8CuEic2r@bcZ+NOItudbSMI9wOzaiv@2cm<{7V0JxyQY& zOXSJiy@4wJnc*$}amQ&qb8ui`z8$fK%DquD;F7p3!4MFW#~9Hw z6Uh>^{Tl#v8}aQ{e9R*sv+SS%Mg{Jwl~5GOB`pgj1`W%?V zK@S@$YqFK78_}rR*RgR72$D&y!rL)(fygz&U6*&x&5&QpVOd|5YVG_*ZgW1*VPj_Q_A?u?O(Zqw6o;N!At+X}A4nh~tJ1EDq+U^)Bi0k$9~ z@7VLRDw-UsRQ&~v1%FJx78L}Ig7J-<=!rWUc}1z2D#1hx(ramMaFAVFwGCsjBpnKdkE(D7vgvNPCGv^~%gLKX2S-lU*`{A*SY_VYrPh z2H?PrU*^C3vx}9JCg9JAI@)vbH&?cq4fqz8mefp_Iir-fKY77jo5@AK>}pMOF6)i6 zp_z@O2MrzGg&5lxnq>$s>93AAa#RwF0^sc_)S9yarBPdn)wj{$8D@T)r`-1D=O)E`pJBAeto#Fd(~JZi;IUhEh?bIWX3rxSwTX_ zN~sPcK2!Rs)_>9=Ke__0axN7|h%Wx4A;Qn*B*WU;9ZM0|Fy|USR+>qXtYzvYE>abARzqiW+@3UcdbF-_U_!6`g!1X~c+l7I5 zKB0;{w*515eUc7L)_+{}Y}vhI8&{TJhPYs3EaRJCB^{&I$Q5m@m6WuMvV6ts;K-@c zmd%}m7_swXii>#kDGj+muB!%o=>`I%mk7ao7CTFK@O81%0wo{QKaT$DJ^Ch+Nt%D+ zvFrs~ExSwu*7|rTJrKC_N?zf>N@U>fK@dZTxIre2eAWqpn`4)!2-? z{1G@A1t)qoj@a|^q%)vgk=aLND!XJD{wH~XKR4!I8g=?7$baa_FFiel>HDoUy*Za8 z_*Hp*Bfd1`t?&F9nx6fG|Bz>}f-n#ktRTI=(jU@5AWYb;^O+c44BVffZms(|DLtO8 z3L;SmHFbxVL`rCh2$Vm%fRsXCrf&RI8=;4?Nec={2LNK92|_z0wW)>H0n$G2b6DYae)QX1Y?s0 z*|46$v;yVuvmI6R{y05Das75;`ed~0XP{4$%3-F@p64!IGLyp}6U~}NLDQtR;jMK? zuqio=jt)GSO>H3ReiO?lXXJdp%*14S;wu4i6Vej)jwT>jK~ZGey!GY>wV%!UTieVtW$W&5fH^U>bjZ4fqtWcT`>&{ z!_S70GVjcnVjCi~Hcqv5Hq4675^xt?*`6uK*S$!F|jJPlE2jjTu%&TlXq9`I?L zP()Y_=?R%#b+~)%B80O)$LXPBPdnsajrsC(bNYH{|>OxQYkHez}-jR*52k-zsB1Z0{iEW??T zPtm?t7#V-qmeR~m=k4qbr1 zHD|Lo=|cGNkVA3vJ_)UZV+szc_osw``1|yUkDKL1S^h#{{Ds2!3x)9)3ga&n#$PCm zzfc%|p)meJVf=-{_zQ*cFQG6#HpZO5vaNg;J^StJMpejmYBcL5#z0i|z|)cKbzVhI zQoyJvSe-Rv9*5L^z!lkFI9*wPY`+qRxgBiu6@s*LxRhXJtV2F?teL5IF_(a! z3h?tvQo0B7X^_)k|CSV%`wiG-{ZuwV%ATkg3mPn|sSWRatCpO^G`x02AAs1MysI$qUhsI9kw4_=U3S(kw z0Ij#sfZIGQDJy$6giz|)G2-_e4xy+p*5(a5#f`Wu=K|}@jZHjDaQ@Mr8`sAnvl%_B zY*CC+29y*ts_DG9Xuyv6>{~E&3wJWAjafoHP^epedwfkHe_6s;B9xdMzh^&7b6G`Y z^Yl#>F6NsM^m`iD_-bbuqM^mBbgG@;nG0LbzSWh5^x>;);33);hH^_2NXI= zv9iCuId9&R1%X^IbQiyzp*t%fEVu`M-%GW}&;hdRm@)iRNVLM$gjrG?f)%e%#GI#?5G7xn8PP2r}llPfQNrxNf*5@+|>Iqc}Y%;s<#dk zoYKxgn1Oisd;m~fJ*V3?vy2;yU&h=c4Kkuk;wkPM4C+VZL?1gvGFcXo6Q=*XH1xmd z-iw7lmmh!01D{R&23$5<{)b;cQUCZ1|8AcDzt|MCw9OI!IXT2^{zLufBWN&+JVA2w zEI}odq1x(+I!3*A+|#b1{0N1OcK$TnC1stB!ylJn=p{H)Nn@LeEtq8g5GKDGO2&2~ zf7#78^>~xpHq@35E5Ak~$P`}16XpSrf?rtQ4h*c9di^P8QRlvAVfMLQ>?XhJ`_q#3=#>8` zkZASb58@1;U%ii-hNWkE)_}dA4k=T0Jbl)y@W>|r2s3}O zt9*Ca=R0!Db20QNBfYiuJ@27xJ+JTyXYuHGRK226r}In3fmi7}-DHHaRrwRGj;m2;dlXj;&k zKJL$f=cR--FQ%sIi7iLCq^VrB)eKfyI9`O_PwP4c+qDe=-3_B&XYWo=)|kkw@WQxa z8`@5Q!Mgph#F%aQM>1~c=5COMNx+pm`&NEIN;ht_yz}pjT;ml)Y(IPV;gZaMluvZ1 z&xoKxOoiicit}YIO^K1Taw>{!9s!@=;{0jJ{1=~PAFjVUGeWVp*ti!rXo)bBZVI5{ zyUhkmtvLw6y)%^LK?icpv1eOxb2>L7yC+Oq`A4_BTL+dqg;tpN{2Pl8Y6b>|5;~5T z?vraiiob#-SgN30DhROe`MTx)r$x<#sT~DImJDDsw^01YmR|q6g!3B0nU}CssykQO z-c6mTqtJO6JF&abS-#%}U-`7H0|oCVvtP7zZ8n|#eu-R3BU%3@yXFFK-K4U2?EQc= zMH>6X4hX7vvEQBUOKTAR)9LA(>@-YB@N0pKE?x<@Sd*$3#@8@+ z%I{rfhfh)Sd@?wTj1^hF8>V7wyZ$sF2#Z)8U8b1231qR`DwMGt)pd$gc7;P;tYqp% z?`9vv6L0_hqWk|U+o8B3c26l(h`dBqSH(!=o`#3?jK4jvxtXU!Neoan(f?%!o-Z!i z@YpndI^*5^ww`XY+5VBR4y+7`%%E3SxOsQ#%2TLKRk`>#XF`wVY6bD%` z-~s}XxyfTU@^@Cv+KgQ0e1oB3UK>q)m$?5YsB5-{(Vr*_E;6rjK&YOC3g|^JU-J`0 zXu-qXiklf?*k!nR1(RK2k`ZxGTWSMgEdSJ$rz#(G1w0j&jSh}3yf0T`b$9uZgjn^N zW&{jB-^sE|t*Y&6&X)7m@(1V32?IjM5Bd~M({kA1tI_%~1^IL)qIr~4hW^a>9U2G0 zBvn2g9+{6X7SY3u^fk3dcF}g>XR3>HJOQBcpyCNSisVlt{Wq}P``r~jYz;ISX1<4; z<+wYdP#wJI<4?@X^d%e?Cw(WwyYwv&W%QSCi>{W zm8bgio9t+N2MQqRC9jQ%nIWLY6pDPEPmuN?2Z=IPeq9SwKf)nZN*!HV?u$NkzJ#dC z&TM=f4W3PXw?`{K1GI~i7)4mjh+Em-vED~R)>o2~djW1;PTrb7EM)gI#-W}$p?NRH z1%J;2L_E|Ol{Wc57KNg4l50=>9vyjnE z_4^X6&D*Q=fun)!BFSVRJi$WY1R5qot;VKY0jH84K$FZkozS}>f!BDv)RJe9S9~6Lc@A1={zBX^q_9Z2pE_h5`nXO%>Zj_ ztt!c&>#5_t>jeb_AZhtOKkKAq!peg}Y#x;$7xhexT^dX~?*>d|P4V=G{03A?h6S{A zkNDvBrF<|Sp;5DXUJZ25%mn3~SK16!g4p%fc%PB45V$oHstNY{FOI%T*xy5j>nfy} zc4U8bcG0q)zxIAKeb$%#GI6-WrK)Ad%NstrakDxLg4jQlGdd8!XZ=NX?xDTi9>6F?4$+kO!u6Qz6gl1a zTGUf8uvYCiz-CZc_*3b6bV&%iifW{TI&Q$Talne^VQE>37jh*jHH9MAc4om{pD(IB zXeob2nfFz#WM5ySCC$Rt_^QHlcOafi^*yU?oa9MFTuB|s<_@H+qG~L^pa4+{tod9* zKEYla@a-kTM$KUMhQ>ZE;|@+35ZG+Wk%1+!=`yirbz?O`XHZZcyK7tz+O@~=zx(5S z2Gd*q>9|iq572B&)Wiikaul)S#f+izR_rg+W(}TO%Sm}liGUX-)v>wto z2JRx<+t*>;C3!cF^y;qSL_M;8S?YbvWM^($v$WEPyJ7pk%mc1S*JdC60Tu%WVLY27 zsbKO(siR+wCccTx@^dB9+s}Pr%Dt-I$w2oP_lQWwbX%+!l87v$amQoM9I^ zmF{S8X@~Y^vpPqKcid76xu`)x@T^dn!t2?HUtIJC8K~PUI`}QTw3#@)Smr7L!@V-q zcA;dKRvzrHHTv$i0sm|nr~2mqBu@jems|-oJNVSX=MbO`*?YQfqL* z-GVUL^IAbQpGKv17l5gU6y??0Z2wg4x=dpN!e7yW6&8}sk0B37&QIHjS${o28U?z& z^hT{9xVD!yxNjZ}idJC=W@d!WJKRQ_neQ^1Ro0Sp*K+nV>KQa>J_f8mMTg$mqC%V* z2b7j5NES09Xpg#q6#bH?G|ux;C~eLG@w5TKI`yFpJ@(c?Wl6ym#5--!h{--IQi{^b zx8Be6O{ANCIYLiiW|$n}405^JzPoRID*=7Ohz}uYE<44+dFP~qa`E#*vv2?++{H~Z zPSxh|h$JhIPH6KO31)E#N36{#c)j1-J3Raea&EEn7`C@>|MO^vVQe@H#zu0NQf4ts z36OgkVK+((GM-(_{r&=gP5O7v8WB1Lhzc5y=?CM%fW-WHqhu%Ar&I(!(zrJ;VG^_*HC zw#Rl)w69#&+er(p`${?$_ImU2ZN~fv<#UrW*E#VYm|yXv8~g5`8xKI&SRPL)CEk*F zU+~uh{q-9C^*Q?MEArPe@_*M?(Mq zOVv+CI;#7%8yu(E7aR`-FAKE%*=73!386kZhRgI|^f+=Rv(>pq&9fGHeV z{Ojy=?~i9@P*@p!_phM%DICm!<`_^rYa_73n?yfXBxv0)os;x#d8me zYaf#`kWmP|kA5*gk_l~SG8(SOwxLXNlKBl_qGP6GBlJ$lSPot_}YiFw|epD01*kHdlF`nRM~R zfJ@Jjy{_!JQ`fL6@cYPu1g^&w@R*%u5!YX7c=S<&j_Orz9Hn0m!wdDd&Wn{b`mO&_{>H zl=AEb5F|d~jfL9o$R#1PUzx`!=SW^m^wV;zRvlgVGkIyc_#1Gbr)b&TFFqAA+3p5p zzA^LDaQ@FD!J~?s{`k=`Q?te+1?`=x_(F+L&POl}{ubpo$IkBBB93QQUmTq($zn+2 zi2gpF0jGiDfskN%cux4$ZJLXT<>luyV&DmgF=Go}9U6~EC<#+`rlqym!*kDFD12N$ zYcEcaJBeB>a7biWo|mrVqX(J<$0*-mRsKAknK@@vuIiW|+bK{-&HRu_1=wlNCr~&_ zkIVQC&<_@-pr8xHoGNR%WN^_K>II@u5sLX1zA9uT76P19pQTW?nJtgj@zQm8$5 z)4PiCYVTD0Q@|1EG;e{zfbyw6g)l|Ipoi;=jym0w8QZHA(;x`}{-0e;pi^}83o$-J z=#q5L!ebBQ-i9;uH(;xfcNxWFJa=xFZmCV*v#DOrgvit{q_*=$?;wAtB)L5{2(Q?! z{kN~sz5xW)IA$WPBPKaHGd(L6VIb&(S>~|yVJJ>JI;6jObJ0ym&_cSG z2UJV58K)|_N2yKe0+7HL*|F@pszXMv=V{e8 zsw9CPg3GE@Icl<_#yrxx8{`s$Bo|$9{3L+=3Ful$Gr4Am7ws9Iz%|}2X#POmEfu&p zlKK?X-m`gu+-j#9P&R*y#!Mw9E@30^ES|$NOzwJAPJo768KcQw}%4$&1jH z^VaqR1%SR_=q~M9hizv5NK7xyg?k3Dl3Zjx_+qVw8dk2phcWYsmA=pvAH0vMe2Q|c zMU2cFeUQP~g#NsuZztZ|+|59oXr={zAOHlJXnT4#+a!Obd?a@>BMuI0%=}#%jinh% zI3tz=Pi+IA+N-C4BL*Y-M%Qc|tUy*%T~kqyz21=$ogv(^T30(dic+>=?EU~^EJ^GmpTs~koAweECPk0nRttYU_-f>a$yO6)CdU0dV z>3n$L9zB-_tP*F{X<)!&l#9BddAiBob_FcE?(4ta>o>U!#4pkS8cod%txR}WYBV6Z zzT?uaZ#zPQaoldatQgVt{D94s*9|g?sl*G%t;bFPNWDkj=vBozQ7Qsa^Y%6XX9ze$<0IzrU70{_l)IkhNtM# zS3LDv@B(Z`?%l4b6iWsY8fn2yY3jfJAXivNd)Ffj%3DP^u1DyedCNTKp*t(uVC*0m zox}H6FL^j;5WagrAo(#!>t%#6k%K$~w2jPEE$raWMi_NEr(*_JttNr~v*tZi;uAL4 zQ$O5@-RKgY$AeVj2E)(IoM#mAEzX7CUu*yZ703FEsVM~#;D{CyFTD3xj4rY*?IoMY9vT9)|kiT+42`Z%$D z{^};pvvr;NP+i?MoMyHI9IF@hYYLg5Htpm!p*GsZ(Tzv94>}3rTsag)T)AHr)1W z*u^T3Ug35Tg`-VUMAr1FN`jS|TY*RRfL52QcjbwaO_Mq1?e&_`06Lh)u^j05$&QPq zOq*|2rQ#$M_DW{lVjPoemn}VDEDx#-m~qItG-{+3ta>Tby@`g6+%|zKX$@Ag_Yhgv z$9*)26Zi~G?wHPcTtyg3d*6T?oHaBKm#@Z>$~JiV(?p`Ly?iAEf%YaXmr+%5Ztd*e z^Sh@dr2UCp-aX;r z0IdgIPGY?<5I`<5()8^S45A+?fw2UPnRh7IXX<;Uq}{O|?>fT0PVyT=I25Btb!m(X zE-C5EjT5FJJ3fU(=HGzTar1ZG+RIYX_z9@&50!YzKnm|dvCj@|Z;q(?a*JPymlc#m z8GrLMy=WA$Oo=mVj=v<#{gN>b2x@BIto8BAnw4`v*2vr@1hRdQ zRNOc&lRD5tS(86Wwfn2B=6%E4g7~1<#Gi$q)@l_m2N2^XITSN_X`W1!frhy5q^iB+ z3VS|6!znNvB&{^pvndn(mMTA7Rm(dlvp?GS`)45qH}!ja5z0Q7dspQ7n%YDKN}b$d za2J;HcFbSNn8$S736l9ZlHw?GoEQza!_Irh0W-{uPF5qFQ)<~_^%saS=QSx$TvJ!; z(Z7D0lD!*acSq~p4o2lobtxGrgoc@ieNyz#FzdAm175IkB&UlLs(FUvO^iamBifzJlOotw219P*AOZ!x{BI>XSToh^%hHg|&#+sNDl$Rx#yVm<- zi2^~F12Z!2neX$mu$Tv3F*D3sb#s~i$^{zzqNST7Fg+B^+Fu4)Uu!*r1}WS%S3G&E zf850*1WA2Me9IH7OggyQJLybAnb?TT&AnNStBrbtc|4F%MC7RB=Gh2jU*AY3N8;35 z7ESimRXNST^71>+hy*tz5NdU7a4{?K5`H%XpH=_Vd>fAy5LVS8&2XQS%^&*<11RODPcwTO9r zOq4QFPU4(%BY46K(s{^&LvdjFYKQ~z+l?agX|?OM{xlA=s>H@g>!*~jo^6Wee11-l zgW|N*feqSPP#wTYo(!7<{o1(bv{@?hQN_uRtI3HhOM1SnYg?Z0BK9Rm(kfW^g=33M z)sAKwdy6%v1tlPEUc?{|?D@zX;gTi&3|T?Q{rv(lAC(Aox(LZ*w0(H~q3uILv3h>%${SJ%hOT38L00avXQ(cp2PEw@%a;RwNgOuC zmu^T+yH%i!4(FJQXZC_!_m#8$u&`Vxj4No%DzXKWYI57SwGdEQQmvCGN1-DjAPpxa zA)2>;%(B%d=o1geF)4PwKT^+3Yv$u0ey(ja(AA&+ZFqA1PCmKpa|eIM^+K3!ubuc~ z1wGM6IaBn(-TJ>ZMuMb3rPU7B-=oHNB29k=50p5Fq82MkoZURrwuDH)V@?`5*dsqK zRajDczF(7-Zdd69GL{)lSaFtBxf4JfKflOIs~14F4EIbuSZ}insAw|`(&k;6-qUgY z<4U9AV@GF$@rQ>vm=EIi|K5T6--RMX*}l)LYkxLyK|a9$)^lo0I8rrKD0pZz(}%~K z&t7TN8_>wipvv<^o#=cey(!L|45#_jXi`ruS!yXuUw$nD(JXL(GWHB5RTO&hcMkUWNfxTn;4UhT2SNoo6HgQh9h- zlIWML4rt5`GdIpjGSk|+Z%m$kAym88+StF}p`URJUMXZ0(=iPb5ZCMQ>rJ7LKGJ6b z-=UiZFw&~hC3eZVaG@uPgE-BMPhJilAy$rhM`uTCe7dS<8-Ol-4wMm_Rgzudv1~Jz z&Q4mX>iL`-_Q|I7{(>jB6Dee$z#ZH7GQv{|E^W7dKN&+WFGczIL4-Q;r zMFjfH(PhY4a!RoETpqId0MGC2;Hk>&4Dvz}Nsh17O=l;wnBn_& z@J1O^yXuHxj(_rB<|kU4g#|38cHPe|+8_~rOqZB^RYU3?Vx>7!dPOb`nOGG1?h}!S zy@iJ-w^1@4h%$WGbHlZWV-J5Dd@7|GNcK%{rD_rm4f6!aZRdSVfHzT^ z_|BWm(u~-C9`$YW?yKH^{Df&$(@0O9UkDuVHXRpL902S@3rS~Bo9J6xI%^+8VxQ8Jm-gJsD0(+67gz^W_$TpIl})*aB`- z>|B2^5WHeaSA1jUo;F@s3C%sFu{^NjVfCkDsXC%N(Wxrun_CpJlbo51ZmoSShTo#1 zL|)cv=}?^v54>yPD!yU&9^-dd0i_~o&`sKeeU%^{4ny5NdM&90#PsUP6W=pQy)>K! z_&ilbzBWaO1?S3CztUnJjk!9jtwf)t?pCnLkaKQ+mKomlP| zsmWcrNT?@N>Dx>GGp$bTrR^Uk>6BTwkgdJDIb*p^V{{`CZ_dcpbqe5k_oWhTWu-%P z{^jf_>!th}Sd~)Zey1jVg`10IcicpeTLcMt^PEK!RPq)Y#lb+$#l@7X3#x@xh>Bbx z^$edyQ=4%|HG~7G$2CYH`_c*1W?^z?119Hd^|vmB-)rdg*3 z>9oMAU(+5eEbTv`dc+zo-NgEb+nlK-CiYaoqF08(M^JN=Ul`$lpLO7@_~;y~_%EQY zuH>7E-f|Ex-Q{%K$YH*Sehb_e7H;mjdc12`M6?R?=$I5iDgG>QmLR!pzmGUDJ=BP?YfSd5BB@>G#7h6!Y za4RS;+;inscr5>Ep1P=^fU*V`=TH61mf8w$1R9&DJ6QlQ2Dm_1Znl^xsIsw&W(rOX zo^=gcQk~H?NpLYUWxv4`+W{?@oGo+--o9nP&<^dS-&W(g8aUBPJ2{Xe^Xi#W(L~c_ zI)U3d3+IcfN*K8}pouSW_p(zEqctW|+um{mO0JT0o2Rb8j(B>BuJo1!Poq%E7!yYmQ< z94p>$VnCNJCl#0T6SAkV*A~TMb%xJy9`wDb{lqhb7RdO<7;W72^25kh$2y?{)q+bw z(Ib6JZq{%`HY>$KLNYw5SLRg2ik{G1z;+L>m*Mz}59%Vdq)l2jV;nEXJl3(mI>Q=F z$mn}dN*bpnNCkQiHW)k3^0{EUd~i9Ku~VVAvb?C=;TeGs>Zh5awjP~<79*>6JSR>G)yq`%<6Acp-6xf4 zIDIQepRAofS?F`C59Vo{+MM2e$;?3+=5R0aBxh-?GMm|Xf(D$CKJu?+GH?{?zi14+ zA-4;9ml}DEPdZPQ_eA<4eXku+^j?L<4k0FR!Q|)(kO;G2kTa6lpk;zV0H_ z60sgid_+V0(G`2`js_L)&}K6h{i)dgFxHioMrb%i^5SQf0&8?0^%cM*_Yu)B0>$l+#@n`nyhR)Li+RgL@#|-c-i5h zOU|_1i~4qZmsDHx_zsdd-je2Icn6gZAHhPLyS8>rZK6B)Z+9H}@qMnYe1wElSv&80t}tZfzGG7*9?1OUVM!29(!}{ZSw#yzevci7 zMAn=s)!8X-up$p?!Vy4n`iErm&if6Km3kG$`yyLIItKPRpBUm86HAOMcIBPhRwt~R zGMVjsTE=e&k}T{;W32)s;V?iauF_<*8rxgjfvJnhZhDegMFp_t*fg&E$5D) zB%GK=MddT$EpL-qRoOP5lznIIj-p*|18SRyMkK1fAK4;(3jZml*fAO8K+vh(BR2MI zOJh`uhX=xSnRG$!(-W5V+jxwomSpo0|4c|tw#Ki#Xt)N~Got}4)rn;T zlB6+MX2i#2Ch|H~K;R$l#ahFe-?}~7n!GmetL%N)Iw8rzWJyIT_sfj0^+AGY2dM{ez3*B?Wwk~X%?A0D1${r+S-Dv_wNu|>t=f&H3AC-CZSlST5ztV$e7=M0a$} zT>QiF$MNhV?5+~LbEaqRxk>qX#I5`2=A&H%{Nmc{;KUFyJ5KZ`)b{!NG7ZrYgs)0V08cGcYfy$pwhDdU`+<{ zx=-6X$fhY>scYpdf#M8x#T z$a~LnBr`Lw(7#p+|1Z*)_CNU=>3UWs2D-YsdPXvJ29eK`)d@b4$|@=>IAE7Wt{tt; zPETtc<$FRhfDwCs*-BD2WpV?*G6UJ3eYOf{Sz-3}nWq`bS>+Hx09UTFw`uBhEnPq3 zA~f%}EHC+xrz0xZkeIx_z`C>3&&2WdPm87wPemJM(x;~940|=@&ulP}GB}J}q4T$~ z@iMY+ZXdPy#z@6lU>j#F#biAS^&x)J6|BwkMGD9ov_@ znAI9H{6f7BS8+XVSsbJ#N{Oshdcq2?0>^Vna+Ue8>hLAwt7%f{>eEh8w4*LTwPndr z)V7%cPUsFuXCL{^gYBhKp}lTM>$E{PuIN-dm5p!tc5>l~Gf6Dq*pJ(g(jrNkiyCvV zC3o`66LJvI{B0`zhwBNaay9E$E7ssM@UHp3E+f1C;hI+BtyTNU9X%L)wIl}qjn>E$`_QZ88^t$ z`!2rV-yrt$??M^=eV7L7m-h7;CH|_UiPW4IducMSaI@EvQm(V#f{PdfmfABK8pz{# zn9?76D^@Q(BD=o;jfIuN)|)jBar~`^5+AHW`r@aZPQIWL8ilwDUoDJ`IV;%W3;x8H z1fl$V7RvROIE%N3oDHrtRnP}T&-;l5Tejb+h7uE#g33_`ymP-Yo?>|4tmjqDfjhB= z+j*n7OL_Y)Q`kCc9sQBWqJl>KRaYPF!a|5t4%gBxGgO_~xi~RZF>cJm!;huy?GF-J zrtnAj_(iv^4-XEjPP_%B#Kp`Gx$I!>qPz72gO(d}kvhFKpwN=2Sv6SwGKNSUmYh5F zx?7j7x}w}$D|iZDvM22u0xyryP535;IR11~`PJBqWJtWqs3L2ct>zV0xkwAD^<16$ zj(c#UzKbKn>`Dx}z2HrgD{xJh?+}cp(baBRp<~)2^O=2iWS=v*bg*vC+B~kZeyB<4 z65C)0L|@i^b49t8sJb*Q@;#lHnU+GCx#>@f={_L zgtbn}UQkNGFAZ?|^{yeTDcFZ+t!44K+Q8E4vZ8npEwF;tN-T~|5V>SML1Q&Tn0B>@ z{~)10gy>^a;Jd>YQEV_!B2jrzf!!tsuHIfEV!?;^yKEX*RVee`*Nc5Zh~%uy357a) zyp+nhm@O+`ISwo+XNpvyVrdDsj^TyRvz>A|RjPAjZTeLf3#0tdNrlHm9_vdU{9`(x zcF^jBMYo1k)&|4J_y(Okv#+%YL6W_{n<3ZDPrT?)+*g})cIeFc)_RF3lzvch(dhJP zRsz>a)5e|au{=EZ)}>Q}P~N7)_6^fZX7pMaiRIvMqer89dzW;ht`7XGQ(m;&>(CuK zeZIvRW5;5MxP|DTwo6$p|9%^YVNW?LEgzzlyNvG|OE0Es*GM@#{~frf!&C~EGKXXf ziJ^?aA%!QTB6);2N!JQpMaNDD$60rQcLRsH9v?GbtCA}#HZ;g_NS@5BkVguTOfZsy z+6`V?xR^lH9BqS&TM!bJ4Eq=w#r|fZWHD?8S`5s}OR47!&hYpEDYpsHJ)tt?BFL{O zK<@X`ulxQ5`0|{2Kcx(+*-bULHZOM@{V-<7xA%xONZmu4kn~}n*b%f`LdnYS=`ycK zgAsGujHi)-;lrE-r+i_UL&GC~Y5WJY)7YTKVO^5f%SXI?&cLGTmXAYo=QB*@*tpXVD%;Xk$0b1|!fGc4jNK zn|-QM)*w|LXZD^?h5t@wQnwg#NDjRb%KamdqNLvHKlmu9h#opV1c6bJ;T6OcO|tv2{ZHNX zI20GYO(-XtM@~maz&9RzP$^$ko--O%OwkJ;zMgcrct* zYh-2@(Sl8A8?v9I$Qrp8g@sRr#R_=D(afLF-k_>k{ITMb+Q7a~pXs{M(-ga8BAzpR zm;Lj;g>}*EU-rl`yg%^q z2dk9hBSY^@N){z6f|`rjel2@ns!P2g7FkW5M;Dz(Yn2j_k2R!AMH(7zOCb-Pf~42f zXdnc+RdC__%RHu1<&_IUu|8}5tFcBUn-a`y=p$5-$)GEGH>UmKhaDY*1X^>;Mz|DX zEkF1wY=fSrDzd^K2#UA|LAF+&;NcPX+iZE!z2p8gi=wKno1S|Ag3Ao2+IdXtow4YW z`dMFfNT<1p{t|>QAG%tw^xj!l$N@UF8u&1Dn|O@m3r1=EEB=dXS%?pEbOzIln6_1K z#j(U@0^Ir)OBTC~1CJ<>9b1G;+{E>-2;h4dH#xgjG&!$Q;1X24aZXjI>obvP1Lbth zV>ilvja_%W%%Bsx`${aW&E`&SiQ5OprZ-x0q?MUf5x32aWVZF=!(G);+3>Gzq@0+Z z^+;5i)%uxandrTHM$Co)&?lLW`K`;!kOpio9IRQeSk4m{cH{7vSa!STX81JG;Li+t zR|FArv5xS%r5%ISL5SmDN;j@3!)4wX($`ai8eCkJ+-zkb3TYj92;-*b1>3B4VRxrd zY0AsvxXENPMdK9>Hw@hcGlMa8J4e#{ucrcC+c!2r)8=%4xU$C8H1n6~HL3kB4si-9 zln>|(RDQPNI*ROZX>=+xvKL8u>gILl<7^jWio}$*mNo{@GOli-Ela@Vjahlr48YiI z*4648n4i?Wy6)Cr-;T}p(_LB5`T4_$ti29B`K}dZ$UN|1HIFA2qrhY6B?plMPzRsd~ z|CG!iUe3~7UAD6Xz?NE8%Fp}F+qQW{6C-^#=bcKn={Q@L#MJaE-{xoeH-}ALaHS_@ zwH4*fzKiy=2IcbBsRAm35s3f+u|6>~bx>6r82Nn{p}S-)lC-nFba*B0{dyAjt2~+I z2BgSXr(Z50NwFohenTVhYe6Kosta_Eb7H5+3{Y}s5YHN3PzGd3}B>& z=dbU(k?|3%OtCF=^*F(BYfxEsnU3%`oz^QoZj2bRv$T;?#555T>BPjuWH|QA4ggji zm7X4MZu^S1B?Wu&oxjr?6WRvx#mpW`X{x^6Vz*Dca|Ancs7e#baRCFAnXNMb7a=E$ z+i`X34u&Z`L#&?)Y2&mVEZC5cHNG(I6F2Qn&I%V&HAt+s2yPaOK|IPG+Raf+)Dn6) zZj8VE+HK!u)0p|uIBKqK$VyDB;kYymvAX5x=p5+h=yvaGVbgBX&0fgOAjuPsP|LZN zOuA73;?UVvP#eWY`qdYJ<3bQqGtjF_kCqq;g){Vc~^4WC%D&} z3R+C1nMs&@bJs!6zKW%QCY=H=2X^7(dQxwSU~NOk3eNHsW;?^8{4sAAetI8ZsHh03 zAK>F-?%ggfGL6C9Awq9$&$wun`o&@M@1g)NU;MV)${vOH?oHSV3nQbuUO%N;wD!FI z5c$uR9p>>GiJLFM421%aVuh3KdZopBwwmU?v~MMTT)fu9ZLn1EN(Pqft3J$;MRj6}_1XXMS|#d=1=yUhjghF>u?x|3Qe zYwip6+ze91zr1Au*#mZ*fo^nRC#1Tw8l_uK<&LNCR54M*`)E}u(?u2nQwEVN39=Mm zGgZg*YIw^Jh4w~x;8F{hQ;B!_fY*NhxBK>8q0QiCOU@BdjB+?AYR!V2>X1&1I4B%< zAJiIW6dM)dV>mmTK5HbOD>E*nc8rxx4)zc;u~hD*$Tq*5@42Aoxy)4z&>~@y<{|N* z_MqZVEGGZtHCtf&CCuPNnh!5 zkEHklhT{e$XnoA}a7QdPec}W5$omf8=gqW?M~Y|tV=rDC3N=ef zB;>8zIq)&%LCeYllmA~)y8WL#{3aaWm1fGHXXt=jtU zAYR1V9l@H!&7KGQeqhM@h4g61Kh?U2E&gx>z?y#RT$&aUBEN0Llk-vSR#;Vsh|8>lN*i<-$e5Ev( zA*?!hCXnCDy*W%2iD&)~ixT~Z@${0~W7NdS+ou0vR@Hj{dF4On;6Lo3BcMQ;b7smz zcqZgkwMQq@6)iW_Ux5F5+lwcEt}qF@|6b?vztSN7@3cA(_%5SAc9@UN&czTx);n`5`pvX=LbS+zug2B z@f#-UBj9&OEF3=m)rk@oQ3Ty0L4gY6Uq;3}f1Dc4bKOi#UQie{k|_(oVOYhABW@G- zO3!u+(xLf6uf7yp4ykVOIkAj=<|F3v)6|yfQiCjb@523weRX8hX6`*F9j%l6f8puE?ywL)P-Rv@bNn>K~I1y5yRrIg0nM zST5+i_wR$-kdzgkn$9gNulW)a&7$al#97go$R?dg@!2z{-(D@WO1_oLHE`(KP`pN& z&ZvQr+F&gDqJ7HqhzdD9FHVO8~uxL>%zq(^yQl z&|?42w0a9a*8j6+OPi4RVD-2mNGiF83OyxUets~>+n?rNfI4T7P|yMfqU#8yT6qRM1DlP`q%zL&Vz)c_s|mwEhh zS|CAvEcNtwTVlP;D^PBJ|4r1T+||Brb_R3XwyNNYs-ccrT`5#TuD^iEPrt*?UuP_R`|nf4`j(Nhj0Y+Zq)-R&xG2l<%5 z)n1)mdJtPll@C8KrCyVtv#w9{WU%iP=(g<8oY^wDUqyDCkQ4vw&$xia5pY#q|Mm^5 zzGU-tk$;emiZ6@ptlw`%GhW-$Pk3pP$<=-0YyM2Ix+ zsZ%*9FS5y|EAXLgU2#b9JkwP@`o}tukM!ruS(_%zI@?r5T64LR^vumZUh-2}fDlBR zZk;e%jq4o49pds`;_v?gq^3uC6-r|o#K6WXw0b4Z;|SID3xp z+j7||0;|nl(k1@(X}C|+Hv;;}K{m9-%m~G9X6Gu(W_WGuMxz<b}j z;^wvg1#HNJJ8@$7ld6eC>Y8twvv+<OlxcO@6P0=O#jT}m~rnlK#-$q1dKyp{?R{|ksyQEJIZd?)p)FA}4jeHS?7WbQkT zla%%$JHK=6r`HU3!^q$#-G`aABqYPAC@7?^u*falg=20d$<R!AU$mVW*jxa%<4%^z<($nb3w`JClDgO`1@V|#v29M&Op^b*? zn>*pKls}K3Jo2{QS@-zk>@=%iQ(01}rmvjsJy=Kgf@Ym^u}#OzP~_s8On3BTy&KVjTsF|#FX z;p11_1HC#C^OG-tdsm^p{b#NHQV7+Fosg$apAyjM_Z3yqoR#cBpIMXnaC`FdBd`Zv zynr8WEK&lQVS-c1B!-{2^k2ZWOEbrw-BFwFWl2ez?mrMBu`uf6~TY@=C`u$zx>Ag*QZ7PlWp&xsKJRreTAtOrInbnqFZ6LAI=gPt~|RvR!1ww8mFhNI2k#~U$@Hv zZR-TSuNAYnYRmR>b!Ie7c#1Sa-U~4<8CvGPed6pQii?7Dgr+zLT@OB#*i<#Ni-?50 z``IBVt}qgQOrRRJfyu(PuWRj2=*#Bnt)X$B&vaGlq4l9K})ze_^ z)xre_weYhPxUGWLC@5rQuJ}N}O|XfGUF?$O%K_?T^vmBSs=91e?l>l@1beyXa|ob0 zow`eKQ{3N`&ax$4_&Vf$vr2`*NRDG@VeOp_))^722sBrAvGcpJxHPXa?;U(z zZPD32VmCLQS+jn<#NP7HV0FWJ>38W5?9}wK0)(E_Oq6Fg?)7I@MDBbRF;AyEwT%qS z(bp}L`Z~;+7l2dfa6-5zHNY=wC2C|h>3ry5&$c+NjMzE)p&SmkMgoxm@I_f^av*G6 zcZqTM1N8OW-bUKz7|BJA?SRj`b6BF>hh^~)i5-{pxDrq5&HzA=X$Mo{aqVaApo%Z& z^>R)R40lpKLDQHOJa>(_->0D`iF71Zu&Aus6!uuBGtv|;f)ruIA)LU}hCeLcS*2bvADr3l`0tlLL3pSVS&{)3RRC(`# z`!i#nW6ZxVzht(b--l$u9&&AlRxHd5L`&d5!I}~h_ZWM4@!H9en-gYdB!VOz`t4|! zmesO&j^p-~OWh$o?gRzF!_5`Fjh;)FR*scSzuhQKS zGvgt}jmD~?Pmxc=WQNIb3;U_3JTq7HE$PLVNJx9lc&0|E4T#7kR}k}PY%MoQB2Pyq zifp0xR{r{6__~263-?u@6S`*_$ZAJcLb`G)e5E$k|LKO~4IlUNZ^_4@41XebO)QKS z%U`S=ScvoML0|gX(x!b2Shv5P?FO&zHg>x2Usjs2?0DC;)C-8DrEcaWN6Getqco2% zJndzD+LQ67=a$}QX_!*y=@N&0ZQWTmnP*emL_tU8A4pDp&pAMw>QrNAw*kWFxM}=P zYu&HYYoN@h88&8CTA?m3x!9qZJtBS zu{>OIuVa5j8{vkC+KE$h9_7BMAtnm4TV0eDz8`L`+N`Q2F=dZTjs#XY6|y&06gWon z!PudHpp(BJ>_=0E(EEsTyV?(rQ;edfwb{O0e;jL>$r#J&q)KLRUfaOppax*2tyQ5N zD>>i7ApSp8^}AVBnb3}`4u{RMCJO(T{)0Om1>JQKL3*jG3v(B^ymRvgq!n!Yk)YF3 zAId&Xpl8!+gImdr?>|}iQI~J$gWXR}fJ|P;@k>CquhTMDRw8W?jaD}4Xxn^pb?H2j zlg~xj?@A!i#ZgRE4t($Dj410@%^HHpRzg1;(%~vo7>Vc&8)(Z24hi)SQak$Jk9M0z3TpN-yO7wHlLqlojxr1VX&WSQ79Q{Rh)}S zlQkAtOB-RA4PP<`LsHOX{fYx1ybhs0xM|Vzv2cm&kiO)H#NKh4b#q|jNoYlF!6a`Z z!a&+-xFcDtdFO~@m3Qo+^l>CCxkoFT+&gbl(cQytM|H#(p+@BFg>?|rv8dYi+F&w1 zz_F^hx-7q>;c()b)I)JGJ?i!prKo4&wMDz@PJ*JzlH8C2tKdU&?wlr6krU)=LRJ3l z@m!9s-`kU=L;Bvg&FHIaM*~!{fvj2kA?>ju7CeG;=3GaN7y_hT?0tJ_i<(nsHJsKk zJG+ya{A)ARs0*y9Fl*bVJkqH=U`yhlC!&VWgZ7A#IFRE}C>nF{@WqG%R3sGEg)XOG zn>Zb3TFSk1b_hs{me^#tv_5`7Hwpc{k7-42@cv?K_>eQlE+a-lgj5WWK7a6d0u@UB zRd0!JXHZ-1?yWs9)G_ZS+*Hw`{y+y`=X)g^xfOaRgqKe6nUbBi_sP`6Tw=CcFsWiB zHAiN>0_{%PvS=cZ^}d5syJ5wORj+Z~*e(~f?f7vqDEMXHHoDrMz)b-8`7Zzy96u-( z+BQGMuMDaz6Qa$q)e9ISbZ2$w$ZGh5g)d7)VR)qip#knx;6#zLux%u_63q$e&nW8y zOCpU8vCA^{NRMxAD}OwO0J+Yxb92y#rN<2)e>CV4WfdE`@Ud!XtMOI z9tLgKWXaVX$&nrXWY9r#ecceba?(B^@!YBQ|a^t;o>>e)wQLAZ{#N7n;fuH zRM4hs0D3F&;gtm)oZh^DA5`D>5?8B9-3wuPJas|78=sp=!W(a!%Yp;g-!7-$?)mtA zuzR*K;Yu_lG`6O}D{j@8fzV5|j75Rr!dC#W?oILRNLwUpe2G$OU*AB=7U!KrKy6Ss zPM#eAPc<;cugD(PCZ49ea+BA2kw?`*Br!30drLb0fXB6UR8eMb$lN)T6-g{KAteV) zS{>s>W8q_s$}U%~@s+@eP>oV*%Z~)=FJCT-#@ewgy_MHJJ&58w7VPbYxA@+cRs z$+*rIs6BGC%5&=0Q4zD)*T!20cB}PWcaSy78JeqLSo6utkOa-;^P+82*OP^ zD-thwz?@RIn-U%5LlG?c)Q}id!AQ%#O{&~9ilGCX)rqM4bi)T98^J%!8Y+w?m+KF5@C*{ND^^i= z(AwP#m3ov~dN-Bl>U*ji&g+l=CZSRPEkv?`g5c4*Q=Ffquo#gi>`(a$s|f{vDhS2J zC(~3gGah7A*VL2oJiSq+_HbWMPB?D3KrG|F6ux9lJAOWClLPSf^^-o8ZnTPd)N5wu zSP*V7_t@9)>*U;PEgG~Ry`)eRObmxS=?m)DOSIE_kDt{LN#J&?Av$+$yMDNPd|Kj# z#84#-N=0zi@Jq*{xD8ln;;dEHSby2MC|bR=sKQP!pFgO`!&x^%V$p{}VD-hZNoS8v zHhIp^2M#-fVs$=W4$F+ymhm6=RU(RXB--@b9Uu;`N4lTYIy}frYtm`GKah0dmK8rf zaE8y`$5XFtVw;hOeg}A1B_@i{e}QfxWF&=a4KoC06j?=G_PX_j3nw?g$39e3$H^^^ zU6tEme*y0s)PX4tn(A0E=)>`bE{1)4TMWh21+F_pa;s|dmo~i%>B_-&+E~WmF#=Qh zLxw(tMs@Q&$CdR#tnq%IS#~Qb=-O*kgT;Wq6L*f#Bo}V4J(H1iv|p=JmUS`oq(sQN z(d6w*Y!=p>N&X6d{QXjy`&cy7WY|z_>2{@4m(^tl5@{9lU@XQZ|0VHfqw(|pyJ6s8 zKv2wIfT_Zh%FKxs_EH-mNpMaCHchO=K`qy<8`UQ>9Li^-UPXz6vJweISnapYmnYB8 z@?#V~R{Fn>ywUUVHZQL@m9jog2{OZIcNB14UI)(|?QUJZHg~O!FFn^Se>MX$-{@=V z$+u-`#eX4z3N))^fvTBW4p5Jz?ga4;xCIYfnp5gw7>%3=toopVH2AVJODx`Ll4H3m zeffM$Kl*h2P!!M0pc~xIOl7vuKRjY1`^B3`9ucnNnSQ14=wFNSY)t+nu5bSZM1FwH z{sk0^-(p~(LZd5NY;p9VHH22|;@p`MVx@HR*xnQxp{_X-wztPw{T_|xqg&m23hPr%2u z{g%5v0$CPY&b=8FLnH(h*hl~QSQp!omP{|4uSlNSmJ2T*0XOz_)%BiAC5X0jh@xWj zz*6^=ApiS&Ol)$h@)y8S{$+N?aMgbf8c+hpdOAkdvgpW@U)W5)pwO#;^QC1pl4D@6 zp*dOv3_$yy>i0tT9D*OLn!dO7LCGm1McY|z%Vy0y3{#@vyR14qX<6tP(eCo+pGd2T z`s%Y7sDhke#3!H!8<$|3gC9}bNjjG=B+WH6AcFyh8JD{3!bR+qbRnI=t|Dg@$H&no zy7@A7|8dZ@>g}S*?I+VW?H+Y(9XNc4(X@U=5aoe~A#-8!|A-s|YkoA65sK=;n;6hs zx~Q1Oe{db+_bt20^7s|8P7sA5X7^gB+p2WRp7u3(M1ou69KGr{KTSVWUpI8HlUCo> z&mh_?ve~j=cf+q(;$zgF{!!j_;9#9-^6tafb&(rOhi{<>VR` zn8UP`rbpXjJj3?c#?CzFAL{A4<(=<3#B{5s$V&&H?u6H%(;a?M&@SJxVOs5 zdQ)5rYn}5Z*F@<~wxUu?3;D=>vy4>ZK{A2Q`DUkaesqVOaI2ix*j>?8@*3eb@YHyc z(IW*Ugk8>7WISro@4TO2h%7W0yCGI3JJ|Vs`jo@JfORi#;wmLV()#*ZanFvo0sfzH z=&&(W!x-wBT3{!Tis_c39TpAGda+Yc3Z>e)p=Y%(T=$eUT*iw36n7Bj)TE_1D-LC4 zIHhkd2$r5#G$5MGTJ0g(q8xaeAO6ZdROt(d#{A&-OShVS-vLSSW#?SNbh-G_detO% zK?%_oYHEq)chA-s8~@CI)m#4A2AyPb5Z0-Yx3shOO{ppmV^mi=ED@;bL(R|90T!AtDla8*n=je&>4YC2Z*B!=53MoS6ZKB08?Y%$AB@_O z8@#frDS0xrm%k(?DLY)a>F8YcOCIFfIKZc$P@GT<5jdY5a=T>>!QU#IX$V~}4~9^i z9}%v?ppv2pd^}RB)v>fhdf7#bB#AuR;*2AEYOOb+WBTbITU9ZuaXBgrk2fD*bx=vN z9=EzItDP^ZgsucUbt_4Ro4x-y^%eF2&?(6)Kj((G>*tFJXfgUiE66t(V9?$!s5m`_ zVLYHQu#D(S^BzZO;?H0FH5qU6|1f}zl#Nt}PrRowJ~MYTvFY^%HM&u5-XmF%<-iHj z)y?GjvBQzxD$kpm_TIz>6P(iVx|M&`x{H0=-d`~-(bgz5((Jz4=uy*S;wzs}+B_Rq zzF(AoPVDdRKO?XQ&;j5x#u5mYlF!i$TYDY~Tv7q|auHHgC293d7Q-|;(YK$muJmhs zD~IzjjGPl~aeJg<`cu@^XOU;EVJ$ivsDp#rb1@9;ojZ%KKSR7t7o|SFni_^1;^RF> z@R?+hv+`{uELuTkYACFjT^q@9P-3P#z1#sqaew}q2yxGs>qa_>2IZ1#`n0R!p(Wf@I{eZ%P^}5dkkK$O zY*N=nH0?Vz48&O;lt*lfUkn)htr|_ifo*|yZ3}UYY!sw}i4WivoY_w_N7bKOapWw272`Ld;5wKAt2PFz?Bpd9VUjA|^|AQVZqc3p2>( zP%;K;H!A%*ZW~xE^g$R6P)9fn4A<0Tss@5AWU&`mg~Ws96==hK2UhF@puIk0a|>K7 zuBg(chxQ41^F3X3bBSmYx80Jr=|5IqB|`$J=KBe2ZJ{jAkST?LgUPEqzP-m%fo{)B z;_8$Y-dHH;(al6^Avi7jkx{JqQjJpT%g>z}m(i|`pDaZusT{5-51aj&`o*p$38BVKRxJJfjjGX^gCifQkmb)Uf&QSbN z<`kSV~zV)h$b_fF$t0HeGcF1oEcAX>wcXi+2 zmx}U?glipG(vwHnxQR=@CCm%i_=r~&iGV^S(VSe&k-A>0edhBaJ&6<(Do&Nn404*E z`I$Fv#^+2e&R9E$!Z`92?z%hFUrzAJX$F`7l_WPM4Xx0 zJ-xowj7WTNYI`4TEA8MFc3y#Gj@<6{PHvhjNV z{zFh;Nibdf=YwIyWNEj~x)$fN0l0zH?fc5Pn*=32gZfkF3pzVeHs@bRN~fci0fp{i zr>YYQG7OX3>w%IW$+R9dDXIK6yhrClQ~yJ7XQjLc^Oty6m^yU3$hKEz>Rew%_`ZMC zqGM^g$B^RuYjO3m65JMNA_6?AI=l5)Q%iO$Q!P`E|?Bo39tS(D|C$>iCl1#rSOG zmdydwjFc%}NnY{s)aJZbUb89|*-~nfN{oa~zDA!P)54u|?XviSig71eYCyYK9lx+T zcA~x|FLVA@RW$N|&SF<=-?!Z|pv$+Xx{tx;mUsZ)i1hVI+z{O4+#lZQmhe|`v&qc) zZAHJsy^jJ0rdD?ResIByB8#AVcaJc+8!5Xsa7S28o~?Y~!9GUd4Y#qMx4tH=u5;0$ zJDgNPZM$L(5QF{sYj8#TRBu0p&q!nzEc43M1ME0lH`luEMiMF%l?~~6;j#i z7w?FQq)2Lvq;|Q{xXU*OcC*A4Y#XLXI~>r=(}fK4vA6VsP`W%%XX^S#(?)}Nc!eGu zM;=4ltqkM&51YAm$pzt$o1GY>hY74l{@^r?;)z)^hKx;HnRAe#2xH$(rb|PnZUCT0 z8sYUBUSCZkQCHj$dmopKI(@3n-Of=yepVu+I6dX-p(7I#S|VOxErlVI;m86_*VN%l zHu&I}w@sA=5XjTeW`Xmg51xcDkYWdEiVBW_a(u8hUOo1gu+J2ywKPO-nR7=3*_Q8; znp>RV3qXtA{#=bub)g|S1&IxECeb3z8+LhaCRW{)dT)Atx%@ecONwDZKVR4PF7V;B z^!9t{;HO$9@jRI|?A6uj`dR94OCw*siCD`>%n2Zs{)5AR@? zT;Ma*cVd&g{yrLsyfD;oz_e3n58L{t9y^aWc&K;?La#i-5SA{!>W;tzE<#nU%qy4> z)pz0%;4Mwiud#c8QN)tO6G|X)?_@vmL0FNy;Zv~wC_SqvzYVF@zL>aOkaF|vZh1h( zJk3K}#4`6G^48Fgxm><#>&`+^Q+K|AOULuKb>4b;_|}aWOUGL! zJC|FWynp;Im#%ZnIiBqfZYe9*PRqD`e~D?&`A3fOO?1LVYTOg z=b}g}Rwozgbxz_bDYW?aNbGU2e_Q`4DufWCbHjI-_c>WQ01#-ydYz3yde7p4@+s0*H>#?SikQC5?Wo`qRg&zl>BFy z*6t#+Qy) z&MxiJ50?ipe>^@3swW`!2i+npQ@e=4>wt3U3$EA}V$J(am2%HWy?w<1~&hBBuyM_e*1)(Qq zFI#DL^p-z-Il^3cMD}!MiqEf+wBU|8e-d;dk@6C(w6ioIP*~W6jn>RqC+O%O8$2ov`R!hv-`J<+;Z?DgiSMT z+AStrxZuCbxbfLOCdU&N+K)6A-FFUp<#nrS8pHF$$|;O@IoYj$>T)!fraG@$wQTMK zc5me+3MDLa-fX|CClsU7_{nt5Q%6|~-g6Iod0GCJ0n^WgLQoWbV12jgUuP/dev/null 2>&1; then echo "🐦 flutter-touched commit — running flutter analyze…" - (cd libraries/flutter_inapp_purchase && flutter analyze) + ( + # Git exports repository-local variables to hooks. Flutter shells out to + # Git to determine its own SDK version, so inheriting the OpenIAP index + # can make it misidentify the app repository as the Flutter checkout. + unset $(git rev-parse --local-env-vars) + cd libraries/flutter_inapp_purchase + flutter analyze + ) else echo "⚠️ flutter not on PATH — skipping flutter analyze (CI will catch any issues)." fi fi +# Paths-aware GQL generator gate. Core parser/plugin/script edits are as +# contract-sensitive as SDL edits, so every packages/gql/** change must run +# the complete schema suite and canonical generation/sync. The final manifest- +# backed check rejects generated outputs that changed but were not staged. +if node packages/gql/scripts/assert-generation-inputs-staged.mjs has-staged-inputs; then + echo "🧬 gql-touched commit — running tests + canonical generation…" + node packages/gql/scripts/assert-generation-inputs-staged.mjs assert-staged-clean + bun install --frozen-lockfile + ( + cd packages/gql + bun run test + bun run generate + bun run verify:generated-staged + ) +fi + # Paths-aware KMP compile check. Compiles each Android store flavor because # `compileDebugKotlinAndroid` is ambiguous once play/horizon/amazon flavors exist. # With a warm gradle daemon this finishes in 5-10s; first run after @@ -148,8 +171,8 @@ fi # @types/react hoisting to break docs's tsc only in CI. Both are now on # React 19, but if either drifts again we want to know on commit. if git diff --cached --name-only --diff-filter=ACMR \ - | grep -qE '^(packages/docs/|scripts/audit-docs(\.test)?\.ts$)'; then - echo "📘 docs/audit-touched commit — running typecheck + audit + format…" + | grep -qE '^(packages/docs/|packages/gql/|scripts/audit-docs(\.test)?\.ts$)'; then + echo "📘 docs/contract/audit-touched commit — running typecheck + audit + format…" bun install --frozen-lockfile bun run --filter @hyodotdev/openiap-docs typecheck bun test scripts/audit-docs.test.ts @@ -158,3 +181,17 @@ if git diff --cached --name-only --diff-filter=ACMR \ cd packages/docs && bunx prettier --check "src/**/*.{ts,tsx,css}" ) fi + +# Generated agent/context files are compiled from the knowledge corpus and +# pinned package metadata. Recompile on every relevant staged change and fail +# if the resulting tracked outputs were not staged with their sources. +if bun scripts/agent/context-files.ts has-staged-inputs; then + echo "🧠 knowledge/agent-touched commit — compiling generated context…" + bun scripts/agent/context-files.ts assert-inputs-staged-clean + ( + cd scripts/agent + bun install --frozen-lockfile + bun run compile:ai + ) + bun scripts/agent/context-files.ts assert-outputs-clean +fi diff --git a/AGENTS.md b/AGENTS.md index 9eef175cb..16a69124c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -82,6 +82,7 @@ openiap/ - `libraries/expo-iap/src/types.ts` - Synced from GQL - `libraries/flutter_inapp_purchase/lib/types.dart` - Synced from GQL - `libraries/godot-iap/addons/godot-iap/types.gd` - Synced from GQL +- `libraries/kmp-iap/library/src/commonMain/kotlin/io/github/hyochan/kmpiap/openiap/Types.kt` - Synced from GQL - `libraries/maui-iap/src/OpenIap.Maui/Types.cs` - Synced from GQL - `openiap-versions.json` - Tracks only `spec`, `google`, and `apple`. Google and Apple are CI-managed; the spec may be bumped directly in a feature PR @@ -102,23 +103,19 @@ copy nearby release blocks without checking the actual package/tag. Regenerate and sync types: ```bash -cd packages/gql && bun run generate # Generate types from GraphQL schema -cd ../.. && ./scripts/sync-versions.sh # Sync to all packages and libraries +cd packages/gql && bun run generate # Generate every language and sync every manifest target ``` ### GQL Code Generation System -The type generation uses an **IR-based (Intermediate Representation)** architecture: +Type generation has two guarded lanes over the same schema inventory and +contract metadata: ```text -GraphQL Schema → Parser → IR → Language Plugins → Generated Code - ↓ - codegen/core/ codegen/plugins/ - ├── types.ts ├── swift.ts - ├── parser.ts ├── kotlin.ts - └── transformer.ts├── dart.ts - ├── gdscript.ts - └── csharp.ts +GraphQL Schema ─┬─► graphql-codegen + AST guards ─► TypeScript + └─► Parser → IR → language plugins ─► Swift/Kotlin/Dart/GDScript/C# + ↓ + generated-sync-manifest.mjs ``` **Language plugins handle:** diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 388db1322..64722ffb8 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -19,7 +19,7 @@ openiap/ │ ├── kmp-iap/ # Kotlin Multiplatform (Maven Central) │ └── maui-iap/ # .NET MAUI / C# (NuGet) ├── scripts/ -│ └── sync-versions.sh # Sync types & versions to all packages +│ └── sync-versions.sh # Sync version metadata and replay manifest copies └── .github/workflows/ # CI/CD ``` @@ -31,7 +31,8 @@ openiap/ ### Prerequisites -- [Bun](https://bun.sh/) v1.1.0+ +- [Bun](https://bun.sh/) at the exact version declared by the root + `packageManager` field (currently 1.3.13) - For Android: JDK 17+, Gradle - For iOS: Xcode, Swift 5.9+ - For Flutter: Flutter SDK @@ -45,7 +46,7 @@ git clone https://github.com/hyodotdev/openiap.git cd openiap bun install -# Sync types and versions to all packages and libraries +# Sync checked-in version metadata and compatibility copies ./scripts/sync-versions.sh ``` @@ -66,22 +67,21 @@ Each library uses its own package manager: 1. Edit `packages/gql/src/*.graphql` 2. `cd packages/gql && bun run generate` -3. `./scripts/sync-versions.sh` (syncs generated types to all libraries) -4. Update Swift switch statements in `packages/apple/Sources/Models/OpenIapError.swift` and `packages/apple/Sources/OpenIapModule.swift` -5. Update `COMMON_ERROR_CODE_MAP` in `libraries/react-native-iap/src/utils/errorMapping.ts` and `libraries/expo-iap/src/utils/errorMapping.ts` +3. Update Swift switch statements in `packages/apple/Sources/Models/OpenIapError.swift` and `packages/apple/Sources/OpenIapModule.swift` +4. Update `COMMON_ERROR_CODE_MAP` in `libraries/react-native-iap/src/utils/errorMapping.ts` and `libraries/expo-iap/src/utils/errorMapping.ts` ### Type Generation Architecture ```text -GraphQL Schema → Parser → IR (Intermediate Representation) → Language Plugins → Generated Code - ├── swift.ts - ├── kotlin.ts - ├── dart.ts - ├── gdscript.ts - └── csharp.ts +GraphQL Schema ─┬─► graphql-codegen + guarded AST post-processing ─► TypeScript + └─► Parser → IR → language plugins ─► Swift/Kotlin/Dart/GDScript/C# + ↓ + generated-sync-manifest.mjs ``` -One `bun run generate` command in `packages/gql` produces types for all platforms. Then `sync-versions.sh` copies them to the correct locations in each package and library. +One `bun run generate` command in `packages/gql` produces every language and +syncs every target declared in `generated-sync-manifest.mjs`. Do not run a +second type-copy command or maintain another target list. ### Working on a Specific Library @@ -135,7 +135,8 @@ All workflows support version bumps: `patch` / `minor` / `major` / `rc` / `promo - `openiap-versions.json` tracks only `spec`, `google`, and `apple` versions. - Framework library versions live in each library's package metadata and release workflow. -- `./scripts/sync-versions.sh` syncs generated types and native version metadata across the monorepo. +- `./scripts/sync-versions.sh` syncs native/docs version metadata and replays + the canonical manifest copies; it does not regenerate schema types. ## 5. CI/CD @@ -151,7 +152,8 @@ All workflows support version bumps: `patch` / `minor` / `major` / `rc` / `promo ## 6. Auto-generated Files (DO NOT EDIT) -These files are generated by `bun run generate` in `packages/gql` and synced by `sync-versions.sh`. Never edit them directly: +These files are generated and synchronized by `bun run generate` in +`packages/gql`. Never edit them directly: - `packages/gql/src/generated/*` -- All generated type files (SSOT) - `packages/apple/Sources/Models/Types.swift` @@ -160,14 +162,16 @@ These files are generated by `bun run generate` in `packages/gql` and synced by - `libraries/expo-iap/src/types.ts` - `libraries/flutter_inapp_purchase/lib/types.dart` - `libraries/godot-iap/addons/godot-iap/types.gd` +- `libraries/kmp-iap/library/src/commonMain/kotlin/io/github/hyochan/kmpiap/openiap/Types.kt` - `libraries/maui-iap/src/OpenIap.Maui/Types.cs` -- `openiap-versions.json` -- Managed by CI/CD workflows only +- `openiap-versions.json` -- Tracks only `spec`, `google`, and `apple`; + Google/Apple are CI-managed, while `spec` changes only on an explicit + coordinated request To regenerate: ```bash cd packages/gql && bun run generate -cd ../.. && ./scripts/sync-versions.sh ``` ## 7. Commit Conventions diff --git a/bun.lock b/bun.lock index 6b791c686..3ae7b8629 100644 --- a/bun.lock +++ b/bun.lock @@ -12,14 +12,14 @@ }, "packages/apple": { "name": "@hyodotdev/openiap-ios", - "version": "2.4.1", + "version": "2.4.2", "dependencies": { "@hyodotdev/openiap-gql": "workspace:*", }, }, "packages/docs": { "name": "@hyodotdev/openiap-docs", - "version": "2.4.0", + "version": "2.5.0", "dependencies": { "@preact/signals-react": "^3.2.1", "@types/prismjs": "^1.26.5", @@ -57,21 +57,19 @@ }, "packages/google": { "name": "@hyodotdev/openiap-android", - "version": "2.4.1", + "version": "2.5.0", "dependencies": { "@hyodotdev/openiap-gql": "workspace:*", }, }, "packages/gql": { "name": "@hyodotdev/openiap-gql", - "version": "2.4.0", + "version": "2.5.0", "devDependencies": { "@graphql-codegen/add": "^6.0.0", "@graphql-codegen/cli": "^6.0.0", "@graphql-codegen/typescript": "^5.0.0", "graphql": "^16.11.0", - "handlebars": "^4.7.8", - "ts-node": "^10.9.2", "typescript": "^5.9.2", "vitest": "^4.1.5", }, @@ -254,8 +252,6 @@ "@convex-dev/migrations": ["@convex-dev/migrations@0.3.4", "", { "peerDependencies": { "convex": "^1.24.8" } }, "sha512-fCUkc4hDzkZTgxicjV1WN4QfUdDDPPrMtvC7VgSLTGssg1B8pm+XlPC6NbZ2Aes38Xf5iiodX+y8VnU96narKQ=="], - "@cspotcode/source-map-support": ["@cspotcode/source-map-support@0.8.1", "", { "dependencies": { "@jridgewell/trace-mapping": "0.3.9" } }, "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw=="], - "@csstools/color-helpers": ["@csstools/color-helpers@6.0.2", "", {}, "sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q=="], "@csstools/css-calc": ["@csstools/css-calc@3.2.0", "", { "peerDependencies": { "@csstools/css-parser-algorithms": "^4.0.0", "@csstools/css-tokenizer": "^4.0.0" } }, "sha512-bR9e6o2BDB12jzN/gIbjHa5wLJ4UjD1CB9pM7ehlc0ddk6EBz+yYS1EV2MF55/HUxrHcB/hehAyt5vhsA3hx7w=="], @@ -270,7 +266,7 @@ "@emnapi/core": ["@emnapi/core@0.45.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-DPWjcUDQkCeEM4VnljEOEcXdAD7pp8zSZsgOujk/LGIwCXWbXJngin+MO4zbH429lzeC3WbYLGjE2MaUOwzpyw=="], - "@emnapi/runtime": ["@emnapi/runtime@1.11.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw=="], + "@emnapi/runtime": ["@emnapi/runtime@0.45.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-Txumi3td7J4A/xTTwlssKieHKTGl3j4A1tglBx72auZ49YK7ePY6XZricgIg9mnZT4xPfA+UPCUdnhRuEFDL+w=="], "@emotion/hash": ["@emotion/hash@0.8.0", "", {}, "sha512-kBJtf7PH6aWwZ6fka3zQ0p6SBYzx4fl1LoZXE2RrnYST9Xljm7WfKJrU4g/Xr3Beg72MLrp1AWNUmuYJTL7Cow=="], @@ -928,14 +924,6 @@ "@testing-library/user-event": ["@testing-library/user-event@14.6.1", "", { "peerDependencies": { "@testing-library/dom": ">=7.21.4" } }, "sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw=="], - "@tsconfig/node10": ["@tsconfig/node10@1.0.12", "", {}, "sha512-UCYBaeFvM11aU2y3YPZ//O5Rhj+xKyzy7mvcIoAjASbigy8mHMryP5cK7dgjlz2hWxh1g5pLw084E0a/wlUSFQ=="], - - "@tsconfig/node12": ["@tsconfig/node12@1.0.11", "", {}, "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag=="], - - "@tsconfig/node14": ["@tsconfig/node14@1.0.3", "", {}, "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow=="], - - "@tsconfig/node16": ["@tsconfig/node16@1.0.4", "", {}, "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA=="], - "@tybys/wasm-util": ["@tybys/wasm-util@0.8.3", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-Z96T/L6dUFFxgFJ+pQtkPpne9q7i6kIPYCFnQBHSgSPV9idTsKfIhCss0h5iM9irweZCatkrdeP8yi5uM1eX6Q=="], "@types/aria-query": ["@types/aria-query@5.0.4", "", {}, "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw=="], @@ -1080,8 +1068,6 @@ "acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="], - "acorn-walk": ["acorn-walk@8.3.5", "", { "dependencies": { "acorn": "^8.11.0" } }, "sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw=="], - "agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="], "ajv": ["ajv@6.15.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw=="], @@ -1096,8 +1082,6 @@ "antd": ["antd@6.3.7", "", { "dependencies": { "@ant-design/colors": "^8.0.1", "@ant-design/cssinjs": "^2.1.2", "@ant-design/cssinjs-utils": "^2.1.2", "@ant-design/fast-color": "^3.0.1", "@ant-design/icons": "^6.1.1", "@ant-design/react-slick": "~2.0.0", "@babel/runtime": "^7.28.4", "@rc-component/cascader": "~1.14.0", "@rc-component/checkbox": "~2.0.0", "@rc-component/collapse": "~1.2.0", "@rc-component/color-picker": "~3.1.1", "@rc-component/dialog": "~1.8.4", "@rc-component/drawer": "~1.4.2", "@rc-component/dropdown": "~1.0.2", "@rc-component/form": "~1.8.1", "@rc-component/image": "~1.9.0", "@rc-component/input": "~1.1.2", "@rc-component/input-number": "~1.6.2", "@rc-component/mentions": "~1.6.0", "@rc-component/menu": "~1.2.0", "@rc-component/motion": "^1.3.2", "@rc-component/mutate-observer": "^2.0.1", "@rc-component/notification": "~1.2.0", "@rc-component/pagination": "~1.2.0", "@rc-component/picker": "~1.9.1", "@rc-component/progress": "~1.0.2", "@rc-component/qrcode": "~1.1.1", "@rc-component/rate": "~1.0.1", "@rc-component/resize-observer": "^1.1.2", "@rc-component/segmented": "~1.3.0", "@rc-component/select": "~1.6.15", "@rc-component/slider": "~1.0.1", "@rc-component/steps": "~1.2.2", "@rc-component/switch": "~1.0.3", "@rc-component/table": "~1.9.1", "@rc-component/tabs": "~1.7.0", "@rc-component/textarea": "~1.1.2", "@rc-component/tooltip": "~1.4.0", "@rc-component/tour": "~2.3.0", "@rc-component/tree": "~1.2.4", "@rc-component/tree-select": "~1.8.0", "@rc-component/trigger": "^3.9.0", "@rc-component/upload": "~1.1.0", "@rc-component/util": "^1.10.1", "clsx": "^2.1.1", "dayjs": "^1.11.11", "scroll-into-view-if-needed": "^3.1.0", "throttle-debounce": "^5.0.2" }, "peerDependencies": { "react": ">=18.0.0", "react-dom": ">=18.0.0" } }, "sha512-WTHi4bHVNKpYXLHESzU0Tts7rRNQeL84Bph9dfI3Qw7mHbTulExDcYKNHny5CTXcrBBOpraXbU9miBAwUR5vaw=="], - "arg": ["arg@4.1.3", "", {}, "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA=="], - "argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], "aria-query": ["aria-query@5.3.2", "", {}, "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw=="], @@ -1234,8 +1218,6 @@ "cosmiconfig": ["cosmiconfig@9.0.1", "", { "dependencies": { "env-paths": "^2.2.1", "import-fresh": "^3.3.0", "js-yaml": "^4.1.0", "parse-json": "^5.2.0" }, "peerDependencies": { "typescript": ">=4.9.5" }, "optionalPeers": ["typescript"] }, "sha512-hr4ihw+DBqcvrsEDioRO31Z17x71pUYoNe/4h6Z0wB72p7MU7/9gH8Q3s12NFhHPfYBBOV3qyfUxmr/Yn3shnQ=="], - "create-require": ["create-require@1.1.1", "", {}, "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ=="], - "cross-inspect": ["cross-inspect@1.0.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-Pcw1JTvZLSJH83iiGWt6fRcT+BjZlCDRVwYLbUcHzv/CRpB7r0MlSrGbIyQvVSNyGnbt7G4AXuyCiDR3POvZ1A=="], "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], @@ -1314,8 +1296,6 @@ "devlop": ["devlop@1.1.0", "", { "dependencies": { "dequal": "^2.0.0" } }, "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA=="], - "diff": ["diff@4.0.4", "", {}, "sha512-X07nttJQkwkfKfvTPG/KSnE2OMdcUCao6+eXF3wmnIQRn2aPAHH3VxDbDOdegkd6JbPsXqShpvEOHfAT+nCNwQ=="], - "dir-glob": ["dir-glob@3.0.1", "", { "dependencies": { "path-type": "^4.0.0" } }, "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA=="], "dom-accessibility-api": ["dom-accessibility-api@0.6.3", "", {}, "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w=="], @@ -1520,8 +1500,6 @@ "graphql-ws": ["graphql-ws@6.0.8", "", { "peerDependencies": { "@fastify/websocket": "^10 || ^11", "crossws": "~0.3", "graphql": "^15.10.1 || ^16", "ws": "^8" }, "optionalPeers": ["@fastify/websocket", "crossws", "ws"] }, "sha512-m3EOaNsUBXwAnkBWbzPfe0Nq8pXUfxsWnolC54sru3FzHvhTZL0Ouf/BoQsaGAXqM+YPerXOJ47BUnmgmoupCw=="], - "handlebars": ["handlebars@4.7.9", "", { "dependencies": { "minimist": "^1.2.5", "neo-async": "^2.6.2", "source-map": "^0.6.1", "wordwrap": "^1.0.0" }, "optionalDependencies": { "uglify-js": "^3.1.4" }, "bin": { "handlebars": "bin/handlebars" } }, "sha512-4E71E0rpOaQuJR2A3xDZ+GM1HyWYv1clR58tC8emQNeQe3RH7MAzSbat+V0wG78LQBo6m6bzSG/L4pBuCsgnUQ=="], - "has-bigints": ["has-bigints@1.1.0", "", {}, "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg=="], "has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="], @@ -1812,8 +1790,6 @@ "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], - "make-error": ["make-error@1.3.6", "", {}, "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw=="], - "map-cache": ["map-cache@0.2.2", "", {}, "sha512-8y/eV9QQZCiyn1SprXSrCmqJN0yNRATe+PO8ztwqrvrbdRLA3eYJF0yaR0YayLWkMbsQSKWS9N2gPcGEc4UsZg=="], "markdown-table": ["markdown-table@3.0.4", "", {}, "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw=="], @@ -1934,8 +1910,6 @@ "minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="], - "minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="], - "mitt": ["mitt@3.0.1", "", {}, "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw=="], "mixpanel-browser": ["mixpanel-browser@2.78.0", "", { "dependencies": { "@mixpanel/rrweb": "2.0.0-alpha.18.4", "@mixpanel/rrweb-plugin-console-record": "2.0.0-alpha.18.4", "@mixpanel/rrweb-utils": "2.0.0-alpha.18.4", "@types/json-logic-js": "2.0.5", "json-logic-js": "2.0.5" } }, "sha512-K2nsMLnTK0PXcQxhj1aJyGpKyEfo2u7wgZhVm532DTjkoCbJJkuSjDBWJFCH5agEM5oE0aVoCYKd0hZ+i8LsYw=="], @@ -1954,8 +1928,6 @@ "negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="], - "neo-async": ["neo-async@2.6.2", "", {}, "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw=="], - "nice-try": ["nice-try@1.0.5", "", {}, "sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ=="], "no-case": ["no-case@3.0.4", "", { "dependencies": { "lower-case": "^2.0.2", "tslib": "^2.0.3" } }, "sha512-fgAN3jGAh+RoxUGZHTSOLJIqUc2wmoBwGR4tbpNAKmmovFoWq0OdRkb0VkldReO2a2iBT/OEulG9XSUc10r3zg=="], @@ -2258,8 +2230,6 @@ "sonner": ["sonner@2.0.7", "", { "peerDependencies": { "react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc", "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w=="], - "source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], - "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], "space-separated-tokens": ["space-separated-tokens@2.0.2", "", {}, "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q=="], @@ -2370,8 +2340,6 @@ "ts-log": ["ts-log@2.2.7", "", {}, "sha512-320x5Ggei84AxzlXp91QkIGSw5wgaLT6GeAH0KsqDmRZdVWW2OiSeVvElVoatk3f7nicwXlElXsoFkARiGE2yg=="], - "ts-node": ["ts-node@10.9.2", "", { "dependencies": { "@cspotcode/source-map-support": "^0.8.0", "@tsconfig/node10": "^1.0.7", "@tsconfig/node12": "^1.0.7", "@tsconfig/node14": "^1.0.0", "@tsconfig/node16": "^1.0.2", "acorn": "^8.4.1", "acorn-walk": "^8.1.1", "arg": "^4.1.0", "create-require": "^1.1.0", "diff": "^4.0.1", "make-error": "^1.1.1", "v8-compile-cache-lib": "^3.0.1", "yn": "3.1.1" }, "peerDependencies": { "@swc/core": ">=1.2.50", "@swc/wasm": ">=1.2.50", "@types/node": "*", "typescript": ">=2.7" }, "optionalPeers": ["@swc/core", "@swc/wasm"], "bin": { "ts-node": "dist/bin.js", "ts-script": "dist/bin-script-deprecated.js", "ts-node-cwd": "dist/bin-cwd.js", "ts-node-esm": "dist/bin-esm.js", "ts-node-script": "dist/bin-script.js", "ts-node-transpile-only": "dist/bin-transpile.js" } }, "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ=="], - "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], "type-check": ["type-check@0.4.0", "", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="], @@ -2390,8 +2358,6 @@ "typescript-eslint": ["typescript-eslint@8.59.1", "", { "dependencies": { "@typescript-eslint/eslint-plugin": "8.59.1", "@typescript-eslint/parser": "8.59.1", "@typescript-eslint/typescript-estree": "8.59.1", "@typescript-eslint/utils": "8.59.1" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-xqDcFVBmlrltH64lklOVp1wYxgJr6LVdg3NamBgH2OOQDLFdTKfIZXF5PfghrnXQKXZGTQs8tr1vL7fJvq8CTQ=="], - "uglify-js": ["uglify-js@3.19.3", "", { "bin": { "uglifyjs": "bin/uglifyjs" } }, "sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ=="], - "unbox-primitive": ["unbox-primitive@1.1.0", "", { "dependencies": { "call-bound": "^1.0.3", "has-bigints": "^1.0.2", "has-symbols": "^1.1.0", "which-boxed-primitive": "^1.1.1" } }, "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw=="], "unc-path-regex": ["unc-path-regex@0.1.2", "", {}, "sha512-eXL4nmJT7oCpkZsHZUOJo8hcX3GbsiDOa0Qu9F646fi8dT3XuSVopVqAcEiVzSKKH7UoDti23wNX3qGFxcW5Qg=="], @@ -2430,8 +2396,6 @@ "use-sync-external-store": ["use-sync-external-store@1.6.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w=="], - "v8-compile-cache-lib": ["v8-compile-cache-lib@3.0.1", "", {}, "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg=="], - "valibot": ["valibot@1.3.1", "", { "peerDependencies": { "typescript": ">=5" }, "optionalPeers": ["typescript"] }, "sha512-sfdRir/QFM0JaF22hqTroPc5xy4DimuGQVKFrzF1YfGwaS1nJot3Y8VqMdLO2Lg27fMzat2yD3pY5PbAYO39Gg=="], "validate-npm-package-license": ["validate-npm-package-license@3.0.4", "", { "dependencies": { "spdx-correct": "^3.0.0", "spdx-expression-parse": "^3.0.0" } }, "sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew=="], @@ -2472,8 +2436,6 @@ "word-wrap": ["word-wrap@1.2.5", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="], - "wordwrap": ["wordwrap@1.0.0", "", {}, "sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q=="], - "wrap-ansi": ["wrap-ansi@9.0.2", "", { "dependencies": { "ansi-styles": "^6.2.1", "string-width": "^7.0.0", "strip-ansi": "^7.1.0" } }, "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww=="], "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="], @@ -2496,8 +2458,6 @@ "yargs-parser": ["yargs-parser@21.1.1", "", {}, "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw=="], - "yn": ["yn@3.1.1", "", {}, "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q=="], - "yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="], "yoctocolors-cjs": ["yoctocolors-cjs@2.1.3", "", {}, "sha512-U/PBtDf35ff0D8X8D0jfdzHYEPFxAI7jJlxZXwCSez5M3190m+QobIfh+sWDWSHMCWWJN2AWamkegn6vr6YBTw=="], @@ -2516,8 +2476,6 @@ "@convex-dev/auth/jose": ["jose@5.10.0", "", {}, "sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg=="], - "@cspotcode/source-map-support/@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.9", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.0.3", "@jridgewell/sourcemap-codec": "^1.4.10" } }, "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ=="], - "@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="], "@eslint/eslintrc/globals": ["globals@14.0.0", "", {}, "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ=="], @@ -2546,16 +2504,14 @@ "@hyodotdev/openiap-mcp-server/@types/node": ["@types/node@24.12.2", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-A1sre26ke7HDIuY/M23nd9gfB+nrmhtYyMINbjI1zHJxYteKR6qSMX56FsmjMcDb3SMcjJg5BiRRgOCC/yBD0g=="], + "@img/sharp-wasm32/@emnapi/runtime": ["@emnapi/runtime@1.11.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw=="], + "@inquirer/core/wrap-ansi": ["wrap-ansi@6.2.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA=="], "@mixpanel/rrweb-snapshot/postcss": ["postcss@8.5.13", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-qif0+jGGZoLWdHey3UFHHWP0H7Gbmsk8T5VEqyYFbWqPr1XqvLGBbk/sl8V5exGmcYJklJOhOQq1pV9IcsiFag=="], "@modelcontextprotocol/sdk/ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="], - "@node-rs/argon2-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@0.45.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-Txumi3td7J4A/xTTwlssKieHKTGl3j4A1tglBx72auZ49YK7ePY6XZricgIg9mnZT4xPfA+UPCUdnhRuEFDL+w=="], - - "@node-rs/bcrypt-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@0.45.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-Txumi3td7J4A/xTTwlssKieHKTGl3j4A1tglBx72auZ49YK7ePY6XZricgIg9mnZT4xPfA+UPCUdnhRuEFDL+w=="], - "@opentelemetry/instrumentation-http/@opentelemetry/core": ["@opentelemetry/core@2.6.1", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-8xHSGWpJP9wBxgBpnqGL0R3PbdWQndL1Qp50qrg71+B28zK5OQmUgcDKLJgzyAAV38t4tOyLMGDD60LneR5W8g=="], "@prisma/instrumentation/@opentelemetry/instrumentation": ["@opentelemetry/instrumentation@0.207.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.207.0", "import-in-the-middle": "^2.0.0", "require-in-the-middle": "^8.0.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-y6eeli9+TLKnznrR8AZlQMSJT7wILpXH+6EYq5Vf/4Ao+huI7EedxQHwRgVUOMLFbe7VFDvHJrX9/f4lcwnJsA=="], diff --git a/knowledge/_claude-context/context.md b/knowledge/_claude-context/context.md index c09f6bd28..c45f36195 100644 --- a/knowledge/_claude-context/context.md +++ b/knowledge/_claude-context/context.md @@ -1,7 +1,7 @@ # OpenIAP Project Context > **Auto-generated for Claude Code** -> Last updated: 2026-07-20T00:25:18.244Z +> Last updated: 2026-07-24T00:17:24.969Z > > Usage: `claude --context knowledge/_claude-context/context.md` @@ -800,11 +800,8 @@ Before writing or editing anything, **ALWAYS** review: The `Types.swift` file in `Sources/Models/` is **auto-generated** from the OpenIAP GraphQL schema. ```bash -# Generate types using version from openiap-versions.json -./scripts/generate-types.sh - -# Or override with environment variable -OPENIAP_GQL_VERSION=1.0.9 ./scripts/generate-types.sh +# From the monorepo root: regenerate all languages and sync manifest targets +cd packages/gql && bun run generate ``` ### Version Management @@ -821,9 +818,12 @@ Version is managed in `openiap-versions.json`: **To update GQL types:** -1. Edit `openiap-versions.json` - change the `"spec"` version -2. Run `./scripts/generate-types.sh` -3. Run `swift test` to verify compatibility +1. Edit the canonical schema under `packages/gql/src/`. +2. Run `cd packages/gql && bun run generate`. +3. Run `cd packages/apple && swift test` to verify compatibility. + +Change the `"spec"` version only when the release train explicitly requests a +version bump; type regeneration itself does not require one. **To bump Apple package version:** @@ -1063,7 +1063,7 @@ The Google package supports **three build flavors**: 1. **DO NOT edit generated files**: `openiap/src/main/java/dev/hyo/openiap/Types.kt` is auto-generated 2. Put reusable Kotlin helpers in `openiap/src/main/java/dev/hyo/openiap/utils/` -3. Run `./scripts/generate-types.sh` to regenerate types +3. Run `cd packages/gql && bun run generate` from the monorepo root 4. **Test ALL THREE flavors** when making changes to shared code 5. **Never persist local receipt-to-SKU aliases as entitlement identity**: store-specific adapters may cache data for performance or correlate an @@ -1155,8 +1155,9 @@ maps OpenIAP product queries, purchases, restore calls, and fulfillment to ### Updating openiap-gql Version -1. Edit `openiap-versions.json` and update the `spec` field -2. Run `./scripts/generate-types.sh` to download and regenerate Types.kt +1. Update the canonical schema and change `openiap-versions.json` only when an + explicitly coordinated release requests a new `spec` version. +2. Run `cd packages/gql && bun run generate` from the monorepo root. 3. Compile ALL THREE flavors to verify: ```bash ./gradlew :openiap:compilePlayDebugKotlin @@ -1247,18 +1248,16 @@ Before writing or editing anything, **ALWAYS** review: ### Code Generation Architecture -The GQL package uses an **IR-based (Intermediate Representation) code generation system**: +The GQL package uses two guarded generation lanes over one schema inventory: ```text GraphQL Schema (src/*.graphql) - ↓ - [1] Parser (codegen/core/parser.ts) - ↓ - [2] Transformer → IR (codegen/core/transformer.ts) - ↓ - [3] Language Plugins (codegen/plugins/*.ts) - ↓ - Generated Files (src/generated/*) + ├──► graphql-codegen + guarded TypeScript AST post-processing + │ └──► src/generated/types.ts + └──► Parser → Transformer → IR → Language Plugins + └──► Swift/Kotlin/Dart/GDScript/C# + ↓ + generated-sync-manifest.mjs ``` #### Directory Structure @@ -1278,7 +1277,6 @@ packages/gql/codegen/ │ ├── dart.ts # Dart plugin (sealed class, factory constructors) │ ├── gdscript.ts # GDScript plugin (Godot engine) │ └── csharp.ts # C# plugin (.NET MAUI) -└── templates/ # Handlebars templates (optional) ``` #### IR (Intermediate Representation) @@ -1308,16 +1306,16 @@ Each plugin handles language-specific requirements: ### Scripts -| Script | Description | -| ------------------- | ------------------------------------------- | -| `generate:ts` | Generate TypeScript types (graphql-codegen) | -| `generate:swift` | Generate Swift types (IR-based plugin) | -| `generate:kotlin` | Generate Kotlin types (IR-based plugin) | -| `generate:dart` | Generate Dart types (IR-based plugin) | -| `generate:gdscript` | Generate GDScript types (IR-based plugin) | -| `generate:csharp` | Generate C# / MAUI types (IR-based plugin) | -| `generate` | Generate all types + sync to platforms | -| `sync` | Sync generated types to platform packages | +| Script | Description | +| ------------------- | --------------------------------------------- | +| `generate:ts` | Generate TypeScript types (graphql-codegen) | +| `generate:swift` | Generate Swift types (IR-based plugin) | +| `generate:kotlin` | Generate Kotlin types (IR-based plugin) | +| `generate:dart` | Generate Dart types (IR-based plugin) | +| `generate:gdscript` | Generate GDScript types (IR-based plugin) | +| `generate:csharp` | Generate C# / MAUI types (IR-based plugin) | +| `generate` | Generate every type and sync manifest targets | +| `sync` | Replay manifest-owned synchronized copies | ### Generating Types @@ -1327,7 +1325,8 @@ cd packages/gql # Generate all platform types bun run generate -# Generate specific platform +# Diagnostic single-plugin generation (always finish with `bun run generate` +# before committing so every manifest target is synchronized) bun run generate:swift bun run generate:kotlin bun run generate:dart @@ -2057,9 +2056,48 @@ GraphQL schema → generated Types → hand-written wrapper SDK → docs p /src/*.graphql) /types.{ts,kt,...}) Dart / TS / GDScript) src/pages/...) ``` +- `packages/gql/schema-files.mjs` — ordered inventory of every production SDL + input. Every repository-owned generator imports it directly. Do not add + another hard-coded schema list or an unverified external generator manifest. +- `packages/gql/schema-source-utils.mjs` — shared source identity normalization + and block-string line detection. Metadata extractors must not duplicate this + lexical bookkeeping. - `packages/gql/src/*.graphql` — schema descriptions ARE the canonical doc string. Edits propagate via `bun run generate` to every generated - `Types.{ts,kt,swift,dart,gd}`. + `types.ts`, `Types.kt`, `Types.swift`, `types.dart`, `types.gd`, and + `Types.cs`. +- `packages/gql/schema-markers.mjs` — the only parser for the SDL comment + contracts `# Future` and `# => Union`. Generators and the schema linter must + consume it rather than maintaining independent line-state machines. A union + wrapper must be a non-root object with at least one field and all fields + nullable; operation roots, empty wrappers, and required fields fail + generation instead of silently degrading to an object. +- `packages/gql/schema-deprecations.mjs` — the only extractor and validator for + canonical deprecation ownership. Standard GraphQL declarations use + `@deprecated(reason: ...)`; named types use the project-scoped + `@openiapDeprecated(reason: ...)` directive declared in `schema.graphql`. + Do not duplicate an + `@deprecated` tag inside the description or encode deprecation only as + description prose. The schema linter rejects missing, duplicate, empty, and + conflicting ownership. The generator appends the directive reason wherever + the target exposes a corresponding declaration; TypeScript receives an + explicit injection for project-scoped type-level directives. + TypeScript string-union members have no per-member declaration and therefore + cannot carry GraphQL enum-value docs; `ErrorCode` remains a real enum, so its + member deprecations are required. Custom aliases such as + `PurchaseInput = Purchase` rely on the aliased declaration's canonical docs + instead of duplicating them. GDScript does not emit GraphQL interfaces, so + implementation declarations carry the applicable field guidance. GDScript + also expresses `# => Union` result wrappers only through operation return + metadata rather than declarations, so wrapper-variant docs have no generated + declaration target there; every language that emits a wrapper or variant + declaration must preserve the canonical reason. +- `packages/gql/custom-input-contracts.ts` — typed + field/type/nullability/default contracts for inputs that custom generators + alias or project. The shared IR transformer validates these before any + language plugin runs. +- `packages/gql/generated-sync-manifest.mjs` — generated source/target mapping + shared by canonical platform sync and the pre-commit drift guard. - `libraries/*/src/types.ts` (or equivalent) — generated; never hand-edit. When a docs page mentions a field name, that field MUST exist in the generated TS type. The audit script enforces this. @@ -2105,10 +2143,10 @@ When changing a default, update: ### R3 — Doc pages reference real fields only When a Type doc page lists fields in a `
    ` or `
      `, every field name -MUST exist in the generated `libraries/expo-iap/src/types.ts` (or -`libraries/react-native-iap/src/types.ts` — they're identical in shape). -The audit script greps for fields that don't appear in the type definition -and flags them. +MUST exist in the canonical generated +`packages/gql/src/generated/types.ts` shape, which is synchronized into Expo +and React Native. The audit parses that TypeScript SSOT with the compiler AST +and flags fields that do not appear in the declaration. Example failure modes already encountered: @@ -2125,14 +2163,20 @@ Example failure modes already encountered: When a doc page mentions enum values (e.g. `'continue' | 'cancelled'`, `.acquisition`, `.services`), they must -appear in the generated enum definition. The audit script extracts string -literals from `'…'` blocks in doc pages and checks them -against the generated TypeScript union types. +appear in the generated enum definition. Compare documentation against the +generated target-language member names and wire values, not a manually copied +list. GraphQL enum identifiers are PascalCase, but serialized string values can +be lowercase or kebab-case. `ExternalPurchaseCustomLinkNoticeTypeIOS` is the canonical recent miss — the union is `'browser'` only, but the doc claimed `'continue' | 'cancelled' | …`. +The audit script enforces exact generated values for the canonical offer +snippets covered by R12. Other enum examples still require review against the +generated types until a focused, fault-tested rule is added; do not describe a +broader automated guarantee than the script actually provides. + ### R5 — `` targets must resolve Anchor links should point to existing pages and section anchors. Common @@ -2146,8 +2190,9 @@ recent failures: wrong section. Add a precise `#external-purchase-custom-link-token-result-ios` anchor on the type page AND link to it. -The audit script crawls every internal `` and asserts -the target file (and anchor when given) exists. +The audit script crawls every internal `` and asserts the +target page file exists. Anchor semantics still require review against the +target page. ### R6 — Native version constraints are honest @@ -2164,16 +2209,13 @@ When you write ` 8.2.0+`, you should be able to point to the matching release-notes line. Don't paraphrase — quote the version requirement exactly as Google / Apple states it. -### R7 — Code-example snippets compile-check +### R7 — Code-example snippets follow the real wrapper contract Code examples in doc pages should at minimum parse / type-check against -the wrapper they target. The audit script does NOT yet run a full -TypeScript / Kotlin / Dart parser, but it does: - -- Verify imports (`import {…} from 'expo-iap'`) reference symbols that - expo-iap actually exports. -- Verify field accesses on shown objects (e.g. `purchase.purchaseToken`) - exist on the corresponding generated type. +the wrapper they target. The audit script does not compile every documentation +language. Its R11 rules reject a focused set of recurring phantom API shapes; +all other imports, calls, and field accesses still require a real example build +or a targeted fault-tested audit rule. When in doubt, run the example in a real example app before publishing. @@ -2226,6 +2268,38 @@ by `scripts/sync-versions.sh` from the real SSOT files: `bun run audit:docs` fails if this generated metadata drifts from the SSOT files or if `versioning.ts` reintroduces raw imports outside `packages/docs`. +### R11 — Active code examples reject recurring phantom API shapes + +Fenced `CodeBlock` examples under active documentation must not reintroduce +known cross-language mistakes such as Kotlin syntax in C#, obsolete Flutter +listener names, legacy purchase request shapes, top-level Godot SKUs, or +obsolete Kotlin/KMP named arguments. Historical release notes are excluded +because they describe APIs as shipped at that time. + +Keep R11 focused. Every new pattern needs a failing fixture and a valid nearby +shape so formatting, comments, or unrelated prose cannot trigger it. + +### R12 — Canonical offer docs derive enum contracts from generated types + +`DiscountOffer` is the canonical Android one-time product offer shape backed by +`ProductDetails.OneTimePurchaseOfferDetails`. Subscription discounts belong to +`SubscriptionOffer`, which maps to StoreKit `Product.SubscriptionOffer` and +Google Play `ProductDetails.SubscriptionOfferDetails`. + +The canonical DiscountOffer page contains TypeScript, Swift, Kotlin, and Dart +`DiscountOfferType` snippets. The audit must parse the corresponding generated +files and compare each snippet with those generated members and wire values. Do +not hard-code a second expected enum list in the audit or its fixtures. + +Search data must contain exactly one canonical entry for each page: + +- `DiscountOffer` → `/docs/types/discount-offer` +- `SubscriptionOffer` → `/docs/types/subscription-offer` + +Every R12 parser edge case needs a fault test. Required fixture transforms must +fail when their search pattern no longer matches; a no-op replacement can make +an invalid parser look green. + ## Pre-commit checklist Run before every `git push` on docs / SDK changes: @@ -2243,13 +2317,16 @@ cd libraries/flutter_inapp_purchase && dart analyze lib cd packages/apple && swift build cd packages/google && ./gradlew :openiap:compilePlayDebugKotlin -# 3. SSOT audit — run the docs-consistency audit script -cd scripts && bun run audit-docs.ts +# 3. SSOT audit + parser fault fixtures (from the repository root) +cd +bun test scripts/audit-docs.test.ts +bun run audit:docs ``` -Auto-mode users: the `commit-push-pr` skill runs steps 1 + 2 automatically -before pushing. Step 3 is opt-in until the audit script has zero false -positives in CI. +The pre-commit hook runs the docs typecheck, audit fixtures, audit, and docs +format check when docs, the audit scripts, or the generated GQL contracts they +consume change. GitHub's `Test Docs` job runs the same audit fixtures and audit. +Do not bypass these gates. ## Audit script @@ -2258,18 +2335,21 @@ parses every `/docs/apis/*.tsx` and `/docs/types/*.tsx` page, extracts: - `` targets - `fieldName` mentions inside Returns / Parameters tables -- String-literal enum values in `'…'` blocks -- `@see {@link openiap.dev/...}` URLs +- published release entries and docs-local version metadata +- focused recurring phantom shapes from active fenced code examples +- canonical offer semantics, generated enum snippets, and search entries -…and cross-references each against the generated TypeScript types in -`libraries/expo-iap/src/types.ts`. Failures print as a punch-list with the -file, line, and the offending mention. +Field mentions are cross-referenced against generated TypeScript shapes. +Canonical offer snippets are compared with the generated TypeScript, Swift, +Kotlin, and Dart outputs. Failures print a punch-list with the file, line, and +offending contract. Run with: ```bash cd -bun run scripts/audit-docs.ts +bun test scripts/audit-docs.test.ts +bun run audit:docs ``` Exit code 1 means at least one drift; 0 means clean. diff --git a/knowledge/internal/04-platform-packages.md b/knowledge/internal/04-platform-packages.md index e189ef249..26f702bb4 100644 --- a/knowledge/internal/04-platform-packages.md +++ b/knowledge/internal/04-platform-packages.md @@ -16,11 +16,8 @@ Before writing or editing anything, **ALWAYS** review: The `Types.swift` file in `Sources/Models/` is **auto-generated** from the OpenIAP GraphQL schema. ```bash -# Generate types using version from openiap-versions.json -./scripts/generate-types.sh - -# Or override with environment variable -OPENIAP_GQL_VERSION=1.0.9 ./scripts/generate-types.sh +# From the monorepo root: regenerate all languages and sync manifest targets +cd packages/gql && bun run generate ``` ### Version Management @@ -37,9 +34,12 @@ Version is managed in `openiap-versions.json`: **To update GQL types:** -1. Edit `openiap-versions.json` - change the `"spec"` version -2. Run `./scripts/generate-types.sh` -3. Run `swift test` to verify compatibility +1. Edit the canonical schema under `packages/gql/src/`. +2. Run `cd packages/gql && bun run generate`. +3. Run `cd packages/apple && swift test` to verify compatibility. + +Change the `"spec"` version only when the release train explicitly requests a +version bump; type regeneration itself does not require one. **To bump Apple package version:** @@ -279,7 +279,7 @@ The Google package supports **three build flavors**: 1. **DO NOT edit generated files**: `openiap/src/main/java/dev/hyo/openiap/Types.kt` is auto-generated 2. Put reusable Kotlin helpers in `openiap/src/main/java/dev/hyo/openiap/utils/` -3. Run `./scripts/generate-types.sh` to regenerate types +3. Run `cd packages/gql && bun run generate` from the monorepo root 4. **Test ALL THREE flavors** when making changes to shared code 5. **Never persist local receipt-to-SKU aliases as entitlement identity**: store-specific adapters may cache data for performance or correlate an @@ -371,8 +371,9 @@ maps OpenIAP product queries, purchases, restore calls, and fulfillment to ### Updating openiap-gql Version -1. Edit `openiap-versions.json` and update the `spec` field -2. Run `./scripts/generate-types.sh` to download and regenerate Types.kt +1. Update the canonical schema and change `openiap-versions.json` only when an + explicitly coordinated release requests a new `spec` version. +2. Run `cd packages/gql && bun run generate` from the monorepo root. 3. Compile ALL THREE flavors to verify: ```bash ./gradlew :openiap:compilePlayDebugKotlin @@ -463,18 +464,16 @@ Before writing or editing anything, **ALWAYS** review: ### Code Generation Architecture -The GQL package uses an **IR-based (Intermediate Representation) code generation system**: +The GQL package uses two guarded generation lanes over one schema inventory: ```text GraphQL Schema (src/*.graphql) - ↓ - [1] Parser (codegen/core/parser.ts) - ↓ - [2] Transformer → IR (codegen/core/transformer.ts) - ↓ - [3] Language Plugins (codegen/plugins/*.ts) - ↓ - Generated Files (src/generated/*) + ├──► graphql-codegen + guarded TypeScript AST post-processing + │ └──► src/generated/types.ts + └──► Parser → Transformer → IR → Language Plugins + └──► Swift/Kotlin/Dart/GDScript/C# + ↓ + generated-sync-manifest.mjs ``` #### Directory Structure @@ -494,7 +493,6 @@ packages/gql/codegen/ │ ├── dart.ts # Dart plugin (sealed class, factory constructors) │ ├── gdscript.ts # GDScript plugin (Godot engine) │ └── csharp.ts # C# plugin (.NET MAUI) -└── templates/ # Handlebars templates (optional) ``` #### IR (Intermediate Representation) @@ -524,16 +522,16 @@ Each plugin handles language-specific requirements: ### Scripts -| Script | Description | -| ------------------- | ------------------------------------------- | -| `generate:ts` | Generate TypeScript types (graphql-codegen) | -| `generate:swift` | Generate Swift types (IR-based plugin) | -| `generate:kotlin` | Generate Kotlin types (IR-based plugin) | -| `generate:dart` | Generate Dart types (IR-based plugin) | -| `generate:gdscript` | Generate GDScript types (IR-based plugin) | -| `generate:csharp` | Generate C# / MAUI types (IR-based plugin) | -| `generate` | Generate all types + sync to platforms | -| `sync` | Sync generated types to platform packages | +| Script | Description | +| ------------------- | --------------------------------------------- | +| `generate:ts` | Generate TypeScript types (graphql-codegen) | +| `generate:swift` | Generate Swift types (IR-based plugin) | +| `generate:kotlin` | Generate Kotlin types (IR-based plugin) | +| `generate:dart` | Generate Dart types (IR-based plugin) | +| `generate:gdscript` | Generate GDScript types (IR-based plugin) | +| `generate:csharp` | Generate C# / MAUI types (IR-based plugin) | +| `generate` | Generate every type and sync manifest targets | +| `sync` | Replay manifest-owned synchronized copies | ### Generating Types @@ -543,7 +541,8 @@ cd packages/gql # Generate all platform types bun run generate -# Generate specific platform +# Diagnostic single-plugin generation (always finish with `bun run generate` +# before committing so every manifest target is synchronized) bun run generate:swift bun run generate:kotlin bun run generate:dart diff --git a/knowledge/internal/07-docs-consistency.md b/knowledge/internal/07-docs-consistency.md index 71f1561c4..6ac4705a3 100644 --- a/knowledge/internal/07-docs-consistency.md +++ b/knowledge/internal/07-docs-consistency.md @@ -16,9 +16,48 @@ GraphQL schema → generated Types → hand-written wrapper SDK → docs p /src/*.graphql) /types.{ts,kt,...}) Dart / TS / GDScript) src/pages/...) ``` +- `packages/gql/schema-files.mjs` — ordered inventory of every production SDL + input. Every repository-owned generator imports it directly. Do not add + another hard-coded schema list or an unverified external generator manifest. +- `packages/gql/schema-source-utils.mjs` — shared source identity normalization + and block-string line detection. Metadata extractors must not duplicate this + lexical bookkeeping. - `packages/gql/src/*.graphql` — schema descriptions ARE the canonical doc string. Edits propagate via `bun run generate` to every generated - `Types.{ts,kt,swift,dart,gd}`. + `types.ts`, `Types.kt`, `Types.swift`, `types.dart`, `types.gd`, and + `Types.cs`. +- `packages/gql/schema-markers.mjs` — the only parser for the SDL comment + contracts `# Future` and `# => Union`. Generators and the schema linter must + consume it rather than maintaining independent line-state machines. A union + wrapper must be a non-root object with at least one field and all fields + nullable; operation roots, empty wrappers, and required fields fail + generation instead of silently degrading to an object. +- `packages/gql/schema-deprecations.mjs` — the only extractor and validator for + canonical deprecation ownership. Standard GraphQL declarations use + `@deprecated(reason: ...)`; named types use the project-scoped + `@openiapDeprecated(reason: ...)` directive declared in `schema.graphql`. + Do not duplicate an + `@deprecated` tag inside the description or encode deprecation only as + description prose. The schema linter rejects missing, duplicate, empty, and + conflicting ownership. The generator appends the directive reason wherever + the target exposes a corresponding declaration; TypeScript receives an + explicit injection for project-scoped type-level directives. + TypeScript string-union members have no per-member declaration and therefore + cannot carry GraphQL enum-value docs; `ErrorCode` remains a real enum, so its + member deprecations are required. Custom aliases such as + `PurchaseInput = Purchase` rely on the aliased declaration's canonical docs + instead of duplicating them. GDScript does not emit GraphQL interfaces, so + implementation declarations carry the applicable field guidance. GDScript + also expresses `# => Union` result wrappers only through operation return + metadata rather than declarations, so wrapper-variant docs have no generated + declaration target there; every language that emits a wrapper or variant + declaration must preserve the canonical reason. +- `packages/gql/custom-input-contracts.ts` — typed + field/type/nullability/default contracts for inputs that custom generators + alias or project. The shared IR transformer validates these before any + language plugin runs. +- `packages/gql/generated-sync-manifest.mjs` — generated source/target mapping + shared by canonical platform sync and the pre-commit drift guard. - `libraries/*/src/types.ts` (or equivalent) — generated; never hand-edit. When a docs page mentions a field name, that field MUST exist in the generated TS type. The audit script enforces this. @@ -64,10 +103,10 @@ When changing a default, update: ### R3 — Doc pages reference real fields only When a Type doc page lists fields in a `
    ` or `
      `, every field name -MUST exist in the generated `libraries/expo-iap/src/types.ts` (or -`libraries/react-native-iap/src/types.ts` — they're identical in shape). -The audit script greps for fields that don't appear in the type definition -and flags them. +MUST exist in the canonical generated +`packages/gql/src/generated/types.ts` shape, which is synchronized into Expo +and React Native. The audit parses that TypeScript SSOT with the compiler AST +and flags fields that do not appear in the declaration. Example failure modes already encountered: @@ -84,14 +123,20 @@ Example failure modes already encountered: When a doc page mentions enum values (e.g. `'continue' | 'cancelled'`, `.acquisition`, `.services`), they must -appear in the generated enum definition. The audit script extracts string -literals from `'…'` blocks in doc pages and checks them -against the generated TypeScript union types. +appear in the generated enum definition. Compare documentation against the +generated target-language member names and wire values, not a manually copied +list. GraphQL enum identifiers are PascalCase, but serialized string values can +be lowercase or kebab-case. `ExternalPurchaseCustomLinkNoticeTypeIOS` is the canonical recent miss — the union is `'browser'` only, but the doc claimed `'continue' | 'cancelled' | …`. +The audit script enforces exact generated values for the canonical offer +snippets covered by R12. Other enum examples still require review against the +generated types until a focused, fault-tested rule is added; do not describe a +broader automated guarantee than the script actually provides. + ### R5 — `` targets must resolve Anchor links should point to existing pages and section anchors. Common @@ -105,8 +150,9 @@ recent failures: wrong section. Add a precise `#external-purchase-custom-link-token-result-ios` anchor on the type page AND link to it. -The audit script crawls every internal `` and asserts -the target file (and anchor when given) exists. +The audit script crawls every internal `` and asserts the +target page file exists. Anchor semantics still require review against the +target page. ### R6 — Native version constraints are honest @@ -123,16 +169,13 @@ When you write ` 8.2.0+`, you should be able to point to the matching release-notes line. Don't paraphrase — quote the version requirement exactly as Google / Apple states it. -### R7 — Code-example snippets compile-check +### R7 — Code-example snippets follow the real wrapper contract Code examples in doc pages should at minimum parse / type-check against -the wrapper they target. The audit script does NOT yet run a full -TypeScript / Kotlin / Dart parser, but it does: - -- Verify imports (`import {…} from 'expo-iap'`) reference symbols that - expo-iap actually exports. -- Verify field accesses on shown objects (e.g. `purchase.purchaseToken`) - exist on the corresponding generated type. +the wrapper they target. The audit script does not compile every documentation +language. Its R11 rules reject a focused set of recurring phantom API shapes; +all other imports, calls, and field accesses still require a real example build +or a targeted fault-tested audit rule. When in doubt, run the example in a real example app before publishing. @@ -185,6 +228,38 @@ by `scripts/sync-versions.sh` from the real SSOT files: `bun run audit:docs` fails if this generated metadata drifts from the SSOT files or if `versioning.ts` reintroduces raw imports outside `packages/docs`. +### R11 — Active code examples reject recurring phantom API shapes + +Fenced `CodeBlock` examples under active documentation must not reintroduce +known cross-language mistakes such as Kotlin syntax in C#, obsolete Flutter +listener names, legacy purchase request shapes, top-level Godot SKUs, or +obsolete Kotlin/KMP named arguments. Historical release notes are excluded +because they describe APIs as shipped at that time. + +Keep R11 focused. Every new pattern needs a failing fixture and a valid nearby +shape so formatting, comments, or unrelated prose cannot trigger it. + +### R12 — Canonical offer docs derive enum contracts from generated types + +`DiscountOffer` is the canonical Android one-time product offer shape backed by +`ProductDetails.OneTimePurchaseOfferDetails`. Subscription discounts belong to +`SubscriptionOffer`, which maps to StoreKit `Product.SubscriptionOffer` and +Google Play `ProductDetails.SubscriptionOfferDetails`. + +The canonical DiscountOffer page contains TypeScript, Swift, Kotlin, and Dart +`DiscountOfferType` snippets. The audit must parse the corresponding generated +files and compare each snippet with those generated members and wire values. Do +not hard-code a second expected enum list in the audit or its fixtures. + +Search data must contain exactly one canonical entry for each page: + +- `DiscountOffer` → `/docs/types/discount-offer` +- `SubscriptionOffer` → `/docs/types/subscription-offer` + +Every R12 parser edge case needs a fault test. Required fixture transforms must +fail when their search pattern no longer matches; a no-op replacement can make +an invalid parser look green. + ## Pre-commit checklist Run before every `git push` on docs / SDK changes: @@ -202,13 +277,16 @@ cd libraries/flutter_inapp_purchase && dart analyze lib cd packages/apple && swift build cd packages/google && ./gradlew :openiap:compilePlayDebugKotlin -# 3. SSOT audit — run the docs-consistency audit script -cd scripts && bun run audit-docs.ts +# 3. SSOT audit + parser fault fixtures (from the repository root) +cd +bun test scripts/audit-docs.test.ts +bun run audit:docs ``` -Auto-mode users: the `commit-push-pr` skill runs steps 1 + 2 automatically -before pushing. Step 3 is opt-in until the audit script has zero false -positives in CI. +The pre-commit hook runs the docs typecheck, audit fixtures, audit, and docs +format check when docs, the audit scripts, or the generated GQL contracts they +consume change. GitHub's `Test Docs` job runs the same audit fixtures and audit. +Do not bypass these gates. ## Audit script @@ -217,18 +295,21 @@ parses every `/docs/apis/*.tsx` and `/docs/types/*.tsx` page, extracts: - `` targets - `fieldName` mentions inside Returns / Parameters tables -- String-literal enum values in `'…'` blocks -- `@see {@link openiap.dev/...}` URLs +- published release entries and docs-local version metadata +- focused recurring phantom shapes from active fenced code examples +- canonical offer semantics, generated enum snippets, and search entries -…and cross-references each against the generated TypeScript types in -`libraries/expo-iap/src/types.ts`. Failures print as a punch-list with the -file, line, and the offending mention. +Field mentions are cross-referenced against generated TypeScript shapes. +Canonical offer snippets are compared with the generated TypeScript, Swift, +Kotlin, and Dart outputs. Failures print a punch-list with the file, line, and +offending contract. Run with: ```bash cd -bun run scripts/audit-docs.ts +bun test scripts/audit-docs.test.ts +bun run audit:docs ``` Exit code 1 means at least one drift; 0 means clean. diff --git a/libraries/expo-iap/CLAUDE.md b/libraries/expo-iap/CLAUDE.md index eaf4c03e9..df6085939 100644 --- a/libraries/expo-iap/CLAUDE.md +++ b/libraries/expo-iap/CLAUDE.md @@ -87,7 +87,7 @@ For complete type definitions and documentation, see: **Important:** `src/types.ts` is generated from the OpenIAP schema. Never edit this file manually or commit hand-written changes. After updating any `*.graphql` schema, run `bun run generate:types` (or the equivalent script in your package manager) to refresh the file. +> **Important:** `src/types.ts` is generated from the OpenIAP schema. Never edit this file manually or commit hand-written changes. In this monorepo, run `cd packages/gql && bun run generate` from the repository root so every platform copy stays synchronized. The Expo-local `bun run generate:types` command is only for a standalone checkout: it atomically refreshes the platform-ready file from the raw `docs-${spec}` tag pinned by `openiap-versions.json` (or the version passed with `--tag`). - Whenever you need Request/Params/Result types in the JS API surface (`src/index.ts`, hooks, modules, examples), import them directly from the generated `src/types.ts` (e.g., `MutationRequestPurchaseArgs`, `QueryFetchProductsArgs`). Bind exported functions with the generated `QueryField` / `MutationField` helpers so their signatures stay in lockstep with `types.ts` instead of redefining ad-hoc unions like `ProductTypeInput`. diff --git a/libraries/expo-iap/CONTRIBUTING.md b/libraries/expo-iap/CONTRIBUTING.md index 1b41b97cb..42b637485 100644 --- a/libraries/expo-iap/CONTRIBUTING.md +++ b/libraries/expo-iap/CONTRIBUTING.md @@ -216,28 +216,28 @@ For detailed code conventions, naming standards, and implementation guidelines, ### Updating OpenIAP Types -The generated TypeScript definitions in `src/types.ts` come from the [OpenIAP](https://github.com/hyodotdev/openiap) release artifacts. Never edit this file by hand. When the schema changes or you need to pull newer types: +The generated TypeScript definitions in `src/types.ts` come from the platform-ready file committed in the [OpenIAP](https://github.com/hyodotdev/openiap) raw `docs-${spec}` tag. Never edit this file by hand. When the schema changes or you need to pull newer published types: -- Run `bun run generate:types` to download the latest pinned release and overwrite `src/types.ts`. -- To target a specific release, pass the tag: `bun run generate:types --tag `. +- Run `bun run generate:types` to download the version pinned by `openiap-versions.json`, validate it, and atomically replace `src/types.ts`. +- To target a specific published spec, pass its version: `bun run generate:types --tag ` (the script resolves it to `docs-`). - Commit the updated file alongside any related schema or documentation changes. Always ensure the repository builds and tests succeed after regenerating the types. ## 🔢 OpenIAP Version Management -All native and type-generation version numbers are sourced from `openiap-versions.json` at the repository root: +The native dependency and OpenIAP specification versions are sourced from `openiap-versions.json` at the repository root: - `apple` → iOS Pod dependency (`ios/ExpoIap.podspec`). - `google` → Android artifact (`android/build.gradle`, Expo config plugin). -- `gql` → GraphQL type generator (`scripts/update-types.mjs`). +- `spec` → published OpenIAP specification/types release (`scripts/update-types.mjs`). When bumping dependencies: 1. Update the relevant fields in `openiap-versions.json`. 2. For iOS changes, run `cd ios && pod install` (and commit the Pod.lock if required by the workflow). 3. For Android, re-run Gradle (`bun run android`) so the new artifact is pulled down. -4. When the `gql` value changes, run `bun run generate:types` to refresh `src/types.ts`. +4. When the `spec` value changes, run `bun run generate:types` to refresh `src/types.ts` from the matching raw `docs-${spec}` tag. 5. Commit the updated JSON, regenerated files, and any resulting lockfile changes together. If the JSON file is missing or malformed, build scripts (Gradle, Podspec, the type generator) will fail fast — fix the JSON rather than hard-coding version strings in multiple locations. diff --git a/libraries/expo-iap/scripts/update-types.mjs b/libraries/expo-iap/scripts/update-types.mjs index 678349205..b7176751a 100755 --- a/libraries/expo-iap/scripts/update-types.mjs +++ b/libraries/expo-iap/scripts/update-types.mjs @@ -1,115 +1,146 @@ #!/usr/bin/env node -import {mkdtempSync, readFileSync, writeFileSync, rmSync} from 'node:fs'; -import {join} from 'node:path'; -import {tmpdir} from 'node:os'; +import { + chmodSync, + mkdtempSync, + readFileSync, + renameSync, + rmSync, + writeFileSync, +} from 'node:fs'; +import {dirname, join} from 'node:path'; import {execFileSync} from 'node:child_process'; -import {fileURLToPath, URL} from 'node:url'; - -const __dirname = fileURLToPath(new URL('.', import.meta.url)); -let versions; -try { - versions = JSON.parse( - readFileSync(join(__dirname, '..', 'openiap-versions.json'), 'utf8'), - ); -} catch { - throw new Error( - 'expo-iap: Unable to load openiap-versions.json. Ensure the file exists and is valid JSON.', - ); -} - -const DEFAULT_TAG = versions?.spec; -if (typeof DEFAULT_TAG !== 'string' || DEFAULT_TAG.length === 0) { - throw new Error( - 'expo-iap: "spec" version missing in openiap-versions.json. Specify --tag manually or update the file.', - ); +import {fileURLToPath} from 'node:url'; + +const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url)); +const PROJECT_ROOT = join(SCRIPT_DIR, '..'); +const TARGET_REPOSITORY_PATH = 'libraries/expo-iap/src/types.ts'; +const TARGET_FILE = join(PROJECT_ROOT, 'src', 'types.ts'); +const GENERATED_HEADER = 'AUTO-GENERATED TYPES — DO NOT EDIT DIRECTLY'; +const HEADER_GUIDANCE = + 'Refresh this file with the generated-types workflow documented for your checkout.'; +const REPRESENTATIVE_DECLARATION = 'export interface ProductRequest'; +const HEADER_SEPARATOR = + '// ============================================================================'; +const HEADER_LINE = `// ${GENERATED_HEADER}`; + +function normalizeDocsTag(value) { + const normalizedVersion = value.trim().replace(/^(?:docs-|gql-v?|v)/, ''); + if ( + normalizedVersion.length === 0 || + !/^[0-9A-Za-z][0-9A-Za-z.+_-]*$/.test(normalizedVersion) + ) { + throw new Error( + `expo-iap: Invalid OpenIAP spec version ${JSON.stringify(value)}.`, + ); + } + return `docs-${normalizedVersion}`; } -const PROJECT_ROOT = process.cwd(); - function parseArgs() { const args = process.argv.slice(2); - let tag = DEFAULT_TAG; + let version = null; for (let i = 0; i < args.length; i++) { const arg = args[i]; - if (arg === '--tag' && typeof args[i + 1] === 'string') { - tag = args[i + 1]; + if (arg === '--tag') { + const next = args[i + 1]; + if (typeof next !== 'string' || next.startsWith('--')) { + throw new Error('expo-iap: --tag requires a version.'); + } + version = next; i++; + continue; } + throw new Error(`expo-iap: Unknown argument ${arg}.`); } - return {tag}; + return {version}; } -function getReleaseUrl(tag) { - return `https://github.com/hyodotdev/openiap/releases/download/${tag}/openiap-typescript.zip`; -} - -function resolveCandidateTags(tag) { - if (tag.startsWith('gql-')) { - return [tag]; +function readPinnedSpecVersion() { + let versions; + try { + versions = JSON.parse( + readFileSync(join(PROJECT_ROOT, 'openiap-versions.json'), 'utf8'), + ); + } catch (error) { + throw new Error( + `expo-iap: Unable to load openiap-versions.json (${error instanceof Error ? error.message : error}). Provide --tag to override it.`, + ); } - // Prefer the new gql- scheme but fall back to legacy bare tags - return [`gql-${tag}`, tag]; + const version = versions?.spec; + if (typeof version !== 'string' || version.trim().length === 0) { + throw new Error( + 'expo-iap: "spec" version missing in openiap-versions.json. Provide --tag manually or update the file.', + ); + } + return version; } -function downloadTypesArchive(zipPath, tags) { - let resolvedTag = null; - let lastError = null; - - for (const [index, candidate] of tags.entries()) { - const releaseUrl = getReleaseUrl(candidate); - console.log(`Downloading OpenIAP types (tag: ${candidate}) from ${releaseUrl}`); +function getDownloadUrl(tag) { + return `https://raw.githubusercontent.com/hyodotdev/openiap/${tag}/${TARGET_REPOSITORY_PATH}`; +} - try { - execFileSync('curl', ['-L', '-o', zipPath, releaseUrl], { - stdio: 'inherit', - }); - resolvedTag = candidate; - break; - } catch (error) { - lastError = error; - const hasFallback = index < tags.length - 1; - console.warn( - `Failed to download for tag ${candidate}; ${ - hasFallback ? 'trying fallback' : 'no fallback available' - }.`, - ); - } +function validateDownloadedTypes(path) { + const contents = readFileSync(path, 'utf8'); + const lines = contents.split(/\r?\n/); + if ( + lines[0] !== HEADER_SEPARATOR || + lines[1] !== HEADER_LINE || + !contents.includes(REPRESENTATIVE_DECLARATION) || + !contents.endsWith('\n') + ) { + throw new Error( + 'expo-iap: Downloaded file is not the expected generated TypeScript target.', + ); } +} - if (!resolvedTag) { - throw lastError ?? new Error('Unable to download OpenIAP types archive.'); +function normalizeGeneratedHeader(path) { + const contents = readFileSync(path, 'utf8'); + const lineEnding = contents.includes('\r\n') ? '\r\n' : '\n'; + const lines = contents.split(/\r?\n/); + const expectedGuidance = `// ${HEADER_GUIDANCE}`; + const closingSeparator = lines.indexOf(HEADER_SEPARATOR, 2); + const guidanceLines = lines + .slice(2, closingSeparator) + .map((line, index) => ({index: index + 2, line})) + .filter( + ({line}) => + line === expectedGuidance || + /^\/\/ Run `[^`\r\n]+`[^\r\n]*\.$/.test(line), + ); + if (closingSeparator < 0 || guidanceLines.length !== 1) { + throw new Error( + 'expo-iap: Downloaded file has an unexpected generated header.', + ); + } + if (guidanceLines[0].line !== expectedGuidance) { + lines[guidanceLines[0].index] = expectedGuidance; + writeFileSync(path, lines.join(lineEnding)); } - - return resolvedTag; } function main() { - const {tag} = parseArgs(); - const candidateTags = resolveCandidateTags(tag); - const tempDir = mkdtempSync(join(tmpdir(), 'openiap-types-')); - const zipPath = join(tempDir, 'openiap-typescript.zip'); + const {version: versionOverride} = parseArgs(); + const tag = normalizeDocsTag(versionOverride ?? readPinnedSpecVersion()); + const downloadUrl = getDownloadUrl(tag); + const tempDir = mkdtempSync(join(dirname(TARGET_FILE), '.openiap-types-')); + const tempFile = join(tempDir, 'types.ts'); try { - const resolvedTag = downloadTypesArchive(zipPath, candidateTags); - - console.log('Extracting types.ts from archive'); - execFileSync('unzip', ['-o', zipPath, 'types.ts', '-d', tempDir], { + console.log(`Downloading OpenIAP types (tag: ${tag}) from ${downloadUrl}`); + execFileSync('curl', ['-fL', '-o', tempFile, downloadUrl], { stdio: 'inherit', }); - const extractedPath = join(tempDir, 'types.ts'); - let contents = readFileSync(extractedPath, 'utf8'); - contents = contents.replace( - /Run `[^`]+` after updating any \*\.graphql schema file\./, - 'Run `bun run generate:types` after updating any *.graphql schema file.', - ); - - const destination = join(PROJECT_ROOT, 'src', 'types.ts'); - writeFileSync(destination, contents); - console.log(`Updated src/types.ts from tag ${resolvedTag}`); + validateDownloadedTypes(tempFile); + normalizeGeneratedHeader(tempFile); + validateDownloadedTypes(tempFile); + chmodSync(tempFile, 0o644); + renameSync(tempFile, TARGET_FILE); + console.log(`Updated src/types.ts from tag ${tag}`); } finally { rmSync(tempDir, {recursive: true, force: true}); } diff --git a/libraries/expo-iap/src/types.ts b/libraries/expo-iap/src/types.ts index a9e259cf7..abed5a93e 100644 --- a/libraries/expo-iap/src/types.ts +++ b/libraries/expo-iap/src/types.ts @@ -1,6 +1,6 @@ // ============================================================================ // AUTO-GENERATED TYPES — DO NOT EDIT DIRECTLY -// Run `npm run generate` after updating any *.graphql schema file. +// Refresh this file with the generated-types workflow documented for your checkout. // ============================================================================ export interface ActiveSubscription { @@ -30,9 +30,9 @@ export interface ActiveSubscription { transactionDate: number; transactionId: string; /** - * @deprecated iOS only - use daysUntilExpirationIOS instead. * Whether the subscription will expire soon (within 7 days). * Consider using daysUntilExpirationIOS for more precise control. + * @deprecated iOS only - use daysUntilExpirationIOS instead. */ willExpireSoon?: (boolean | null); } @@ -90,8 +90,8 @@ export interface AdvancedCommerceRefundIOS { /** * Alternative billing mode for Android * Controls which billing system is used - * @deprecated Use enableBillingProgramAndroid with BillingProgramAndroid instead. * Use USER_CHOICE_BILLING for user choice billing, EXTERNAL_OFFER for alternative only. + * @deprecated Use enableBillingProgramAndroid with BillingProgramAndroid instead. */ export type AlternativeBillingModeAndroid = 'none' | 'user-choice' | 'alternative-only'; @@ -327,8 +327,8 @@ export interface DiscountDisplayInfoAndroid { /** * Discount information returned from the store. - * @deprecated Use the standardized SubscriptionOffer type instead for cross-platform compatibility. * @see https://openiap.dev/docs/types/subscription-offer + * @deprecated Use the standardized SubscriptionOffer type instead for cross-platform compatibility. */ export interface DiscountIOS { identifier: string; @@ -407,7 +407,11 @@ export interface DiscountOffer { purchaseOptionIdAndroid?: (string | null); /** [Android] Rental details if this is a rental offer. */ rentalDetailsAndroid?: (RentalDetailsAndroid | null); - /** Type of discount offer */ + /** + * Offer category. DiscountOffer currently represents Android one-time product + * offers and is populated as OneTime. Introductory and Promotional are used by + * SubscriptionOffer. + */ type: DiscountOfferType; /** * [Android] Valid time window for the offer. @@ -418,8 +422,8 @@ export interface DiscountOffer { /** * iOS DiscountOffer (output type). - * @deprecated Use the standardized SubscriptionOffer type instead for cross-platform compatibility. * @see https://openiap.dev/docs/types/subscription-offer + * @deprecated Use the standardized SubscriptionOffer type instead for cross-platform compatibility. */ export interface DiscountOfferIOS { /** Discount identifier */ @@ -485,8 +489,11 @@ export enum ErrorCode { PurchaseVerificationFinishFailed = 'purchase-verification-finish-failed', PurchaseVerificationFinished = 'purchase-verification-finished', QueryProduct = 'query-product', + /** @deprecated Use PurchaseVerificationFailed instead */ ReceiptFailed = 'receipt-failed', + /** @deprecated Use PurchaseVerificationFinished instead */ ReceiptFinished = 'receipt-finished', + /** @deprecated Use PurchaseVerificationFinishFailed instead */ ReceiptFinishedFailed = 'receipt-finished-failed', RemoteError = 'remote-error', ServiceDisconnected = 'service-disconnected', @@ -518,8 +525,8 @@ export type ExternalLinkTypeAndroid = 'unspecified' | 'link-to-digital-content-o /** * External offer availability result (Android) - * @deprecated Use BillingProgramAvailabilityResultAndroid with isBillingProgramAvailableAsync instead * Available in Google Play Billing Library 6.2.0+, deprecated in 8.2.0 + * @deprecated Use BillingProgramAvailabilityResultAndroid with isBillingProgramAvailableAsync instead */ export interface ExternalOfferAvailabilityResultAndroid { /** Whether external offers are available for the user */ @@ -528,8 +535,8 @@ export interface ExternalOfferAvailabilityResultAndroid { /** * External offer reporting details (Android) - * @deprecated Use BillingProgramReportingDetailsAndroid with createBillingProgramReportingDetailsAsync instead * Available in Google Play Billing Library 6.2.0+, deprecated in 8.2.0 + * @deprecated Use BillingProgramReportingDetailsAndroid with createBillingProgramReportingDetailsAsync instead */ export interface ExternalOfferReportingDetailsAndroid { /** External transaction token for reporting external offer transactions */ @@ -676,8 +683,8 @@ export interface InitConnectionConfig { /** * Alternative billing mode for Android * If not specified, defaults to NONE (standard Google Play billing) - * @deprecated Use enableBillingProgramAndroid instead. * Use USER_CHOICE_BILLING for user choice billing, EXTERNAL_OFFER for alternative only. + * @deprecated Use enableBillingProgramAndroid instead. */ alternativeBillingModeAndroid?: (AlternativeBillingModeAndroid | null); /** @@ -892,10 +899,8 @@ export interface Mutation { /** * Buy the currently promoted product. * - * @deprecated Use promotedProductListenerIOS to receive the productId, - * then call requestPurchase with that SKU instead. In StoreKit 2, - * promoted products can be purchased directly via the standard purchase flow. * See: https://openiap.dev/docs/apis/ios/request-purchase-on-promoted-product-ios + * @deprecated Use promotedProductListenerIOS to receive the productId, then call requestPurchase with that SKU instead. In StoreKit 2, promoted products can be purchased directly via the standard purchase flow. */ requestPurchaseOnPromotedProductIOS: Promise; /** @@ -969,8 +974,6 @@ export interface Mutation { verifyPurchaseWithProvider: Promise; } - - export type MutationAcknowledgePurchaseAndroidArgs = string; export type MutationBeginRefundRequestIosArgs = string; @@ -982,7 +985,6 @@ export interface MutationCreateBillingProgramReportingDetailsAndroidArgs { program: BillingProgramAndroid; } - export type MutationDeepLinkToSubscriptionsArgs = (DeepLinkOptions | null) | undefined; export interface MutationFinishTransactionArgs { @@ -990,7 +992,6 @@ export interface MutationFinishTransactionArgs { purchase: PurchaseInput; } - export type MutationInitConnectionArgs = (InitConnectionConfig | null) | undefined; export type MutationIsBillingProgramAvailableAndroidArgs = BillingProgramAndroid; @@ -999,22 +1000,7 @@ export type MutationLaunchExternalLinkAndroidArgs = LaunchExternalLinkParamsAndr export type MutationPresentExternalPurchaseLinkIosArgs = string; -export type MutationRequestPurchaseArgs = - | { - /** Per-platform purchase request props */ - request: RequestPurchasePropsByPlatforms; - type: 'in-app'; - /** Use alternative billing (Google Play alternative billing, Apple external purchase link) */ - useAlternativeBilling?: boolean | null; - } - | { - /** Per-platform subscription request props */ - request: RequestSubscriptionPropsByPlatforms; - type: 'subs'; - /** Use alternative billing (Google Play alternative billing, Apple external purchase link) */ - useAlternativeBilling?: boolean | null; - }; - +export type MutationRequestPurchaseArgs = RequestPurchaseProps; export type MutationShowBillingProgramInformationDialogAndroidArgs = BillingProgramInformationDialogParamsAndroid; @@ -1118,9 +1104,7 @@ export interface ProductAndroid extends ProductCommon { * Available in Google Play Billing Library 8.0.0+ */ productStatusAndroid?: (ProductStatusAndroid | null); - /** - * @deprecated Use subscriptionOffers instead for cross-platform compatibility. - */ + /** @deprecated Use subscriptionOffers instead for cross-platform compatibility. */ subscriptionOfferDetailsAndroid?: (ProductSubscriptionAndroidOfferDetails[] | null); /** * Standardized subscription offers. @@ -1135,8 +1119,8 @@ export interface ProductAndroid extends ProductCommon { /** * One-time purchase offer details (Android). * Available in Google Play Billing Library 8.0+ - * @deprecated Use the standardized DiscountOffer type for Android one-time offers. * @see https://openiap.dev/docs/types/discount-offer + * @deprecated Use the standardized DiscountOffer type for Android one-time offers. */ export interface ProductAndroidOneTimePurchaseOfferDetail { /** @@ -1207,9 +1191,7 @@ export interface ProductIOS extends ProductCommon { * monthly subscriptions with a 12-month commitment. */ pricingTermsIOS?: (SubscriptionPricingTermsIOS[] | null); - /** - * @deprecated Use subscriptionOffers instead for cross-platform compatibility. - */ + /** @deprecated Use subscriptionOffers instead for cross-platform compatibility. */ subscriptionInfoIOS?: (SubscriptionInfoIOS | null); /** * Standardized subscription offers. @@ -1258,8 +1240,7 @@ export interface ProductSubscriptionAndroid extends ProductCommon { /** * Legacy nullable compatibility field. Google Play does not populate one-time * purchase offer details for subscription products. - * @deprecated One-time offers belong to ProductAndroid.discountOffers; - * subscriptions use subscriptionOffers. + * @deprecated One-time offers belong to ProductAndroid.discountOffers; subscriptions use subscriptionOffers. */ oneTimePurchaseOfferDetailsAndroid?: (ProductAndroidOneTimePurchaseOfferDetail[] | null); platform: 'android'; @@ -1272,9 +1253,7 @@ export interface ProductSubscriptionAndroid extends ProductCommon { * Available in Google Play Billing Library 8.0.0+ */ productStatusAndroid?: (ProductStatusAndroid | null); - /** - * @deprecated Use subscriptionOffers instead for cross-platform compatibility. - */ + /** @deprecated Use subscriptionOffers instead for cross-platform compatibility. */ subscriptionOfferDetailsAndroid: ProductSubscriptionAndroidOfferDetails[]; /** * Standardized subscription offers. @@ -1288,8 +1267,8 @@ export interface ProductSubscriptionAndroid extends ProductCommon { /** * Subscription offer details (Android). - * @deprecated Use the standardized SubscriptionOffer type instead for cross-platform compatibility. * @see https://openiap.dev/docs/types/subscription-offer + * @deprecated Use the standardized SubscriptionOffer type instead for cross-platform compatibility. */ export interface ProductSubscriptionAndroidOfferDetails { basePlanId: string; @@ -1309,9 +1288,7 @@ export interface ProductSubscriptionIOS extends ProductCommon { currency: string; debugDescription?: (string | null); description: string; - /** - * @deprecated Use subscriptionOffers instead for cross-platform compatibility. - */ + /** @deprecated Use subscriptionOffers instead for cross-platform compatibility. */ discountsIOS?: (DiscountIOS[] | null); displayName?: (string | null); displayNameIOS: string; @@ -1333,9 +1310,7 @@ export interface ProductSubscriptionIOS extends ProductCommon { pricingTermsIOS?: (SubscriptionPricingTermsIOS[] | null); /** App Store subscription group identifier for intro-offer eligibility checks. */ subscriptionGroupIdIOS?: (string | null); - /** - * @deprecated Use subscriptionOffers for offer metadata and subscriptionGroupIdIOS for the App Store subscription group identifier. - */ + /** @deprecated Use subscriptionOffers for offer metadata and subscriptionGroupIdIOS for the App Store subscription group identifier. */ subscriptionInfoIOS?: (SubscriptionInfoIOS | null); /** * Standardized subscription offers. @@ -1663,8 +1638,6 @@ export interface Query { validateReceiptIOS: Promise; } - - export type QueryCurrentEntitlementIosArgs = string; export type QueryFetchProductsArgs = ProductRequest; @@ -1825,15 +1798,23 @@ export type RequestPurchaseProps = | { /** Per-platform purchase request props */ request: RequestPurchasePropsByPlatforms; + /** Explicit purchase type hint (defaults to in-app) */ type: 'in-app'; - /** Use alternative billing (Google Play alternative billing, Apple external purchase link) */ + /** + * This flag only logs debug info and has no effect on the purchase flow. + * @deprecated Use enableBillingProgramAndroid in InitConnectionConfig instead. + */ useAlternativeBilling?: boolean | null; } | { /** Per-platform subscription request props */ request: RequestSubscriptionPropsByPlatforms; + /** Explicit purchase type hint (defaults to in-app) */ type: 'subs'; - /** Use alternative billing (Google Play alternative billing, Apple external purchase link) */ + /** + * This flag only logs debug info and has no effect on the purchase flow. + * @deprecated Use enableBillingProgramAndroid in InitConnectionConfig instead. + */ useAlternativeBilling?: boolean | null; }; @@ -1885,7 +1866,7 @@ export interface RequestSubscriptionAndroidProps { purchaseToken?: (string | null); /** * Replacement mode for subscription changes - * @deprecated Use subscriptionProductReplacementParams instead for item-level replacement (8.1.0+) + * @deprecated Use subscriptionProductReplacementParams instead for item-level replacement (8.1.0+). */ replacementMode?: (number | null); /** List of subscription SKUs */ @@ -2091,8 +2072,6 @@ export interface Subscription { userChoiceBillingAndroid: UserChoiceBillingDetails; } - - export type SubscriptionPurchaseUpdatedArgs = (PurchaseUpdatedListenerOptions | null) | undefined; export type SubscriptionBillingPlanTypeIOS = 'unknown' | 'monthly' | 'up-front'; @@ -2193,8 +2172,8 @@ export interface SubscriptionOffer { /** * iOS subscription offer details. - * @deprecated Use the standardized SubscriptionOffer type instead for cross-platform compatibility. * @see https://openiap.dev/docs/types/subscription-offer + * @deprecated Use the standardized SubscriptionOffer type instead for cross-platform compatibility. */ export interface SubscriptionOfferIOS { displayPrice: string; @@ -2508,44 +2487,6 @@ export interface WinBackOfferInputIOS { /** The win-back offer ID from App Store Connect */ offerId: string; } -// -- Query helper types (auto-generated) -export type QueryArgsMap = { - canPresentExternalPurchaseNoticeIOS: never; - currentEntitlementIOS: QueryCurrentEntitlementIosArgs; - fetchProducts: QueryFetchProductsArgs; - getActiveSubscriptions: QueryGetActiveSubscriptionsArgs; - getAllTransactionsIOS: never; - getAppTransactionIOS: never; - getAvailablePurchases: QueryGetAvailablePurchasesArgs; - getBillingChoiceInfoAndroid: QueryGetBillingChoiceInfoAndroidArgs; - getExternalPurchaseCustomLinkTokenIOS: QueryGetExternalPurchaseCustomLinkTokenIosArgs; - getPendingTransactionsIOS: never; - getPromotedProductIOS: never; - getReceiptDataIOS: never; - getStorefront: never; - getStorefrontIOS: never; - getTransactionJwsIOS: QueryGetTransactionJwsIosArgs; - hasActiveSubscriptions: QueryHasActiveSubscriptionsArgs; - isEligibleForExternalPurchaseCustomLinkIOS: never; - isEligibleForIntroOfferIOS: QueryIsEligibleForIntroOfferIosArgs; - isTransactionVerifiedIOS: QueryIsTransactionVerifiedIosArgs; - latestTransactionIOS: QueryLatestTransactionIosArgs; - subscriptionStatusIOS: QuerySubscriptionStatusIosArgs; - validateReceiptIOS: QueryValidateReceiptIosArgs; -}; - -export type QueryField = - QueryArgsMap[K] extends never - ? () => NonNullable - : undefined extends QueryArgsMap[K] - ? (args?: QueryArgsMap[K]) => NonNullable - : (args: QueryArgsMap[K]) => NonNullable; - -export type QueryFieldMap = { - [K in keyof Query]?: QueryField; -}; -// -- End query helper types - // -- Mutation helper types (auto-generated) export type MutationArgsMap = { acknowledgePurchaseAndroid: MutationAcknowledgePurchaseAndroidArgs; @@ -2591,6 +2532,44 @@ export type MutationFieldMap = { }; // -- End mutation helper types +// -- Query helper types (auto-generated) +export type QueryArgsMap = { + canPresentExternalPurchaseNoticeIOS: never; + currentEntitlementIOS: QueryCurrentEntitlementIosArgs; + fetchProducts: QueryFetchProductsArgs; + getActiveSubscriptions: QueryGetActiveSubscriptionsArgs; + getAllTransactionsIOS: never; + getAppTransactionIOS: never; + getAvailablePurchases: QueryGetAvailablePurchasesArgs; + getBillingChoiceInfoAndroid: QueryGetBillingChoiceInfoAndroidArgs; + getExternalPurchaseCustomLinkTokenIOS: QueryGetExternalPurchaseCustomLinkTokenIosArgs; + getPendingTransactionsIOS: never; + getPromotedProductIOS: never; + getReceiptDataIOS: never; + getStorefront: never; + getStorefrontIOS: never; + getTransactionJwsIOS: QueryGetTransactionJwsIosArgs; + hasActiveSubscriptions: QueryHasActiveSubscriptionsArgs; + isEligibleForExternalPurchaseCustomLinkIOS: never; + isEligibleForIntroOfferIOS: QueryIsEligibleForIntroOfferIosArgs; + isTransactionVerifiedIOS: QueryIsTransactionVerifiedIosArgs; + latestTransactionIOS: QueryLatestTransactionIosArgs; + subscriptionStatusIOS: QuerySubscriptionStatusIosArgs; + validateReceiptIOS: QueryValidateReceiptIosArgs; +}; + +export type QueryField = + QueryArgsMap[K] extends never + ? () => NonNullable + : undefined extends QueryArgsMap[K] + ? (args?: QueryArgsMap[K]) => NonNullable + : (args: QueryArgsMap[K]) => NonNullable; + +export type QueryFieldMap = { + [K in keyof Query]?: QueryField; +}; +// -- End query helper types + // -- Subscription helper types (auto-generated) export type SubscriptionArgsMap = { developerProvidedBillingAndroid: never; diff --git a/libraries/flutter_inapp_purchase/CLAUDE.md b/libraries/flutter_inapp_purchase/CLAUDE.md index 150cca65e..1eabb0287 100644 --- a/libraries/flutter_inapp_purchase/CLAUDE.md +++ b/libraries/flutter_inapp_purchase/CLAUDE.md @@ -57,8 +57,8 @@ final allPurchases = await iap.getAvailablePurchases( ### Generated Files - `lib/types.dart` is generated from the OpenIAP GraphQL schema (`packages/gql`). Never edit it by hand. -- In monorepo: run `./scripts/sync-versions.sh` from the repo root to sync types from `packages/gql/src/generated/types.dart`. -- Standalone: run `./scripts/generate-type.sh` to download from GitHub Releases. +- In monorepo: run `cd packages/gql && bun run generate` from the repo root to regenerate every language and sync every manifest target. +- Standalone: run `./scripts/generate-type.sh` to validate and atomically install the platform-ready `lib/types.dart` file from the raw `docs-${spec}` tag pinned by `openiap-versions.json`. - If the generation script fails, fix the schema in `packages/gql/` instead of patching the output manually. ### Using `lib/types.dart` diff --git a/libraries/flutter_inapp_purchase/lib/types.dart b/libraries/flutter_inapp_purchase/lib/types.dart index 6ec1d075f..61e9f9606 100644 --- a/libraries/flutter_inapp_purchase/lib/types.dart +++ b/libraries/flutter_inapp_purchase/lib/types.dart @@ -1,6 +1,6 @@ // ============================================================================ // AUTO-GENERATED TYPES — DO NOT EDIT DIRECTLY -// Run `bun run generate` after updating any *.graphql schema file. +// Refresh this file with the generated-types workflow documented for your checkout. // ============================================================================ // ignore_for_file: unused_element, unused_field @@ -11,18 +11,18 @@ import 'dart:async'; /// Alternative billing mode for Android /// Controls which billing system is used -/// @deprecated Use enableBillingProgramAndroid with BillingProgramAndroid instead. /// Use USER_CHOICE_BILLING for user choice billing, EXTERNAL_OFFER for alternative only. +/// @deprecated Use enableBillingProgramAndroid with BillingProgramAndroid instead. enum AlternativeBillingModeAndroid { /// Standard Google Play billing (default) None('none'), /// User choice billing - user can select between Google Play or alternative /// Requires Google Play Billing Library 7.0+ - /// @deprecated Use BillingProgramAndroid.USER_CHOICE_BILLING instead + /// @deprecated Use BillingProgramAndroid.USER_CHOICE_BILLING instead. UserChoice('user-choice'), /// Alternative billing only - no Google Play billing option /// Requires Google Play Billing Library 6.2+ - /// @deprecated Use BillingProgramAndroid.EXTERNAL_OFFER instead + /// @deprecated Use BillingProgramAndroid.EXTERNAL_OFFER instead. AlternativeOnly('alternative-only'); const AlternativeBillingModeAndroid(this.value); @@ -255,8 +255,11 @@ enum ErrorCode { RemoteError('remote-error'), NetworkError('network-error'), ServiceError('service-error'), + /// @deprecated Use PurchaseVerificationFailed instead ReceiptFailed('receipt-failed'), + /// @deprecated Use PurchaseVerificationFinished instead ReceiptFinished('receipt-finished'), + /// @deprecated Use PurchaseVerificationFinishFailed instead ReceiptFinishedFailed('receipt-finished-failed'), PurchaseVerificationFailed('purchase-verification-failed'), PurchaseVerificationFinished('purchase-verification-finished'), @@ -1398,6 +1401,7 @@ abstract class PurchaseCommon { String get id; List? get ids; bool get isAutoRenewing; + /// @deprecated Use store instead IapPlatform get platform; String get productId; PurchaseState get purchaseState; @@ -1451,9 +1455,9 @@ class ActiveSubscription { /// Unix timestamp in milliseconds since January 1, 1970 UTC. final double transactionDate; final String transactionId; - /// @deprecated iOS only - use daysUntilExpirationIOS instead. /// Whether the subscription will expire soon (within 7 days). /// Consider using daysUntilExpirationIOS for more precise control. + /// @deprecated iOS only - use daysUntilExpirationIOS instead. final bool? willExpireSoon; factory ActiveSubscription.fromJson(Map json) { @@ -1981,8 +1985,8 @@ class DiscountDisplayInfoAndroid { } /// Discount information returned from the store. -/// @deprecated Use the standardized SubscriptionOffer type instead for cross-platform compatibility. /// @see https://openiap.dev/docs/types/subscription-offer +/// @deprecated Use the standardized SubscriptionOffer type instead for cross-platform compatibility. class DiscountIOS { const DiscountIOS({ required this.identifier, @@ -2099,7 +2103,9 @@ class DiscountOffer { final String? purchaseOptionIdAndroid; /// [Android] Rental details if this is a rental offer. final RentalDetailsAndroid? rentalDetailsAndroid; - /// Type of discount offer + /// Offer category. DiscountOffer currently represents Android one-time product + /// offers and is populated as OneTime. Introductory and Promotional are used by + /// SubscriptionOffer. final DiscountOfferType type; /// [Android] Valid time window for the offer. /// Contains startTimeMillis and endTimeMillis. @@ -2150,8 +2156,8 @@ class DiscountOffer { } /// iOS DiscountOffer (output type). -/// @deprecated Use the standardized SubscriptionOffer type instead for cross-platform compatibility. /// @see https://openiap.dev/docs/types/subscription-offer +/// @deprecated Use the standardized SubscriptionOffer type instead for cross-platform compatibility. class DiscountOfferIOS { const DiscountOfferIOS({ required this.identifier, @@ -2224,8 +2230,8 @@ class EntitlementIOS { } /// External offer availability result (Android) -/// @deprecated Use BillingProgramAvailabilityResultAndroid with isBillingProgramAvailableAsync instead /// Available in Google Play Billing Library 6.2.0+, deprecated in 8.2.0 +/// @deprecated Use BillingProgramAvailabilityResultAndroid with isBillingProgramAvailableAsync instead class ExternalOfferAvailabilityResultAndroid { const ExternalOfferAvailabilityResultAndroid({ required this.isAvailable, @@ -2249,8 +2255,8 @@ class ExternalOfferAvailabilityResultAndroid { } /// External offer reporting details (Android) -/// @deprecated Use BillingProgramReportingDetailsAndroid with createBillingProgramReportingDetailsAsync instead /// Available in Google Play Billing Library 6.2.0+, deprecated in 8.2.0 +/// @deprecated Use BillingProgramReportingDetailsAndroid with createBillingProgramReportingDetailsAsync instead class ExternalOfferReportingDetailsAndroid { const ExternalOfferReportingDetailsAndroid({ required this.externalTransactionToken, @@ -2768,8 +2774,8 @@ class ProductAndroid extends Product implements ProductCommon { /// One-time purchase offer details (Android). /// Available in Google Play Billing Library 8.0+ -/// @deprecated Use the standardized DiscountOffer type for Android one-time offers. /// @see https://openiap.dev/docs/types/discount-offer +/// @deprecated Use the standardized DiscountOffer type for Android one-time offers. class ProductAndroidOneTimePurchaseOfferDetail { const ProductAndroidOneTimePurchaseOfferDetail({ this.discountDisplayInfo, @@ -2979,8 +2985,7 @@ class ProductSubscriptionAndroid extends ProductSubscription implements ProductC final String nameAndroid; /// Legacy nullable compatibility field. Google Play does not populate one-time /// purchase offer details for subscription products. - /// @deprecated One-time offers belong to ProductAndroid.discountOffers; - /// subscriptions use subscriptionOffers. + /// @deprecated One-time offers belong to ProductAndroid.discountOffers; subscriptions use subscriptionOffers. final List? oneTimePurchaseOfferDetailsAndroid; final IapPlatform platform; final double? price; @@ -3045,8 +3050,8 @@ class ProductSubscriptionAndroid extends ProductSubscription implements ProductC } /// Subscription offer details (Android). -/// @deprecated Use the standardized SubscriptionOffer type instead for cross-platform compatibility. /// @see https://openiap.dev/docs/types/subscription-offer +/// @deprecated Use the standardized SubscriptionOffer type instead for cross-platform compatibility. class ProductSubscriptionAndroidOfferDetails { const ProductSubscriptionAndroidOfferDetails({ required this.basePlanId, @@ -3270,6 +3275,7 @@ class PurchaseAndroid extends Purchase implements PurchaseCommon { /// Returns null if no pending update exists. /// Available in Google Play Billing Library 5.0+ final PendingPurchaseUpdateAndroid? pendingPurchaseUpdateAndroid; + /// @deprecated Use store instead final IapPlatform platform; final String productId; final PurchaseState purchaseState; @@ -3461,6 +3467,7 @@ class PurchaseIOS extends Purchase implements PurchaseCommon { final double? originalTransactionDateIOS; final String? originalTransactionIdentifierIOS; final String? ownershipTypeIOS; + /// @deprecated Use store instead final IapPlatform platform; final String productId; final PurchaseState purchaseState; @@ -4041,8 +4048,8 @@ class SubscriptionOffer { } /// iOS subscription offer details. -/// @deprecated Use the standardized SubscriptionOffer type instead for cross-platform compatibility. /// @see https://openiap.dev/docs/types/subscription-offer +/// @deprecated Use the standardized SubscriptionOffer type instead for cross-platform compatibility. class SubscriptionOfferIOS { const SubscriptionOfferIOS({ required this.displayPrice, @@ -4871,8 +4878,8 @@ class InitConnectionConfig { /// Alternative billing mode for Android /// If not specified, defaults to NONE (standard Google Play billing) - /// @deprecated Use enableBillingProgramAndroid instead. /// Use USER_CHOICE_BILLING for user choice billing, EXTERNAL_OFFER for alternative only. + /// @deprecated Use enableBillingProgramAndroid instead. final AlternativeBillingModeAndroid? alternativeBillingModeAndroid; /// Billing Choice renderer configured in Play Console. Available in OpenIAP /// Spec 2.1.0 / openiap-google 2.3.0 (requires Play Billing 9.1.0+). @@ -5175,15 +5182,21 @@ class RequestPurchaseIosProps { sealed class RequestPurchaseProps { const RequestPurchaseProps._(); + /// Per-platform purchase request props const factory RequestPurchaseProps.inApp(({ RequestPurchaseIosProps? apple, RequestPurchaseAndroidProps? google, + /// This flag only logs debug info and has no effect on the purchase flow. + /// @deprecated Use enableBillingProgramAndroid in InitConnectionConfig instead. bool? useAlternativeBilling, }) props) = _InAppPurchase; + /// Per-platform subscription request props const factory RequestPurchaseProps.subs(({ RequestSubscriptionIosProps? apple, RequestSubscriptionAndroidProps? google, + /// This flag only logs debug info and has no effect on the purchase flow. + /// @deprecated Use enableBillingProgramAndroid in InitConnectionConfig instead. bool? useAlternativeBilling, }) props) = _SubsPurchase; @@ -5307,7 +5320,7 @@ class RequestSubscriptionAndroidProps { /// Purchase token for upgrades/downgrades final String? purchaseToken; /// Replacement mode for subscription changes - /// @deprecated Use subscriptionProductReplacementParams instead for item-level replacement (8.1.0+) + /// @deprecated Use subscriptionProductReplacementParams instead for item-level replacement (8.1.0+). final int? replacementMode; /// List of subscription SKUs final List skus; @@ -5961,6 +5974,7 @@ sealed class Purchase implements PurchaseCommon { List? get ids; @override bool get isAutoRenewing; + /// @deprecated Use store instead @override IapPlatform get platform; @override @@ -6120,10 +6134,8 @@ abstract class MutationResolver { Future requestPurchase(RequestPurchaseProps params); /// Buy the currently promoted product. /// - /// @deprecated Use promotedProductListenerIOS to receive the productId, - /// then call requestPurchase with that SKU instead. In StoreKit 2, - /// promoted products can be purchased directly via the standard purchase flow. /// See: https://openiap.dev/docs/apis/ios/request-purchase-on-promoted-product-ios + /// @deprecated Use promotedProductListenerIOS to receive the productId, then call requestPurchase with that SKU instead. In StoreKit 2, promoted products can be purchased directly via the standard purchase flow. Future requestPurchaseOnPromotedProductIOS(); /// Restore non-consumable and active subscription purchases. /// See: https://openiap.dev/docs/apis/restore-purchases @@ -6147,6 +6159,7 @@ abstract class MutationResolver { /// Call this after a deliberate customer interaction before linking out to external purchases. /// Reference: https://developer.apple.com/documentation/storekit/externalpurchasecustomlink/shownotice(type:) /// See: https://openiap.dev/docs/apis/ios/show-external-purchase-custom-link-notice-ios + /// Parameter noticeType: Notice type determining the style of disclosure Future showExternalPurchaseCustomLinkNoticeIOS(ExternalPurchaseCustomLinkNoticeTypeIOS noticeType); /// Overlay Play billing in-app messages, such as payment issues or subscription price-change confirmations. /// OpenIAP availability: Spec 2.1.0 / openiap-google 2.3.0 @@ -6165,6 +6178,7 @@ abstract class MutationResolver { Future syncIOS(); /// Deprecated. Validate purchase receipts with the configured providers — use verifyPurchase instead. /// See: https://openiap.dev/docs/features/validation#verify-purchase + /// @deprecated Use verifyPurchase Future validateReceipt({ VerifyPurchaseAppleOptions? apple, VerifyPurchaseGoogleOptions? google, @@ -6238,6 +6252,7 @@ abstract class QueryResolver { /// Use this token to report transactions made through ExternalPurchaseCustomLink. /// Reference: https://developer.apple.com/documentation/storekit/externalpurchasecustomlink/token(for:) /// See: https://openiap.dev/docs/apis/ios/get-external-purchase-custom-link-token-ios + /// Parameter tokenType: Token type: acquisition (new customers) or services (existing customers) Future getExternalPurchaseCustomLinkTokenIOS(ExternalPurchaseCustomLinkTokenTypeIOS tokenType); /// List unfinished StoreKit transactions in the queue. /// See: https://openiap.dev/docs/apis/ios/get-pending-transactions-ios @@ -6256,6 +6271,7 @@ abstract class QueryResolver { /// Deprecated. Get the current App Store storefront ISO 3166-1 alpha-3 country /// code — use cross-platform getStorefront instead. /// See: https://openiap.dev/docs/apis/ios/get-storefront-ios + /// @deprecated Use getStorefront Future getStorefrontIOS(); /// Return the JWS string for a transaction (StoreKit 2). /// See: https://openiap.dev/docs/apis/ios/get-transaction-jws-ios @@ -6282,6 +6298,7 @@ abstract class QueryResolver { Future> subscriptionStatusIOS(String sku); /// Deprecated. Legacy App Store receipt validation — use verifyPurchase instead. /// See: https://openiap.dev/docs/apis/ios/validate-receipt-ios + /// @deprecated Use verifyPurchase Future validateReceiptIOS({ VerifyPurchaseAppleOptions? apple, VerifyPurchaseGoogleOptions? google, diff --git a/libraries/flutter_inapp_purchase/scripts/generate-type.sh b/libraries/flutter_inapp_purchase/scripts/generate-type.sh index 1027a4413..dcf5db07f 100755 --- a/libraries/flutter_inapp_purchase/scripts/generate-type.sh +++ b/libraries/flutter_inapp_purchase/scripts/generate-type.sh @@ -4,16 +4,18 @@ set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" TARGET_FILE="${REPO_ROOT}/lib/types.dart" -TMP_DIR="$(mktemp -d)" VERSIONS_FILE="${REPO_ROOT}/openiap-versions.json" +TARGET_REPOSITORY_PATH="libraries/flutter_inapp_purchase/lib/types.dart" +HEADER_GUIDANCE="Refresh this file with the generated-types workflow documented for your checkout." if ! command -v python3 >/dev/null 2>&1; then echo "Error: python3 is required but not installed." >&2 exit 1 fi -OPENIAP_GQL_VERSION=$(python3 - "${VERSIONS_FILE}" <<'PY' +SPEC_VERSION=$(python3 - "${VERSIONS_FILE}" <<'PY' import json +import re import sys from pathlib import Path versions_path = Path(sys.argv[1]) @@ -26,53 +28,89 @@ except json.JSONDecodeError as exc: print(f"Error parsing {versions_path}: {exc}", file=sys.stderr) sys.exit(1) -value = data.get('spec') -if not value: +value = data.get("spec") +if not isinstance(value, str) or not value.strip(): print("Error: 'spec' version missing in openiap-versions.json", file=sys.stderr) sys.exit(1) +value = value.strip() +if not re.fullmatch(r"[0-9A-Za-z][0-9A-Za-z.+_-]*", value): + print(f"Error: invalid 'spec' version {value!r}", file=sys.stderr) + sys.exit(1) print(value) PY ) -ZIP_URL="https://github.com/hyodotdev/openiap/releases/download/gql-${OPENIAP_GQL_VERSION}/openiap-dart.zip" +TAG="docs-${SPEC_VERSION}" +DOWNLOAD_URL="https://raw.githubusercontent.com/hyodotdev/openiap/${TAG}/${TARGET_REPOSITORY_PATH}" cleanup() { - rm -rf "${TMP_DIR}" + if [[ -n "${TEMP_FILE:-}" && -f "${TEMP_FILE}" ]]; then + rm -f "${TEMP_FILE}" + fi } trap cleanup EXIT -ZIP_PATH="${TMP_DIR}/openiap-dart.zip" - if ! command -v curl >/dev/null 2>&1; then echo "Error: curl is required but not installed." >&2 exit 1 fi -if ! command -v unzip >/dev/null 2>&1; then - echo "Error: unzip is required but not installed." >&2 - exit 1 -fi - -echo "Downloading openiap-dart.zip from ${ZIP_URL}..." -curl -fL "${ZIP_URL}" -o "${ZIP_PATH}" +mkdir -p "$(dirname "${TARGET_FILE}")" +TEMP_FILE=$(mktemp "${TARGET_FILE}.tmp.XXXXXX") -echo "Extracting types.dart..." -unzip -q -d "${TMP_DIR}" "${ZIP_PATH}" types.dart +echo "Downloading the platform-ready Dart types from ${DOWNLOAD_URL}..." +curl -fL "${DOWNLOAD_URL}" -o "$TEMP_FILE" -if [ ! -f "${TMP_DIR}/types.dart" ]; then - echo "Error: types.dart not found in archive." >&2 +if ! grep -Fq 'AUTO-GENERATED TYPES — DO NOT EDIT DIRECTLY' "$TEMP_FILE" || + ! grep -q '^class ProductRequest {' "$TEMP_FILE" || + [[ -n "$(tail -c 1 "$TEMP_FILE")" ]]; then + echo "Error: downloaded file is not the expected generated Dart target." >&2 exit 1 fi -mkdir -p "$(dirname "${TARGET_FILE}")" +python3 - "$TEMP_FILE" "//" "$HEADER_GUIDANCE" <<'PY' +import re +import sys +from pathlib import Path + +path = Path(sys.argv[1]) +prefix = sys.argv[2] +guidance = sys.argv[3] +lines = path.read_text(encoding="utf-8").splitlines(keepends=True) +separator = f"{prefix} " + "=" * 76 +header = f"{prefix} AUTO-GENERATED TYPES — DO NOT EDIT DIRECTLY" +expected = f"{prefix} {guidance}" +plain_lines = [line.rstrip("\r\n") for line in lines] +if len(lines) < 4 or plain_lines[0] != separator or plain_lines[1] != header: + print("Error: downloaded file has an unexpected generated header.", file=sys.stderr) + sys.exit(1) +try: + closing_index = plain_lines.index(separator, 2) +except ValueError: + print("Error: downloaded file has an unterminated generated header.", file=sys.stderr) + sys.exit(1) +candidates = [ + index + for index in range(2, closing_index) + if plain_lines[index] == expected + or re.fullmatch( + re.escape(prefix) + r" Run `[^`\r\n]+`[^\r\n]*\.", + plain_lines[index], + ) +] +if len(candidates) != 1: + print("Error: downloaded file has unexpected generated guidance.", file=sys.stderr) + sys.exit(1) +guidance_index = candidates[0] +if plain_lines[guidance_index] != expected: + ending = "\r\n" if lines[guidance_index].endswith("\r\n") else "\n" + lines[guidance_index] = expected + ending + path.write_text("".join(lines), encoding="utf-8") +PY -echo "Replacing ${TARGET_FILE}" -# Add ignore directives at the top of the file -# Note: dart format doesn't respect these, but analyzer and coverage do -echo "// ignore_for_file: type=lint" > "${TARGET_FILE}" -echo "// coverage:ignore-file" >> "${TARGET_FILE}" -echo "" >> "${TARGET_FILE}" -cat "${TMP_DIR}/types.dart" >> "${TARGET_FILE}" +chmod 0644 "$TEMP_FILE" +mv -f "$TEMP_FILE" "$TARGET_FILE" +TEMP_FILE="" -echo "Done." +echo "Updated ${TARGET_FILE} from tag ${TAG}." diff --git a/libraries/flutter_inapp_purchase/scripts/update-types.mjs b/libraries/flutter_inapp_purchase/scripts/update-types.mjs deleted file mode 100644 index 9867ded74..000000000 --- a/libraries/flutter_inapp_purchase/scripts/update-types.mjs +++ /dev/null @@ -1,104 +0,0 @@ -#!/usr/bin/env node -import fs from "fs/promises"; -import { createWriteStream } from "fs"; -import os from "os"; -import path from "path"; -import { fileURLToPath } from "url"; -import { Readable } from "stream"; -import { pipeline as pipelinePromises } from "stream/promises"; -import { execFile } from "child_process"; -import { promisify } from "util"; - -const execFileAsync = promisify(execFile); - -const __filename = fileURLToPath(import.meta.url); -const __dirname = path.dirname(__filename); -const repoRoot = path.resolve(__dirname, ".."); -const versionsPath = path.join(repoRoot, "openiap-versions.json"); -const targetFile = path.join(repoRoot, "src", "types.ts"); - -if (typeof fetch !== "function") { - console.error("Node 18+ with global fetch is required to run this script."); - process.exit(1); -} - -const toNodeStream = (body) => - body instanceof Readable ? body : Readable.fromWeb(body); - -async function readDefaultTag() { - try { - const raw = await fs.readFile(versionsPath, "utf8"); - const parsed = JSON.parse(raw); - if (!parsed.spec) { - throw new Error("Missing 'spec' entry in openiap-versions.json"); - } - return parsed.spec; - } catch (error) { - throw new Error( - `Unable to read default tag from ${versionsPath}: ${error.message}`, - ); - } -} - -function buildCandidateTags(inputTag) { - if (inputTag.startsWith("gql-")) { - return [inputTag]; - } - return [`gql-${inputTag}`, inputTag]; -} - -async function downloadZip(url, destination) { - const response = await fetch(url); - if (!response.ok || !response.body) { - throw new Error(`HTTP ${response.status} when fetching ${url}`); - } - await pipelinePromises(toNodeStream(response.body), createWriteStream(destination)); -} - -async function extractTypes(zipPath, destinationDir) { - await execFileAsync("unzip", ["-o", zipPath, "types.ts", "-d", destinationDir]); - const extracted = path.join(destinationDir, "types.ts"); - return fs.readFile(extracted, "utf8"); -} - -async function updateTypes() { - const requestedTag = process.argv[2] || (await readDefaultTag()); - const candidateTags = buildCandidateTags(requestedTag); - - const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openiap-types-")); - const zipPath = path.join(tempDir, "openiap-dart.zip"); - - let resolvedTag = null; - for (const tag of candidateTags) { - const url = `https://github.com/hyodotdev/openiap/releases/download/${tag}/openiap-dart.zip`; - try { - console.log(`Attempting to download types from tag ${tag}...`); - await downloadZip(url, zipPath); - const contents = await extractTypes(zipPath, tempDir); - - await fs.mkdir(path.dirname(targetFile), { recursive: true }); - await fs.writeFile(targetFile, contents); - resolvedTag = tag; - console.log(`Updated src/types.ts from tag ${tag}`); - break; - } catch (error) { - console.warn( - `Failed to update types for tag ${tag}: ${error.message}. Falling back...`, - ); - } - } - - await fs.rm(tempDir, { recursive: true, force: true }); - - if (!resolvedTag) { - console.error( - `Failed to update types. Tried tags: ${candidateTags.join(", ")}`, - ); - process.exit(1); - } -} - -updateTypes().catch((error) => { - console.error(error); - process.exit(1); -}); diff --git a/libraries/godot-iap/CLAUDE.md b/libraries/godot-iap/CLAUDE.md index 0fd90beb9..9f41b683a 100644 --- a/libraries/godot-iap/CLAUDE.md +++ b/libraries/godot-iap/CLAUDE.md @@ -81,10 +81,13 @@ const PRODUCT_PREMIUM := "com.example.premium" ### Types (auto-generated from OpenIAP GraphQL schema) Types are generated from `openiap/packages/gql` and should not be modified manually: -- `scripts/generate-types.sh` - Downloads latest `types.gd` from openiap releases -- `addons/godot-iap/types.gd` and `Example/addons/godot-iap/types.gd` - Auto-generated type definitions + +- In this monorepo, run `cd packages/gql && bun run generate` from the repository root so every manifest target stays synchronized. +- `scripts/generate-types.sh` is only for a standalone checkout and validates then atomically installs the platform-ready `types.gd` file from the raw `docs-${spec}` tag pinned by `openiap-versions.json`. +- `addons/godot-iap/types.gd` is the one generated type file. `Example/addons` is a tracked symlink to `../addons`, so the example sees the canonical file rather than owning a second generated copy. Key types: + - `Types.ProductRequest` - Input for `fetch_products()` - `Types.ProductAndroid`, `Types.ProductIOS` - Product info - `Types.RequestPurchaseProps` - Input for `request_purchase()` diff --git a/libraries/godot-iap/CONTRIBUTING.md b/libraries/godot-iap/CONTRIBUTING.md index a5e5e9496..52cc065f0 100644 --- a/libraries/godot-iap/CONTRIBUTING.md +++ b/libraries/godot-iap/CONTRIBUTING.md @@ -74,7 +74,7 @@ make test-ios # Reset TestProject, copy binaries, open Xcode ### Testing Workflow -1. **Development**: Make changes in `Example/addons/godot-iap/` and test with `make run-*` +1. **Development**: Make changes in the canonical `addons/godot-iap/` tree (the tracked `Example/addons` symlink exposes the same files) and test with `make run-*` 2. **Pre-release**: Test with `make test-*` to verify the release zip structure works correctly 3. **TestProject** is automatically reset from `Example/` on each test run @@ -101,7 +101,7 @@ Follow the conventions in [CLAUDE.md](CLAUDE.md): ### Modifying the Plugin -1. Edit files in `Example/addons/godot-iap/` +1. Edit files in `addons/godot-iap/`; do not maintain a second copy under `Example/` 2. Test with `make run-android` or `make run-ios` 3. Verify release structure with `make test-android` or `make test-ios` diff --git a/libraries/godot-iap/addons/godot-iap/types.gd b/libraries/godot-iap/addons/godot-iap/types.gd index e61c34f63..82d263531 100644 --- a/libraries/godot-iap/addons/godot-iap/types.gd +++ b/libraries/godot-iap/addons/godot-iap/types.gd @@ -1,8 +1,8 @@ # ============================================================================ # AUTO-GENERATED TYPES — DO NOT EDIT DIRECTLY -# Generated from OpenIAP GraphQL schema (https://openiap.dev) -# Run `bun run generate` to regenerate this file. +# Refresh this file with the generated-types workflow documented for your checkout. # ============================================================================ +# Generated from OpenIAP GraphQL schema (https://openiap.dev) # Usage: const Types = preload("types.gd") # var store: Types.IapStore = Types.IapStore.APPLE # ============================================================================ @@ -11,13 +11,13 @@ # Enums # ============================================================================ -## Alternative billing mode for Android Controls which billing system is used @deprecated Use enableBillingProgramAndroid with BillingProgramAndroid instead. Use USER_CHOICE_BILLING for user choice billing, EXTERNAL_OFFER for alternative only. +## Alternative billing mode for Android Controls which billing system is used Use USER_CHOICE_BILLING for user choice billing, EXTERNAL_OFFER for alternative only. @deprecated Use enableBillingProgramAndroid with BillingProgramAndroid instead. enum AlternativeBillingModeAndroid { ## Standard Google Play billing (default) NONE = 0, - ## User choice billing - user can select between Google Play or alternative Requires Google Play Billing Library 7.0+ @deprecated Use BillingProgramAndroid.USER_CHOICE_BILLING instead + ## User choice billing - user can select between Google Play or alternative Requires Google Play Billing Library 7.0+ @deprecated Use BillingProgramAndroid.USER_CHOICE_BILLING instead. USER_CHOICE = 1, - ## Alternative billing only - no Google Play billing option Requires Google Play Billing Library 6.2+ @deprecated Use BillingProgramAndroid.EXTERNAL_OFFER instead + ## Alternative billing only - no Google Play billing option Requires Google Play Billing Library 6.2+ @deprecated Use BillingProgramAndroid.EXTERNAL_OFFER instead. ALTERNATIVE_ONLY = 2, } @@ -95,8 +95,11 @@ enum ErrorCode { REMOTE_ERROR = 4, NETWORK_ERROR = 5, SERVICE_ERROR = 6, + ## @deprecated Use PurchaseVerificationFailed instead RECEIPT_FAILED = 7, + ## @deprecated Use PurchaseVerificationFinished instead RECEIPT_FINISHED = 8, + ## @deprecated Use PurchaseVerificationFinishFailed instead RECEIPT_FINISHED_FAILED = 9, PURCHASE_VERIFICATION_FAILED = 10, PURCHASE_VERIFICATION_FINISHED = 11, @@ -438,7 +441,7 @@ class ActiveSubscription: var expiration_date_ios: Variant = null var auto_renewing_android: Variant = null var environment_ios: Variant = null - ## @deprecated iOS only - use daysUntilExpirationIOS instead. + ## Whether the subscription will expire soon (within 7 days). Consider using daysUntilExpirationIOS for more precise control. @deprecated iOS only - use daysUntilExpirationIOS instead. var will_expire_soon: Variant = null var days_until_expiration_ios: Variant = null var transaction_id: String = "" @@ -448,9 +451,9 @@ class ActiveSubscription: var base_plan_id_android: Variant = null ## Required for subscription upgrade/downgrade on Android var purchase_token_android: Variant = null - ## The current plan identifier. This is: + ## The current plan identifier. This is: - On Android: the basePlanId (e.g., "premium", "premium-year") - On iOS: the productId (e.g., "com.example.premium_monthly", "com.example.premium_yearly") This provides a unified way to identify which specific plan/tier the user is subscribed to. var current_plan_id: Variant = null - ## Renewal information from StoreKit 2 (iOS only). Contains details about subscription renewal status, + ## Renewal information from StoreKit 2 (iOS only). Contains details about subscription renewal status, pending upgrades/downgrades, and auto-renewal preferences. var renewal_info_ios: RenewalInfoIOS static func from_dict(data: Dictionary) -> ActiveSubscription: @@ -768,9 +771,9 @@ class BillingProgramAvailabilityResultAndroid: var is_available: bool = false ## The billing program that was checked var billing_program: BillingProgramAndroid - ## Billing Choice screen renderer. Populated only for available BILLING_CHOICE results. + ## Billing Choice screen renderer. Populated only for available BILLING_CHOICE results. Available in OpenIAP Spec 2.1.0 / openiap-google 2.3.0. var choice_screen_type: Variant = null - ## Whether external-link payment is available for Billing Choice. + ## Whether external-link payment is available for Billing Choice. Populated only for available BILLING_CHOICE results. Available in OpenIAP Spec 2.1.0 / openiap-google 2.3.0. var is_external_link_available: Variant = null static func from_dict(data: Dictionary) -> BillingProgramAvailabilityResultAndroid: @@ -813,7 +816,7 @@ class BillingProgramAvailabilityResultAndroid: class BillingProgramReportingDetailsAndroid: ## The billing program that the reporting details are associated with var billing_program: BillingProgramAndroid - ## External transaction token used to report transactions made outside of Google Play Billing. + ## External transaction token used to report transactions made outside of Google Play Billing. Do not cache it for a later redirect session. For External Offer, the same token may report multiple purchases made during the session that generated it. var external_transaction_token: String = "" static func from_dict(data: Dictionary) -> BillingProgramReportingDetailsAndroid: @@ -843,7 +846,7 @@ class BillingResultAndroid: var response_code: int = 0 ## Debug message from the billing library var debug_message: Variant = null - ## Sub-response code for more granular error information (8.0+). + ## Sub-response code for more granular error information (8.0+). Provides additional context when responseCode indicates an error. var sub_response_code: Variant = null static func from_dict(data: Dictionary) -> BillingResultAndroid: @@ -874,11 +877,11 @@ class BillingResultAndroid: ## Details provided when user selects developer billing option (Android) Received via DeveloperProvidedBillingListener callback Available in Google Play Billing Library 8.3.0+ class DeveloperProvidedBillingDetailsAndroid: - ## External transaction token used to report transactions made through developer billing. + ## External transaction token used to report transactions made through developer billing. Nullable for flows such as external payments where no token is returned. var external_transaction_token: Variant = null - ## URI to launch for an external-link Billing Choice flow, when provided by + ## URI to launch for an external-link Billing Choice flow, when provided by Google Play. var link_uri: Variant = null - ## Original external transaction ID when replacing a subscription that was + ## Original external transaction ID when replacing a subscription that was purchased through developer billing. var original_external_transaction_id: Variant = null ## Products selected for the developer billing flow. var products: Array[DeveloperProvidedBillingProductAndroid] = [] @@ -979,9 +982,9 @@ class DiscountAmountAndroid: ## Discount display information for one-time purchase offers (Android) Available in Google Play Billing Library 8.0+ class DiscountDisplayInfoAndroid: - ## Percentage discount (e.g., 33 for 33% off) + ## Percentage discount (e.g., 33 for 33% off) Only returned for percentage-based discounts var percentage_discount: Variant = null - ## Absolute discount amount details + ## Absolute discount amount details Only returned for fixed amount discounts var discount_amount: DiscountAmountAndroid static func from_dict(data: Dictionary) -> DiscountDisplayInfoAndroid: @@ -1005,7 +1008,7 @@ class DiscountDisplayInfoAndroid: dict["discountAmount"] = discount_amount return dict -## Discount information returned from the store. @deprecated Use the standardized SubscriptionOffer type instead for cross-platform compatibility. @see https://openiap.dev/docs/types/subscription-offer +## Discount information returned from the store. @see https://openiap.dev/docs/types/subscription-offer @deprecated Use the standardized SubscriptionOffer type instead for cross-platform compatibility. class DiscountIOS: var identifier: String = "" var type: String = "" @@ -1058,7 +1061,7 @@ class DiscountIOS: ## Standardized one-time product discount offer. Provides a platform-neutral OpenIAP shape for Google Play one-time product purchase options and offers. Currently populated only on Android (Google Play Billing 8.0+). iOS does not populate this type. @see https://openiap.dev/docs/types/discount-offer class DiscountOffer: - ## Unique identifier for the offer. + ## Unique identifier for the offer. - iOS: Not applicable (one-time discounts not supported) - Android: offerId from ProductAndroidOneTimePurchaseOfferDetail var id: Variant = null ## Formatted display price string (e.g., "$4.99") var display_price: String = "" @@ -1066,29 +1069,29 @@ class DiscountOffer: var price: float = 0.0 ## Currency code (ISO 4217, e.g., "USD") var currency: String = "" - ## Type of discount offer + ## Offer category. DiscountOffer currently represents Android one-time product offers and is populated as OneTime. Introductory and Promotional are used by SubscriptionOffer. var type: DiscountOfferType - ## [Android] Offer token required for purchase. + ## [Android] Offer token required for purchase. Must be passed to requestPurchase() when purchasing with this offer. var offer_token_android: Variant = null ## [Android] List of tags associated with this offer. var offer_tags_android: Array[String] = [] - ## [Android] Original full price in micro-units before discount. + ## [Android] Original full price in micro-units before discount. Divide by 1,000,000 to get the actual price. Use for displaying strikethrough original price. var full_price_micros_android: Variant = null - ## [Android] Percentage discount (e.g., 33 for 33% off). + ## [Android] Percentage discount (e.g., 33 for 33% off). Only present for percentage-based discounts. var percentage_discount_android: Variant = null - ## [Android] Fixed discount amount in micro-units. + ## [Android] Fixed discount amount in micro-units. Only present for fixed amount discounts. var discount_amount_micros_android: Variant = null ## [Android] Formatted discount amount including its currency sign (e.g., "$5.00"). var formatted_discount_amount_android: Variant = null - ## [Android] Valid time window for the offer. + ## [Android] Valid time window for the offer. Contains startTimeMillis and endTimeMillis. var valid_time_window_android: ValidTimeWindowAndroid - ## [Android] Limited quantity information. + ## [Android] Limited quantity information. Contains maximumQuantity and remainingQuantity. var limited_quantity_info_android: LimitedQuantityInfoAndroid - ## [Android] Pre-order details if this is a pre-order offer. + ## [Android] Pre-order details if this is a pre-order offer. Available in Google Play Billing Library 8.1.0+ var preorder_details_android: PreorderDetailsAndroid ## [Android] Rental details if this is a rental offer. var rental_details_android: RentalDetailsAndroid - ## [Android] Purchase option ID for this offer. + ## [Android] Purchase option ID for this offer. Used to identify which purchase option the user selected. Available in Google Play Billing Library 8.0+ var purchase_option_id_android: Variant = null static func from_dict(data: Dictionary) -> DiscountOffer: @@ -1190,7 +1193,7 @@ class DiscountOffer: dict["purchaseOptionIdAndroid"] = purchase_option_id_android return dict -## iOS DiscountOffer (output type). @deprecated Use the standardized SubscriptionOffer type instead for cross-platform compatibility. @see https://openiap.dev/docs/types/subscription-offer +## iOS DiscountOffer (output type). @see https://openiap.dev/docs/types/subscription-offer @deprecated Use the standardized SubscriptionOffer type instead for cross-platform compatibility. class DiscountOfferIOS: ## Discount identifier var identifier: String = "" @@ -1248,7 +1251,7 @@ class EntitlementIOS: dict["jsonRepresentation"] = json_representation return dict -## External offer availability result (Android) @deprecated Use BillingProgramAvailabilityResultAndroid with isBillingProgramAvailableAsync instead Available in Google Play Billing Library 6.2.0+, deprecated in 8.2.0 +## External offer availability result (Android) Available in Google Play Billing Library 6.2.0+, deprecated in 8.2.0 @deprecated Use BillingProgramAvailabilityResultAndroid with isBillingProgramAvailableAsync instead class ExternalOfferAvailabilityResultAndroid: ## Whether external offers are available for the user var is_available: bool = false @@ -1264,7 +1267,7 @@ class ExternalOfferAvailabilityResultAndroid: dict["isAvailable"] = is_available return dict -## External offer reporting details (Android) @deprecated Use BillingProgramReportingDetailsAndroid with createBillingProgramReportingDetailsAsync instead Available in Google Play Billing Library 6.2.0+, deprecated in 8.2.0 +## External offer reporting details (Android) Available in Google Play Billing Library 6.2.0+, deprecated in 8.2.0 @deprecated Use BillingProgramReportingDetailsAndroid with createBillingProgramReportingDetailsAsync instead class ExternalOfferReportingDetailsAndroid: ## External transaction token for reporting external offer transactions var external_transaction_token: String = "" @@ -1304,7 +1307,7 @@ class ExternalPurchaseCustomLinkNoticeResultIOS: ## Result of requesting an ExternalPurchaseCustomLink token (iOS 18.1+). class ExternalPurchaseCustomLinkTokenResultIOS: - ## The external purchase token string. + ## The external purchase token string. Report this token to Apple's External Purchase Server API. var token: Variant = null ## Optional error message if token retrieval failed var error: Variant = null @@ -1353,7 +1356,7 @@ class ExternalPurchaseNoticeResultIOS: var result: ExternalPurchaseNoticeAction ## Optional error message if the presentation failed var error: Variant = null - ## External purchase token returned when user continues (iOS 17.4+). + ## External purchase token returned when user continues (iOS 17.4+). This token should be reported to Apple's External Purchase Server API. Only present when result is Continue. var external_purchase_token: Variant = null static func from_dict(data: Dictionary) -> ExternalPurchaseNoticeResultIOS: @@ -1447,9 +1450,9 @@ class InAppMessageResultAndroid: ## Installment plan details for subscription offers (Android) Contains information about the installment plan commitment. Available in Google Play Billing Library 7.0+ class InstallmentPlanDetailsAndroid: - ## Committed payments count after a user signs up for this subscription plan. + ## Committed payments count after a user signs up for this subscription plan. For example, for a monthly subscription with commitmentPaymentsCount of 12, users will be charged monthly for 12 months after signup. var commitment_payments_count: int = 0 - ## Subsequent committed payments count after the subscription plan renews. + ## Subsequent committed payments count after the subscription plan renews. For example, for a monthly subscription with subsequentCommitmentPaymentsCount of 12, users will be committed to another 12 monthly payments when the plan renews. Returns 0 if the installment plan has no subsequent commitment (reverts to normal plan). var subsequent_commitment_payments_count: int = 0 static func from_dict(data: Dictionary) -> InstallmentPlanDetailsAndroid: @@ -1489,9 +1492,9 @@ class LimitedQuantityInfoAndroid: ## Pending purchase update for subscription upgrades/downgrades (Android) When a user initiates a subscription change (upgrade/downgrade), the new purchase may be pending until the current billing period ends. This type contains the details of the pending change. Available in Google Play Billing Library 5.0+ class PendingPurchaseUpdateAndroid: - ## Product IDs for the pending purchase update. + ## Product IDs for the pending purchase update. These are the new products the user is switching to. var products: Array[String] = [] - ## Purchase token for the pending transaction. + ## Purchase token for the pending transaction. Use this token to track or manage the pending purchase update. var purchase_token: String = "" static func from_dict(data: Dictionary) -> PendingPurchaseUpdateAndroid: @@ -1515,9 +1518,9 @@ class PendingPurchaseUpdateAndroid: ## Pre-order details for one-time purchase products (Android) Available in Google Play Billing Library 8.1.0+ class PreorderDetailsAndroid: - ## Pre-order presale end time in milliseconds since epoch. + ## Pre-order presale end time in milliseconds since epoch. This is when the presale period ends and the product will be released. var preorder_presale_end_time_millis: String = "" - ## Pre-order release time in milliseconds since epoch. + ## Pre-order release time in milliseconds since epoch. This is when the product will be available to users who pre-ordered. var preorder_release_time_millis: String = "" static func from_dict(data: Dictionary) -> PreorderDetailsAndroid: @@ -1602,21 +1605,21 @@ class ProductAndroid: var id: String = "" var title: String = "" var description: String = "" - var type: ProductType + var type: ProductType = ProductType.IN_APP var display_name: Variant = null var display_price: String = "" var currency: String = "" var price: Variant = null var debug_description: Variant = null - var platform: IapPlatform + var platform: IapPlatform = IapPlatform.ANDROID var name_android: String = "" - ## Product-level status code indicating fetch result (Android 8.0+) + ## Product-level status code indicating fetch result (Android 8.0+) OK = product fetched successfully NOT_FOUND = SKU doesn't exist NO_OFFERS_AVAILABLE = user not eligible for any offers Available in Google Play Billing Library 8.0.0+ var product_status_android: Variant = null - ## Standardized Android one-time product purchase options and offers. + ## Standardized Android one-time product purchase options and offers. Native metadata uses Android-suffixed fields. @see https://openiap.dev/docs/types/discount-offer var discount_offers: Array[DiscountOffer] = [] - ## Standardized subscription offers. + ## Standardized subscription offers. Cross-platform type with Android-specific fields using suffix. @see https://openiap.dev/docs/types/subscription-offer var subscription_offers: Array[SubscriptionOffer] = [] - ## One-time purchase offer details including discounts (Android) + ## One-time purchase offer details including discounts (Android) Returns all eligible offers. Available in Google Play Billing Library 8.0+ @deprecated Use the standardized discountOffers field instead. var one_time_purchase_offer_details_android: Array[ProductAndroidOneTimePurchaseOfferDetail] = [] ## @deprecated Use subscriptionOffers instead for cross-platform compatibility. var subscription_offer_details_android: Array[ProductSubscriptionAndroidOfferDetails] = [] @@ -1766,7 +1769,7 @@ class ProductAndroid: dict["subscriptionOfferDetailsAndroid"] = null return dict -## One-time purchase offer details (Android). Available in Google Play Billing Library 8.0+ @deprecated Use the standardized DiscountOffer type for Android one-time offers. @see https://openiap.dev/docs/types/discount-offer +## One-time purchase offer details (Android). Available in Google Play Billing Library 8.0+ @see https://openiap.dev/docs/types/discount-offer @deprecated Use the standardized DiscountOffer type for Android one-time offers. class ProductAndroidOneTimePurchaseOfferDetail: ## Offer ID var offer_id: Variant = null @@ -1777,19 +1780,19 @@ class ProductAndroidOneTimePurchaseOfferDetail: var price_currency_code: String = "" var formatted_price: String = "" var price_amount_micros: String = "" - ## Full (non-discounted) price in micro-units + ## Full (non-discounted) price in micro-units Only available for discounted offers var full_price_micros: Variant = null - ## Discount display information + ## Discount display information Only available for discounted offers var discount_display_info: DiscountDisplayInfoAndroid ## Valid time window for the offer var valid_time_window: ValidTimeWindowAndroid ## Limited quantity information var limited_quantity_info: LimitedQuantityInfoAndroid - ## Pre-order details for products available for pre-order + ## Pre-order details for products available for pre-order Available in Google Play Billing Library 8.1.0+ var preorder_details_android: PreorderDetailsAndroid ## Rental details for rental offers var rental_details_android: RentalDetailsAndroid - ## Purchase option ID for this offer (Android) + ## Purchase option ID for this offer (Android) Used to identify which purchase option the user selected. Available in Google Play Billing Library 8.0+ var purchase_option_id: Variant = null static func from_dict(data: Dictionary) -> ProductAndroidOneTimePurchaseOfferDetail: @@ -1881,20 +1884,20 @@ class ProductIOS: var id: String = "" var title: String = "" var description: String = "" - var type: ProductType + var type: ProductType = ProductType.IN_APP var display_name: Variant = null var display_price: String = "" var currency: String = "" var price: Variant = null var debug_description: Variant = null - var platform: IapPlatform + var platform: IapPlatform = IapPlatform.IOS var display_name_ios: String = "" var is_family_shareable_ios: bool = false var json_representation_ios: String = "" var type_ios: ProductTypeIOS - ## Standardized subscription offers. + ## Standardized subscription offers. Cross-platform type with iOS-specific fields using suffix. Note: iOS does not support one-time product discounts. @see https://openiap.dev/docs/types/subscription-offer var subscription_offers: Array[SubscriptionOffer] = [] - ## iOS 26.4+ subscription pricing terms, including billing plan metadata for + ## iOS 26.4+ subscription pricing terms, including billing plan metadata for monthly subscriptions with a 12-month commitment. var pricing_terms_ios: Array[SubscriptionPricingTermsIOS] = [] ## @deprecated Use subscriptionOffers instead for cross-platform compatibility. var subscription_info_ios: SubscriptionInfoIOS @@ -2024,21 +2027,21 @@ class ProductSubscriptionAndroid: var id: String = "" var title: String = "" var description: String = "" - var type: ProductType + var type: ProductType = ProductType.SUBS var display_name: Variant = null var display_price: String = "" var currency: String = "" var price: Variant = null var debug_description: Variant = null - var platform: IapPlatform + var platform: IapPlatform = IapPlatform.ANDROID var name_android: String = "" - ## Product-level status code indicating fetch result (Android 8.0+) + ## Product-level status code indicating fetch result (Android 8.0+) OK = product fetched successfully NOT_FOUND = SKU doesn't exist NO_OFFERS_AVAILABLE = user not eligible for any offers Available in Google Play Billing Library 8.0.0+ var product_status_android: Variant = null - ## Nullable compatibility field. Google Play does not return one-time purchase + ## Nullable compatibility field. Google Play does not return one-time purchase offer details for subscription products; use subscriptionOffers below. var discount_offers: Array[DiscountOffer] = [] - ## Standardized subscription offers. + ## Standardized subscription offers. Cross-platform type with Android-specific fields using suffix. @see https://openiap.dev/docs/types/subscription-offer var subscription_offers: Array[SubscriptionOffer] = [] - ## Legacy nullable compatibility field. Google Play does not populate one-time + ## Legacy nullable compatibility field. Google Play does not populate one-time purchase offer details for subscription products. @deprecated One-time offers belong to ProductAndroid.discountOffers; subscriptions use subscriptionOffers. var one_time_purchase_offer_details_android: Array[ProductAndroidOneTimePurchaseOfferDetail] = [] ## @deprecated Use subscriptionOffers instead for cross-platform compatibility. var subscription_offer_details_android: Array[ProductSubscriptionAndroidOfferDetails] = [] @@ -2188,14 +2191,14 @@ class ProductSubscriptionAndroid: dict["subscriptionOfferDetailsAndroid"] = null return dict -## Subscription offer details (Android). @deprecated Use the standardized SubscriptionOffer type instead for cross-platform compatibility. @see https://openiap.dev/docs/types/subscription-offer +## Subscription offer details (Android). @see https://openiap.dev/docs/types/subscription-offer @deprecated Use the standardized SubscriptionOffer type instead for cross-platform compatibility. class ProductSubscriptionAndroidOfferDetails: var base_plan_id: String = "" var offer_id: Variant = null var offer_token: String = "" var offer_tags: Array[String] = [] var pricing_phases: PricingPhasesAndroid - ## Installment plan details for this subscription offer. + ## Installment plan details for this subscription offer. Only set for installment subscription plans; null for non-installment plans. Available in Google Play Billing Library 7.0+ var installment_plan_details: InstallmentPlanDetailsAndroid static func from_dict(data: Dictionary) -> ProductSubscriptionAndroidOfferDetails: @@ -2246,20 +2249,20 @@ class ProductSubscriptionIOS: var id: String = "" var title: String = "" var description: String = "" - var type: ProductType + var type: ProductType = ProductType.SUBS var display_name: Variant = null var display_price: String = "" var currency: String = "" var price: Variant = null var debug_description: Variant = null - var platform: IapPlatform + var platform: IapPlatform = IapPlatform.IOS var display_name_ios: String = "" var is_family_shareable_ios: bool = false var json_representation_ios: String = "" var type_ios: ProductTypeIOS - ## Standardized subscription offers. + ## Standardized subscription offers. Cross-platform type with iOS-specific fields using suffix. @see https://openiap.dev/docs/types/subscription-offer var subscription_offers: Array[SubscriptionOffer] = [] - ## iOS 26.4+ subscription pricing terms, including billing plan metadata for + ## iOS 26.4+ subscription pricing terms, including billing plan metadata for monthly subscriptions with a 12-month commitment. var pricing_terms_ios: Array[SubscriptionPricingTermsIOS] = [] ## App Store subscription group identifier for intro-offer eligibility checks. var subscription_group_id_ios: Variant = null @@ -2477,6 +2480,7 @@ class PurchaseAndroid: var purchase_token: Variant = null ## Store where purchase was made var store: IapStore + ## @deprecated Use store instead var platform: IapPlatform var quantity: int = 0 var purchase_state: PurchaseState @@ -2490,9 +2494,9 @@ class PurchaseAndroid: var developer_payload_android: Variant = null var obfuscated_account_id_android: Variant = null var obfuscated_profile_id_android: Variant = null - ## Whether the subscription is suspended (Android) + ## Whether the subscription is suspended (Android) A suspended subscription means the user's payment method failed and they need to fix it. Users should be directed to the subscription center to resolve the issue. Do NOT grant entitlements for suspended subscriptions. Available in Google Play Billing Library 8.1.0+ var is_suspended_android: Variant = null - ## Pending purchase update for uncommitted subscription upgrade/downgrade (Android) + ## Pending purchase update for uncommitted subscription upgrade/downgrade (Android) Contains the new products and purchase token for the pending transaction. Returns null if no pending update exists. Available in Google Play Billing Library 5.0+ var pending_purchase_update_android: PendingPurchaseUpdateAndroid static func from_dict(data: Dictionary) -> PurchaseAndroid: @@ -2693,6 +2697,7 @@ class PurchaseIOS: var purchase_token: Variant = null ## Store where purchase was made var store: IapStore + ## @deprecated Use store instead var platform: IapPlatform var quantity: int = 0 var purchase_state: PurchaseState @@ -2725,7 +2730,7 @@ class PurchaseIOS: var billing_plan_type_ios: Variant = null ## iOS 26.4+ progress information for monthly subscriptions with a 12-month commitment. var commitment_info_ios: TransactionCommitmentInfoIOS - ## Advanced Commerce API metadata (iOS 18.4+). + ## Advanced Commerce API metadata (iOS 18.4+). Present only for transactions that use the Advanced Commerce API. Contains item details, tax information, and refund data for generic SKU purchases. var advanced_commerce_info_ios: AdvancedCommerceInfoIOS static func from_dict(data: Dictionary) -> PurchaseIOS: @@ -3010,25 +3015,25 @@ class RenewalInfoIOS: var json_representation: Variant = null var will_auto_renew: bool = false var auto_renew_preference: Variant = null - ## When subscription expires due to cancellation/billing issue + ## When subscription expires due to cancellation/billing issue Possible values: "VOLUNTARY", "BILLING_ERROR", "DID_NOT_AGREE_TO_PRICE_INCREASE", "PRODUCT_NOT_AVAILABLE", "UNKNOWN" var expiration_reason: Variant = null - ## Grace period expiration date (milliseconds since epoch) + ## Grace period expiration date (milliseconds since epoch) When set, subscription is in grace period (billing issue but still has access) var grace_period_expiration_date: Variant = null - ## True if subscription failed to renew due to billing issue and is retrying + ## True if subscription failed to renew due to billing issue and is retrying StoreKit exposes this directly as RenewalInfo.isInBillingRetry. var is_in_billing_retry: Variant = null - ## Product ID that will be used on next renewal (when user upgrades/downgrades) + ## Product ID that will be used on next renewal (when user upgrades/downgrades) If set and different from current productId, subscription will change on expiration var pending_upgrade_product_id: Variant = null - ## User's response to subscription price increase + ## User's response to subscription price increase Possible values: "AGREED", "PENDING", null (no price increase) var price_increase_status: Variant = null - ## Expected renewal date (milliseconds since epoch) + ## Expected renewal date (milliseconds since epoch) For active subscriptions, when the next renewal/charge will occur var renewal_date: Variant = null ## Offer ID applied to next renewal (promotional offer, subscription offer code, etc.) var renewal_offer_id: Variant = null - ## Type of offer applied to next renewal + ## Type of offer applied to next renewal Possible values: "PROMOTIONAL", "SUBSCRIPTION_OFFER_CODE", "WIN_BACK", etc. var renewal_offer_type: Variant = null ## iOS 26.4+ billing plan that will renew after the current period. var renewal_billing_plan_type: Variant = null - ## iOS 26.4+ renewal commitment metadata for monthly subscriptions with a + ## iOS 26.4+ renewal commitment metadata for monthly subscriptions with a 12-month commitment. var commitment_info: RenewalCommitmentInfoIOS static func from_dict(data: Dictionary) -> RenewalInfoIOS: @@ -3106,7 +3111,7 @@ class RenewalInfoIOS: class RentalDetailsAndroid: ## Rental period in ISO 8601 format (e.g., P7D for 7 days) var rental_period: String = "" - ## Rental expiration period in ISO 8601 format + ## Rental expiration period in ISO 8601 format Time after rental period ends when user can still extend var rental_expiration_period: Variant = null static func from_dict(data: Dictionary) -> RentalDetailsAndroid: @@ -3126,13 +3131,13 @@ class RentalDetailsAndroid: class RequestVerifyPurchaseWithIapkitResult: var store: IapStore - ## True when the purchase is valid and actionable. + ## True when the purchase is valid and actionable. Only entitled, pending-acknowledgment, or ready-to-consume return true. Callers must still match productId and use the platform plus app-owned product type to choose the fulfillment path. var is_valid: bool = false ## The current state of the purchase. var state: IapkitPurchaseState - ## Available in OpenIAP Spec 2.4.0 / openiap-apple 2.4.1 / openiap-google 2.4.1. + ## Available in OpenIAP Spec 2.4.0 / openiap-apple 2.4.1 / openiap-google 2.4.1. Store-verified product identifier when the provider returns one. var product_id: Variant = null - ## Available in OpenIAP Spec 2.4.0 / openiap-apple 2.4.1 / openiap-google 2.4.1. + ## Available in OpenIAP Spec 2.4.0 / openiap-apple 2.4.1 / openiap-google 2.4.1. Public product payload when includeClientPayload was requested, the Apple or Google receipt is valid, and a payload exists for that product. var client_payload: IapkitProductClientPayload static func from_dict(data: Dictionary) -> RequestVerifyPurchaseWithIapkitResult: @@ -3283,7 +3288,7 @@ class SubscriptionInfoIOS: ## Standardized subscription discount/promotional offer. Provides a unified interface for subscription offers across iOS and Android. Both platforms support subscription offers with different implementations: - iOS: Introductory offers, promotional offers with server-side signatures - Android: Offer tokens with pricing phases @see https://openiap.dev/docs/types/subscription-offer class SubscriptionOffer: - ## Unique identifier for the offer. + ## Unique identifier for the offer. - iOS: Discount identifier from App Store Connect - Android: offerId from ProductSubscriptionAndroidOfferDetails var id: String = "" ## Formatted display price string (e.g., "$9.99/month") var display_price: String = "" @@ -3299,27 +3304,27 @@ class SubscriptionOffer: var period_count: Variant = null ## Payment mode during the offer period var payment_mode: Variant = null - ## [iOS] Key identifier for signature validation. + ## [iOS] Key identifier for signature validation. Used with server-side signature generation for promotional offers. var key_identifier_ios: Variant = null - ## [iOS] Cryptographic nonce (UUID) for signature validation. + ## [iOS] Cryptographic nonce (UUID) for signature validation. Must be generated server-side for each purchase attempt. var nonce_ios: Variant = null - ## [iOS] Server-generated signature for promotional offer validation. + ## [iOS] Server-generated signature for promotional offer validation. Required when applying promotional offers on iOS. var signature_ios: Variant = null - ## [iOS] Timestamp when the signature was generated. + ## [iOS] Timestamp when the signature was generated. Used for signature validation. var timestamp_ios: Variant = null ## [iOS] Number of billing periods for this discount. var number_of_periods_ios: Variant = null ## [iOS] Localized price string. var localized_price_ios: Variant = null - ## [Android] Base plan identifier. + ## [Android] Base plan identifier. Identifies which base plan this offer belongs to. var base_plan_id_android: Variant = null - ## [Android] Offer token required for purchase. + ## [Android] Offer token required for purchase. Must be passed to requestPurchase() when purchasing with this offer. var offer_token_android: Variant = null ## [Android] List of tags associated with this offer. var offer_tags_android: Array[String] = [] - ## [Android] Pricing phases for this subscription offer. + ## [Android] Pricing phases for this subscription offer. Contains detailed pricing information for each phase (trial, intro, regular). var pricing_phases_android: PricingPhasesAndroid - ## [Android] Installment plan details for this subscription offer. + ## [Android] Installment plan details for this subscription offer. Only set for installment subscription plans; null for non-installment plans. Available in Google Play Billing Library 7.0+ var installment_plan_details_android: InstallmentPlanDetailsAndroid static func from_dict(data: Dictionary) -> SubscriptionOffer: @@ -3435,7 +3440,7 @@ class SubscriptionOffer: dict["installmentPlanDetailsAndroid"] = installment_plan_details_android return dict -## iOS subscription offer details. @deprecated Use the standardized SubscriptionOffer type instead for cross-platform compatibility. @see https://openiap.dev/docs/types/subscription-offer +## iOS subscription offer details. @see https://openiap.dev/docs/types/subscription-offer @deprecated Use the standardized SubscriptionOffer type instead for cross-platform compatibility. class SubscriptionOfferIOS: var display_price: String = "" var id: String = "" @@ -3670,11 +3675,11 @@ class TransactionCommitmentInfoIOS: class UserChoiceBillingDetails: ## Token that must be reported to Google Play within 24 hours var external_transaction_token: String = "" - ## External transaction ID of the originating subscription when the user is + ## External transaction ID of the originating subscription when the user is upgrading or downgrading a developer-billed subscription. Available in OpenIAP Spec 2.3.0 / openiap-google 2.3.1 (requires Play Billing 9.1+). var original_external_transaction_id: Variant = null ## List of product IDs selected by the user var products: Array[String] = [] - ## Structured product details selected in the user-choice flow, including the + ## Structured product details selected in the user-choice flow, including the product type and offer token. Legacy payloads may omit this field; use products as the product-ID fallback. Available in OpenIAP Spec 2.3.0 / openiap-google 2.3.1 (requires Play Billing 9.1+). var product_details_android: Array[DeveloperProvidedBillingProductAndroid] = [] static func from_dict(data: Dictionary) -> UserChoiceBillingDetails: @@ -3965,7 +3970,7 @@ class VoidResult: return dict class WebhookEvent: - ## Stable identifier suitable for idempotency. Derived from the source notification + ## Stable identifier suitable for idempotency. Derived from the source notification UUID where the store provides one (ASN v2 `notificationUUID`, RTDN message id); otherwise hashed from the canonicalized payload. var id: String = "" var type: WebhookEventType var source: WebhookEventSource @@ -3977,11 +3982,11 @@ class WebhookEvent: ## Time kit ingested and normalized this event. Epoch milliseconds. var received_at: float = 0.0 var environment: WebhookEventEnvironment - ## Cross-platform purchase identity used to correlate this event with an existing + ## Cross-platform purchase identity used to correlate this event with an existing purchase record. iOS: `originalTransactionId`. Android: `purchaseToken`. Null for `TestNotification` events (Apple ASN v2 / Google RTDN test payloads carry no transaction); always present for every other event type. var purchase_token: Variant = null ## Product the event pertains to. May be null for account-level events. var product_id: Variant = null - ## Normalized subscription state at the time of event, when the event refers to + ## Normalized subscription state at the time of event, when the event refers to a subscription. Null for one-time purchase events. var subscription_state: Variant = null ## When the current subscription period ends. Epoch milliseconds. var expires_at: Variant = null @@ -3991,9 +3996,9 @@ class WebhookEvent: var cancellation_reason: Variant = null ## Localized currency code (ISO 4217) at event time, when available. var currency: Variant = null - ## Price in micros (1/1,000,000 of the currency unit) at event time, when available. + ## Price in micros (1/1,000,000 of the currency unit) at event time, when available. Matches Google Play's `priceAmountMicros` convention; iOS values are converted. var price_amount_micros: Variant = null - ## Original signed payload from the store. ASN v2 events expose the JWS string; + ## Original signed payload from the store. ASN v2 events expose the JWS string; RTDN events expose the base64-decoded Pub/Sub message JSON. Provided so that consumers can independently verify or extract platform-specific fields. kit always validates this payload before emitting the event. var raw_signed_payload: Variant = null static func from_dict(data: Dictionary) -> WebhookEvent: @@ -4188,11 +4193,11 @@ class DeepLinkOptions: class DeveloperBillingOptionParamsAndroid: ## The billing program. Use EXTERNAL_PAYMENTS or BILLING_CHOICE. var billing_program: BillingProgramAndroid - ## The URI where the external payment will be processed. + ## The URI where the external payment will be processed. Required only when the selected billing program links outside the app. var link_uri: Variant = null - ## The launch mode for the external payment link. + ## The launch mode for the external payment link. Required only when the selected billing program links outside the app. var launch_mode: Variant = null - ## A pre-generated external transaction token for a Billing Choice external-link + ## A pre-generated external transaction token for a Billing Choice external-link flow. Omit it when Google Play should provide the token in the callback. var external_transaction_token: Variant = null static func from_dict(data: Dictionary) -> DeveloperBillingOptionParamsAndroid: @@ -4348,11 +4353,11 @@ class InAppMessageParamsAndroid: ## Connection initialization configuration class InitConnectionConfig: - ## Alternative billing mode for Android + ## Alternative billing mode for Android If not specified, defaults to NONE (standard Google Play billing) Use USER_CHOICE_BILLING for user choice billing, EXTERNAL_OFFER for alternative only. @deprecated Use enableBillingProgramAndroid instead. var alternative_billing_mode_android: Variant = null - ## Enable a specific billing program for Android (7.0+) + ## Enable a specific billing program for Android (7.0+) When set, enables the specified billing program for external transactions. - USER_CHOICE_BILLING: User can select between Google Play or alternative (7.0+) - EXTERNAL_CONTENT_LINK: Link to external content (introduced in 8.2.0; use 8.2.1+) - EXTERNAL_OFFER: External offers for digital content (introduced in 8.2.0; use 8.2.1+) - EXTERNAL_PAYMENTS: Developer provided billing, Japan only (8.3.0+) - BILLING_CHOICE: Google-rendered or developer-rendered billing choice (OpenIAP Spec 2.1.0 / openiap-google 2.3.0; requires Play Billing 9.1.0+) var enable_billing_program_android: Variant = null - ## Billing Choice renderer configured in Play Console. Available in OpenIAP + ## Billing Choice renderer configured in Play Console. Available in OpenIAP Spec 2.1.0 / openiap-google 2.3.0 (requires Play Billing 9.1.0+). GOOGLE_RENDERED registers the developer-provided billing listener so OpenIAP can emit the selection event. DEVELOPER_RENDERED omits that listener so the app can render its own choice screen and use the reporting/dialog/link APIs. Must match choiceScreenType returned by isBillingProgramAvailableAndroid. Defaults to GOOGLE_RENDERED. var billing_choice_screen_type_android: BillingChoiceScreenTypeAndroid = BillingChoiceScreenTypeAndroid.GOOGLE_RENDERED static func from_dict(data: Dictionary) -> InitConnectionConfig: @@ -4406,7 +4411,7 @@ class LaunchExternalLinkParamsAndroid: var link_type: ExternalLinkTypeAndroid ## The URI where the content will be accessed from var link_uri: String = "" - ## External transaction token for a developer-rendered Billing Choice external-link + ## External transaction token for a developer-rendered Billing Choice external-link flow. Available in OpenIAP Spec 2.1.0 / openiap-google 2.3.0 (requires Play Billing 9.1.0+). Generate it with createBillingProgramReportingDetailsAndroid. var external_transaction_token: Variant = null static func from_dict(data: Dictionary) -> LaunchExternalLinkParamsAndroid: @@ -4494,7 +4499,7 @@ class ProductRequest: class PromotionalOfferJWSInputIOS: ## The promotional offer identifier from App Store Connect var offer_id: String = "" - ## Compact JWS string signed by your server. + ## Compact JWS string signed by your server. The JWS should contain the promotional offer signature data. Format: header.payload.signature (base64url encoded) var jws: String = "" static func from_dict(data: Dictionary) -> PromotionalOfferJWSInputIOS: @@ -4607,7 +4612,7 @@ class PurchaseOptions: var also_publish_to_event_listener_ios: Variant = null ## Limit to currently active items on iOS var only_include_active_items_ios: Variant = null - ## Include suspended subscriptions in the result (Android 8.1+). + ## Include suspended subscriptions in the result (Android 8.1+). Suspended subscriptions have isSuspendedAndroid=true and should NOT be granted entitlements. Users should be directed to the subscription center to resolve payment issues. Default: false (only active subscriptions are returned) var include_suspended_android: Variant = null static func from_dict(data: Dictionary) -> PurchaseOptions: @@ -4631,7 +4636,7 @@ class PurchaseOptions: return dict class PurchaseUpdatedListenerOptions: - ## iOS only. Defaults to true. When false, listener callbacks also receive + ## iOS only. Defaults to true. When false, listener callbacks also receive StoreKit replay events for a transaction ID that was already emitted during the current connection session. Android ignores this option. var dedupe_transaction_ios: Variant = null static func from_dict(data: Dictionary) -> PurchaseUpdatedListenerOptions: @@ -4653,11 +4658,11 @@ class RequestPurchaseAndroidProps: var obfuscated_account_id: Variant = null ## Obfuscated profile ID var obfuscated_profile_id: Variant = null - ## Personalized offer flag. + ## Personalized offer flag. When true, indicates the price was customized for this user. var is_offer_personalized: Variant = null - ## Offer token for one-time purchase discounts (8.0+). + ## Offer token for one-time purchase discounts (8.0+). Pass the offerToken from oneTimePurchaseOfferDetailsAndroid or discountOffers to apply a discount offer to the purchase. var offer_token: Variant = null - ## Developer billing option parameters for external payments and Billing Choice. + ## Developer billing option parameters for external payments and Billing Choice. Billing Choice is available in OpenIAP Spec 2.1.0 / openiap-google 2.3.0 (requires Play Billing 9.1.0+). var developer_billing_option: DeveloperBillingOptionParamsAndroid static func from_dict(data: Dictionary) -> RequestPurchaseAndroidProps: @@ -4712,9 +4717,9 @@ class RequestPurchaseIosProps: var app_account_token: Variant = null ## Purchase quantity var quantity: Variant = null - ## Promotional offer to apply (subscriptions only, ignored for one-time purchases). + ## Promotional offer to apply (subscriptions only, ignored for one-time purchases). iOS only supports promotional offers for auto-renewable subscriptions. var with_offer: DiscountOfferInputIOS - ## Advanced commerce data token (iOS 15+). + ## Advanced commerce data token (iOS 15+). Used with StoreKit 2's Product.PurchaseOption.custom API for passing campaign tokens, affiliate IDs, or other attribution data. The data is formatted as JSON: {"signatureInfo": {"token": ""}} var advanced_commerce_data: Variant = null static func from_dict(data: Dictionary) -> RequestPurchaseIosProps: @@ -4762,7 +4767,7 @@ class RequestPurchaseProps: var request_subscription: RequestSubscriptionPropsByPlatforms ## Explicit purchase type hint (defaults to in-app) var type: ProductQueryType = ProductQueryType.IN_APP - ## @deprecated Use enableBillingProgramAndroid in InitConnectionConfig instead. + ## This flag only logs debug info and has no effect on the purchase flow. @deprecated Use enableBillingProgramAndroid in InitConnectionConfig instead. var use_alternative_billing: Variant = null static func in_app(platforms: RequestPurchasePropsByPlatforms, use_alternative_billing_value: Variant = null) -> RequestPurchaseProps: @@ -4890,19 +4895,19 @@ class RequestSubscriptionAndroidProps: var obfuscated_account_id: Variant = null ## Obfuscated profile ID var obfuscated_profile_id: Variant = null - ## Personalized offer flag. + ## Personalized offer flag. When true, indicates the price was customized for this user. var is_offer_personalized: Variant = null ## Purchase token for upgrades/downgrades var purchase_token: Variant = null - ## Original external transaction ID for replacing a subscription that was + ## Original external transaction ID for replacing a subscription that was purchased through developer billing. Available in OpenIAP Spec 2.1.0 / openiap-google 2.3.0 (requires Play Billing 9.1.0+). var original_external_transaction_id: Variant = null - ## Replacement mode for subscription changes + ## Replacement mode for subscription changes @deprecated Use subscriptionProductReplacementParams instead for item-level replacement (8.1.0+). var replacement_mode: Variant = null ## Subscription offers var subscription_offers: Array[AndroidSubscriptionOfferInput] = [] - ## Product-level replacement parameters (8.1.0+) + ## Product-level replacement parameters (8.1.0+) Use this instead of replacementMode for item-level replacement This singular form requires skus to contain exactly one target product. Multi-item subscription changes need a per-target replacement mapping and are rejected rather than applying one oldProductId to multiple products. var subscription_product_replacement_params: SubscriptionProductReplacementParamsAndroid - ## Developer billing option parameters for external payments and Billing Choice. + ## Developer billing option parameters for external payments and Billing Choice. Billing Choice is available in OpenIAP Spec 2.1.0 / openiap-google 2.3.0 (requires Play Billing 9.1.0+). var developer_billing_option: DeveloperBillingOptionParamsAndroid static func from_dict(data: Dictionary) -> RequestSubscriptionAndroidProps: @@ -4988,17 +4993,17 @@ class RequestSubscriptionIosProps: var and_dangerously_finish_transaction_automatically: Variant = null var app_account_token: Variant = null var quantity: Variant = null - ## Promotional offer to apply for subscription purchases. + ## Promotional offer to apply for subscription purchases. Requires server-signed offer with nonce, timestamp, keyId, and signature. var with_offer: DiscountOfferInputIOS - ## Win-back offer to apply (iOS 18+) + ## Win-back offer to apply (iOS 18+) Used to re-engage churned subscribers with a discount or free trial. The offer is available when the customer is eligible and can be discovered via StoreKit Message (automatic) or subscription offer APIs. var win_back_offer: WinBackOfferInputIOS - ## JWS promotional offer (iOS 15+, WWDC 2025). + ## JWS promotional offer (iOS 15+, WWDC 2025). New signature format using compact JWS string for promotional offers. Back-deployed to iOS 15. var promotional_offer_jws: PromotionalOfferJWSInputIOS - ## Billing plan to use when purchasing an annual subscription that offers + ## Billing plan to use when purchasing an annual subscription that offers monthly billing with a 12-month commitment (iOS 26.4+). var billing_plan_type: Variant = null - ## Compact JWS string for overriding introductory offer eligibility + ## Compact JWS string for overriding introductory offer eligibility (iOS 15+, WWDC 2025). When nil, the system determines eligibility. Generate the JWS on your server and pass it to StoreKit's introductoryOfferEligibility(compactJWS:) purchase option. var compact_jws: Variant = null - ## Advanced commerce data token (iOS 15+). + ## Advanced commerce data token (iOS 15+). Used with StoreKit 2's Product.PurchaseOption.custom API for passing campaign tokens, affiliate IDs, or other attribution data. The data is formatted as JSON: {"signatureInfo": {"token": ""}} var advanced_commerce_data: Variant = null static func from_dict(data: Dictionary) -> RequestSubscriptionIosProps: @@ -5197,9 +5202,9 @@ class RequestVerifyPurchaseWithIapkitGoogleProps: class RequestVerifyPurchaseWithIapkitProps: ## API key used for the Authorization header (Bearer {apiKey}). var api_key: Variant = null - ## Available in OpenIAP Spec 2.3.1 / openiap-apple 2.4.0 / openiap-google 2.4.0. + ## Available in OpenIAP Spec 2.3.1 / openiap-apple 2.4.0 / openiap-google 2.4.0. Base URL for the IAPKit server. Defaults to https://kit.openiap.dev. Set this to a reachable HTTP(S) origin when self-hosting or testing a local IAPKit server. The apiKey must be issued by the same IAPKit/Convex deployment as this server. var base_url: Variant = null - ## Available in OpenIAP Spec 2.4.0 / openiap-apple 2.4.1 / openiap-google 2.4.1. + ## Available in OpenIAP Spec 2.4.0 / openiap-apple 2.4.1 / openiap-google 2.4.1. Include the product's public IAPKit client payload in a valid Apple or Google verification response. Defaults to false so existing response shapes and bandwidth remain unchanged. var include_client_payload: Variant = null ## Apple App Store verification parameters. var apple: RequestVerifyPurchaseWithIapkitAppleProps @@ -5311,9 +5316,9 @@ class VerifyPurchaseGoogleOptions: var sku: String = "" ## Android package name (e.g., com.example.app) var package_name: String = "" - ## Purchase token from the purchase response. + ## Purchase token from the purchase response. ⚠️ Sensitive: Do not log this value. var purchase_token: String = "" - ## Google OAuth2 access token for API authentication. + ## Google OAuth2 access token for API authentication. ⚠️ Sensitive: Do not log this value. var access_token: String = "" ## Whether this is a subscription purchase (affects API endpoint used) var is_sub: Variant = null @@ -5352,7 +5357,7 @@ class VerifyPurchaseHorizonOptions: var sku: String = "" ## The user ID of the user whose purchase you want to verify var user_id: String = "" - ## Access token for Meta API authentication (OC|$APP_ID|$APP_SECRET or User Access Token). + ## Access token for Meta API authentication (OC|$APP_ID|$APP_SECRET or User Access Token). ⚠️ Sensitive: Do not log this value. var access_token: String = "" static func from_dict(data: Dictionary) -> VerifyPurchaseHorizonOptions: @@ -6107,7 +6112,7 @@ class Query: const return_type = "Boolean" const is_array = false - ## Fetch products or subscriptions from the store. + ## Fetch products or subscriptions from the store. See: https://openiap.dev/docs/apis/fetch-products class fetchProductsField: const name = "fetchProducts" const snake_name = "fetch_products" @@ -6127,7 +6132,7 @@ class Query: const return_type = "FetchProductsResult" const is_array = false - ## List active purchases for the current user. + ## List active purchases for the current user. See: https://openiap.dev/docs/apis/get-available-purchases class getAvailablePurchasesField: const name = "getAvailablePurchases" const snake_name = "get_available_purchases" @@ -6148,7 +6153,7 @@ class Query: const return_type = "Purchase" const is_array = true - ## Get details of all currently active subscriptions (filters by subscriptionIds when provided). + ## Get details of all currently active subscriptions (filters by subscriptionIds when provided). See: https://openiap.dev/docs/apis/get-active-subscriptions class getActiveSubscriptionsField: const name = "getActiveSubscriptions" const snake_name = "get_active_subscriptions" @@ -6174,7 +6179,7 @@ class Query: const return_type = "ActiveSubscription" const is_array = true - ## Check whether the user has any active subscription. + ## Check whether the user has any active subscription. See: https://openiap.dev/docs/apis/has-active-subscriptions class hasActiveSubscriptionsField: const name = "hasActiveSubscriptions" const snake_name = "has_active_subscriptions" @@ -6200,7 +6205,7 @@ class Query: const return_type = "Boolean" const is_array = false - ## Return the store-authoritative country code: ISO 3166-1 alpha-3 on Apple + ## Return the store-authoritative country code: ISO 3166-1 alpha-3 on Apple platforms and alpha-2 on Android. The operation fails when the store cannot provide a value; implementations must not synthesize a locale fallback. See: https://openiap.dev/docs/apis/get-storefront class getStorefrontField: const name = "getStorefront" const snake_name = "get_storefront" @@ -6209,7 +6214,7 @@ class Query: const return_type = "String" const is_array = false - ## Deprecated. Get the current App Store storefront ISO 3166-1 alpha-3 country + ## Deprecated. Get the current App Store storefront ISO 3166-1 alpha-3 country code — use cross-platform getStorefront instead. See: https://openiap.dev/docs/apis/ios/get-storefront-ios @deprecated Use getStorefront class getStorefrontIOSField: const name = "getStorefrontIOS" const snake_name = "get_storefront_ios" @@ -6218,7 +6223,7 @@ class Query: const return_type = "String" const is_array = false - ## Read the App Store-promoted product, if any (iOS 11+). + ## Read the App Store-promoted product, if any (iOS 11+). See: https://openiap.dev/docs/apis/ios/get-promoted-product-ios class getPromotedProductIOSField: const name = "getPromotedProductIOS" const snake_name = "get_promoted_product_ios" @@ -6227,7 +6232,7 @@ class Query: const return_type = "ProductIOS" const is_array = false - ## Check eligibility for the external purchase notice sheet (iOS 17.4+). + ## Check eligibility for the external purchase notice sheet (iOS 17.4+). Uses ExternalPurchase.canPresent. See: https://openiap.dev/docs/apis/ios/can-present-external-purchase-notice-ios class canPresentExternalPurchaseNoticeIOSField: const name = "canPresentExternalPurchaseNoticeIOS" const snake_name = "can_present_external_purchase_notice_ios" @@ -6236,7 +6241,7 @@ class Query: const return_type = "Boolean" const is_array = false - ## Check eligibility for the custom-link variant of external purchase (iOS 18.1+). + ## Check eligibility for the custom-link variant of external purchase (iOS 18.1+). Returns true if the app can use custom external purchase links. Reference: https://developer.apple.com/documentation/storekit/externalpurchasecustomlink/iseligible See: https://openiap.dev/docs/apis/ios/is-eligible-for-external-purchase-custom-link-ios class isEligibleForExternalPurchaseCustomLinkIOSField: const name = "isEligibleForExternalPurchaseCustomLinkIOS" const snake_name = "is_eligible_for_external_purchase_custom_link_ios" @@ -6245,7 +6250,7 @@ class Query: const return_type = "Boolean" const is_array = false - ## Fetch a token for Apple's External Purchase Server reporting API (iOS 18.1+). + ## Fetch a token for Apple's External Purchase Server reporting API (iOS 18.1+). Use this token to report transactions made through ExternalPurchaseCustomLink. Reference: https://developer.apple.com/documentation/storekit/externalpurchasecustomlink/token(for:) See: https://openiap.dev/docs/apis/ios/get-external-purchase-custom-link-token-ios class getExternalPurchaseCustomLinkTokenIOSField: const name = "getExternalPurchaseCustomLinkTokenIOS" const snake_name = "get_external_purchase_custom_link_token_ios" @@ -6273,7 +6278,7 @@ class Query: const return_type = "ExternalPurchaseCustomLinkTokenResultIOS" const is_array = false - ## List unfinished StoreKit transactions in the queue. + ## List unfinished StoreKit transactions in the queue. See: https://openiap.dev/docs/apis/ios/get-pending-transactions-ios class getPendingTransactionsIOSField: const name = "getPendingTransactionsIOS" const snake_name = "get_pending_transactions_ios" @@ -6282,7 +6287,7 @@ class Query: const return_type = "PurchaseIOS" const is_array = true - ## Check intro-offer eligibility for a subscription group. + ## Check intro-offer eligibility for a subscription group. See: https://openiap.dev/docs/apis/ios/is-eligible-for-intro-offer-ios class isEligibleForIntroOfferIOSField: const name = "isEligibleForIntroOfferIOS" const snake_name = "is_eligible_for_intro_offer_ios" @@ -6302,7 +6307,7 @@ class Query: const return_type = "Boolean" const is_array = false - ## Get subscription status objects from StoreKit 2 (iOS 15+). + ## Get subscription status objects from StoreKit 2 (iOS 15+). See: https://openiap.dev/docs/apis/ios/subscription-status-ios class subscriptionStatusIOSField: const name = "subscriptionStatusIOS" const snake_name = "subscription_status_ios" @@ -6322,7 +6327,7 @@ class Query: const return_type = "SubscriptionStatusIOS" const is_array = true - ## Get the user's current entitlement for a product, using StoreKit 2 (iOS 15+). + ## Get the user's current entitlement for a product, using StoreKit 2 (iOS 15+). See: https://openiap.dev/docs/apis/ios/current-entitlement-ios class currentEntitlementIOSField: const name = "currentEntitlementIOS" const snake_name = "current_entitlement_ios" @@ -6342,7 +6347,7 @@ class Query: const return_type = "PurchaseIOS" const is_array = false - ## Get the latest verified transaction for a product, using StoreKit 2. + ## Get the latest verified transaction for a product, using StoreKit 2. See: https://openiap.dev/docs/apis/ios/latest-transaction-ios class latestTransactionIOSField: const name = "latestTransactionIOS" const snake_name = "latest_transaction_ios" @@ -6362,7 +6367,7 @@ class Query: const return_type = "PurchaseIOS" const is_array = false - ## Check whether a transaction's JWS verification passed (StoreKit 2). + ## Check whether a transaction's JWS verification passed (StoreKit 2). See: https://openiap.dev/docs/apis/ios/is-transaction-verified-ios class isTransactionVerifiedIOSField: const name = "isTransactionVerifiedIOS" const snake_name = "is_transaction_verified_ios" @@ -6382,7 +6387,7 @@ class Query: const return_type = "Boolean" const is_array = false - ## Return the JWS string for a transaction (StoreKit 2). + ## Return the JWS string for a transaction (StoreKit 2). See: https://openiap.dev/docs/apis/ios/get-transaction-jws-ios class getTransactionJwsIOSField: const name = "getTransactionJwsIOS" const snake_name = "get_transaction_jws_ios" @@ -6402,7 +6407,7 @@ class Query: const return_type = "String" const is_array = false - ## Get base64-encoded receipt data (legacy validation). + ## Get base64-encoded receipt data (legacy validation). See: https://openiap.dev/docs/apis/ios/get-receipt-data-ios class getReceiptDataIOSField: const name = "getReceiptDataIOS" const snake_name = "get_receipt_data_ios" @@ -6411,7 +6416,7 @@ class Query: const return_type = "String" const is_array = false - ## Fetch the app transaction (iOS 16+). + ## Fetch the app transaction (iOS 16+). See: https://openiap.dev/docs/apis/ios/get-app-transaction-ios class getAppTransactionIOSField: const name = "getAppTransactionIOS" const snake_name = "get_app_transaction_ios" @@ -6420,7 +6425,7 @@ class Query: const return_type = "AppTransaction" const is_array = false - ## List every StoreKit transaction (finished + unfinished) for the current user. + ## List every StoreKit transaction (finished + unfinished) for the current user. Requires the SKIncludeConsumableInAppPurchaseHistory Info.plist key in the host app for finished consumables to be included (iOS 18+). Unlike getAvailablePurchases, always returns the iOS-specific PurchaseIOS shape. See: https://openiap.dev/docs/apis/ios/get-all-transactions-ios class getAllTransactionsIOSField: const name = "getAllTransactionsIOS" const snake_name = "get_all_transactions_ios" @@ -6429,7 +6434,7 @@ class Query: const return_type = "PurchaseIOS" const is_array = true - ## Deprecated. Legacy App Store receipt validation — use verifyPurchase instead. + ## Deprecated. Legacy App Store receipt validation — use verifyPurchase instead. See: https://openiap.dev/docs/apis/ios/validate-receipt-ios @deprecated Use verifyPurchase class validateReceiptIOSField: const name = "validateReceiptIOS" const snake_name = "validate_receipt_ios" @@ -6449,7 +6454,7 @@ class Query: const return_type = "VerifyPurchaseResultIOS" const is_array = false - ## Fetch Play Billing assets and loyalty text for developer-rendered Billing Choice screens. + ## Fetch Play Billing assets and loyalty text for developer-rendered Billing Choice screens. OpenIAP availability: Spec 2.1.0 / openiap-google 2.3.0 (requires Play Billing 9.1.0+). Throws OpenIapError.NotPrepared if billing client is not ready. See: https://openiap.dev/docs/apis/android/get-billing-choice-info-android class getBillingChoiceInfoAndroidField: const name = "getBillingChoiceInfoAndroid" const snake_name = "get_billing_choice_info_android" @@ -6483,7 +6488,7 @@ class Mutation: const return_type = "Boolean" const is_array = false - ## Initialize the store connection. Call before any IAP API. + ## Initialize the store connection. Call before any IAP API. See: https://openiap.dev/docs/apis/init-connection class initConnectionField: const name = "initConnection" const snake_name = "init_connection" @@ -6504,7 +6509,7 @@ class Mutation: const return_type = "Boolean" const is_array = false - ## Close the store connection and release resources. + ## Close the store connection and release resources. See: https://openiap.dev/docs/apis/end-connection class endConnectionField: const name = "endConnection" const snake_name = "end_connection" @@ -6513,7 +6518,7 @@ class Mutation: const return_type = "Boolean" const is_array = false - ## Initiate a purchase or subscription flow; rely on events for final state. + ## Initiate a purchase or subscription flow; rely on events for final state. See: https://openiap.dev/docs/apis/request-purchase class requestPurchaseField: const name = "requestPurchase" const snake_name = "request_purchase" @@ -6533,7 +6538,7 @@ class Mutation: const return_type = "RequestPurchaseResult" const is_array = false - ## Complete a transaction after server-side verification. Required on Android within 3 days. + ## Complete a transaction after server-side verification. Required on Android within 3 days. See: https://openiap.dev/docs/apis/finish-transaction class finishTransactionField: const name = "finishTransaction" const snake_name = "finish_transaction" @@ -6558,7 +6563,7 @@ class Mutation: const return_type = "VoidResult" const is_array = false - ## Restore non-consumable and active subscription purchases. + ## Restore non-consumable and active subscription purchases. See: https://openiap.dev/docs/apis/restore-purchases class restorePurchasesField: const name = "restorePurchases" const snake_name = "restore_purchases" @@ -6567,7 +6572,7 @@ class Mutation: const return_type = "VoidResult" const is_array = false - ## Open the platform's subscription management UI. + ## Open the platform's subscription management UI. See: https://openiap.dev/docs/apis/deep-link-to-subscriptions class deepLinkToSubscriptionsField: const name = "deepLinkToSubscriptions" const snake_name = "deep_link_to_subscriptions" @@ -6588,7 +6593,7 @@ class Mutation: const return_type = "VoidResult" const is_array = false - ## Deprecated. Validate purchase receipts with the configured providers — use verifyPurchase instead. + ## Deprecated. Validate purchase receipts with the configured providers — use verifyPurchase instead. See: https://openiap.dev/docs/features/validation#verify-purchase @deprecated Use verifyPurchase class validateReceiptField: const name = "validateReceipt" const snake_name = "validate_receipt" @@ -6608,7 +6613,7 @@ class Mutation: const return_type = "VerifyPurchaseResult" const is_array = false - ## Verify a purchase against your own backend. Returns a platform-specific + ## Verify a purchase against your own backend. Returns a platform-specific variant of VerifyPurchaseResult — VerifyPurchaseResultIOS exposes isValid + receipt/JWS metadata, VerifyPurchaseResultAndroid carries Play Store receipt fields (no isValid), and VerifyPurchaseResultHorizon uses success. Inspect the concrete variant before reading fields. See: https://openiap.dev/docs/features/validation#verify-purchase class verifyPurchaseField: const name = "verifyPurchase" const snake_name = "verify_purchase" @@ -6628,7 +6633,7 @@ class Mutation: const return_type = "VerifyPurchaseResult" const is_array = false - ## Verify via a managed provider without standing up your own server. The + ## Verify via a managed provider without standing up your own server. The PurchaseVerificationProvider enum currently exposes only IAPKit; platform availability may differ by implementation. See: https://openiap.dev/docs/features/validation#verify-purchase-with-provider class verifyPurchaseWithProviderField: const name = "verifyPurchaseWithProvider" const snake_name = "verify_purchase_with_provider" @@ -6648,7 +6653,7 @@ class Mutation: const return_type = "VerifyPurchaseWithProviderResult" const is_array = false - ## Clear pending transactions in the queue (sandbox helper). + ## Clear pending transactions in the queue (sandbox helper). See: https://openiap.dev/docs/apis/ios/clear-transaction-ios class clearTransactionIOSField: const name = "clearTransactionIOS" const snake_name = "clear_transaction_ios" @@ -6657,7 +6662,7 @@ class Mutation: const return_type = "Boolean" const is_array = false - ## Buy the currently promoted product. + ## Buy the currently promoted product. See: https://openiap.dev/docs/apis/ios/request-purchase-on-promoted-product-ios @deprecated Use promotedProductListenerIOS to receive the productId, then call requestPurchase with that SKU instead. In StoreKit 2, promoted products can be purchased directly via the standard purchase flow. class requestPurchaseOnPromotedProductIOSField: const name = "requestPurchaseOnPromotedProductIOS" const snake_name = "request_purchase_on_promoted_product_ios" @@ -6666,7 +6671,7 @@ class Mutation: const return_type = "Boolean" const is_array = false - ## Present the manage-subscriptions sheet and return changed purchases (iOS 15+). + ## Present the manage-subscriptions sheet and return changed purchases (iOS 15+). See: https://openiap.dev/docs/apis/ios/show-manage-subscriptions-ios class showManageSubscriptionsIOSField: const name = "showManageSubscriptionsIOS" const snake_name = "show_manage_subscriptions_ios" @@ -6675,7 +6680,7 @@ class Mutation: const return_type = "PurchaseIOS" const is_array = true - ## Present the refund request sheet (iOS 15+). See also Features → Refund. + ## Present the refund request sheet (iOS 15+). See also Features → Refund. See: https://openiap.dev/docs/apis/ios/begin-refund-request-ios class beginRefundRequestIOSField: const name = "beginRefundRequestIOS" const snake_name = "begin_refund_request_ios" @@ -6695,7 +6700,7 @@ class Mutation: const return_type = "String" const is_array = false - ## Force sync transactions with the App Store (iOS 15+). + ## Force sync transactions with the App Store (iOS 15+). See: https://openiap.dev/docs/apis/ios/sync-ios class syncIOSField: const name = "syncIOS" const snake_name = "sync_ios" @@ -6704,7 +6709,7 @@ class Mutation: const return_type = "Boolean" const is_array = false - ## Show the App Store offer code redemption sheet. + ## Show the App Store offer code redemption sheet. See: https://openiap.dev/docs/apis/ios/present-code-redemption-sheet-ios class presentCodeRedemptionSheetIOSField: const name = "presentCodeRedemptionSheetIOS" const snake_name = "present_code_redemption_sheet_ios" @@ -6713,7 +6718,7 @@ class Mutation: const return_type = "Boolean" const is_array = false - ## Present the external purchase notice sheet (iOS 17.4+). + ## Present the external purchase notice sheet (iOS 17.4+). Uses ExternalPurchase.presentNoticeSheet() which returns a token when the user continues. Reference: https://developer.apple.com/documentation/storekit/externalpurchase/presentnoticesheet() See: https://openiap.dev/docs/apis/ios/present-external-purchase-notice-sheet-ios class presentExternalPurchaseNoticeSheetIOSField: const name = "presentExternalPurchaseNoticeSheetIOS" const snake_name = "present_external_purchase_notice_sheet_ios" @@ -6722,7 +6727,7 @@ class Mutation: const return_type = "ExternalPurchaseNoticeResultIOS" const is_array = false - ## Present an external purchase link, StoreKit External (iOS 16+). + ## Present an external purchase link, StoreKit External (iOS 16+). See: https://openiap.dev/docs/apis/ios/present-external-purchase-link-ios class presentExternalPurchaseLinkIOSField: const name = "presentExternalPurchaseLinkIOS" const snake_name = "present_external_purchase_link_ios" @@ -6742,7 +6747,7 @@ class Mutation: const return_type = "ExternalPurchaseLinkResultIOS" const is_array = false - ## Present the disclosure sheet required before linking out via ExternalPurchaseCustomLink (iOS 18.1+). + ## Present the disclosure sheet required before linking out via ExternalPurchaseCustomLink (iOS 18.1+). Call this after a deliberate customer interaction before linking out to external purchases. Reference: https://developer.apple.com/documentation/storekit/externalpurchasecustomlink/shownotice(type:) See: https://openiap.dev/docs/apis/ios/show-external-purchase-custom-link-notice-ios class showExternalPurchaseCustomLinkNoticeIOSField: const name = "showExternalPurchaseCustomLinkNoticeIOS" const snake_name = "show_external_purchase_custom_link_notice_ios" @@ -6770,7 +6775,7 @@ class Mutation: const return_type = "ExternalPurchaseCustomLinkNoticeResultIOS" const is_array = false - ## Acknowledge a non-consumable purchase. Required within 3 days or Google auto-refunds. + ## Acknowledge a non-consumable purchase. Required within 3 days or Google auto-refunds. See: https://openiap.dev/docs/apis/android/acknowledge-purchase-android class acknowledgePurchaseAndroidField: const name = "acknowledgePurchaseAndroid" const snake_name = "acknowledge_purchase_android" @@ -6790,7 +6795,7 @@ class Mutation: const return_type = "Boolean" const is_array = false - ## Consume a consumable purchase so it can be re-bought. + ## Consume a consumable purchase so it can be re-bought. See: https://openiap.dev/docs/apis/android/consume-purchase-android class consumePurchaseAndroidField: const name = "consumePurchaseAndroid" const snake_name = "consume_purchase_android" @@ -6810,7 +6815,7 @@ class Mutation: const return_type = "Boolean" const is_array = false - ## Check whether alternative billing is available for the user. Step 1 of the alternative billing flow. + ## Check whether alternative billing is available for the user. Step 1 of the alternative billing flow. Returns true if available, false otherwise. Throws OpenIapError.NotPrepared if billing client not ready. See: https://openiap.dev/docs/apis/android/check-alternative-billing-availability-android class checkAlternativeBillingAvailabilityAndroidField: const name = "checkAlternativeBillingAvailabilityAndroid" const snake_name = "check_alternative_billing_availability_android" @@ -6819,7 +6824,7 @@ class Mutation: const return_type = "Boolean" const is_array = false - ## Display Google's alternative billing information dialog. Step 2 of the alternative billing flow. + ## Display Google's alternative billing information dialog. Step 2 of the alternative billing flow. Must be called BEFORE processing payment in your payment system. Returns true if user accepted, false if user canceled. Throws OpenIapError.NotPrepared if billing client not ready. See: https://openiap.dev/docs/apis/android/show-alternative-billing-dialog-android class showAlternativeBillingDialogAndroidField: const name = "showAlternativeBillingDialogAndroid" const snake_name = "show_alternative_billing_dialog_android" @@ -6828,7 +6833,7 @@ class Mutation: const return_type = "Boolean" const is_array = false - ## Create a reporting token for an alternative billing flow. Step 3 of the alternative billing flow. + ## Create a reporting token for an alternative billing flow. Step 3 of the alternative billing flow. Must be called AFTER successful payment in your payment system. Token must be reported to Google Play backend within 24 hours. Returns token string, or null if creation failed. Throws OpenIapError.NotPrepared if billing client not ready. See: https://openiap.dev/docs/apis/android/create-alternative-billing-token-android class createAlternativeBillingTokenAndroidField: const name = "createAlternativeBillingTokenAndroid" const snake_name = "create_alternative_billing_token_android" @@ -6837,7 +6842,7 @@ class Mutation: const return_type = "String" const is_array = false - ## Check whether a billing program (e.g., External Payments) is available for the current user. + ## Check whether a billing program (e.g., External Payments) is available for the current user. Replaces the deprecated isExternalOfferAvailableAsync API. Introduced in Google Play Billing Library 8.2.0. External Offer and External Content Link integrations must use 8.2.1+ because 8.2.1 fixes this API. Returns availability result with isAvailable flag. Throws OpenIapError.NotPrepared if billing client not ready. See: https://openiap.dev/docs/apis/android/is-billing-program-available-android class isBillingProgramAvailableAndroidField: const name = "isBillingProgramAvailableAndroid" const snake_name = "is_billing_program_available_android" @@ -6864,7 +6869,7 @@ class Mutation: const return_type = "BillingProgramAvailabilityResultAndroid" const is_array = false - ## Create the reporting details and external transaction token required by a billing program. + ## Create the reporting details and external transaction token required by a billing program. Introduced in Play Billing 8.2.0. External Offer and External Content Link integrations must use 8.2.1+ and create fresh details immediately before every redirect session; do not cache the token for a later redirect. The same token may report multiple purchases made during one External Offer session. Replaces the deprecated createExternalOfferReportingDetailsAsync API. Returns external transaction token needed for reporting external transactions. developerBillingType is optional. When program is BILLING_CHOICE and developerBillingType is omitted, native Android defaults it to IN_APP. The Billing Choice extension is available in OpenIAP Spec 2.1.0 / openiap-google 2.3.0 (requires Play Billing 9.1.0+). Throws OpenIapError.NotPrepared if billing client not ready. See: https://openiap.dev/docs/apis/android/create-billing-program-reporting-details-android class createBillingProgramReportingDetailsAndroidField: const name = "createBillingProgramReportingDetailsAndroid" const snake_name = "create_billing_program_reporting_details_android" @@ -6903,7 +6908,7 @@ class Mutation: const return_type = "BillingProgramReportingDetailsAndroid" const is_array = false - ## Launch an external content/offer link from inside the Billing Programs flow (introduced in + ## Launch an external content/offer link from inside the Billing Programs flow (introduced in Play Billing 8.2.0; External Offer and External Content Link require 8.2.1+), including developer-rendered Billing Choice external-link flows. Billing Choice availability: OpenIAP Spec 2.1.0 / openiap-google 2.3.0 (requires Play Billing 9.1.0+). Replaces the deprecated showExternalOfferInformationDialog API. Shows Play Store dialog and optionally launches external URL. Throws OpenIapError.NotPrepared if billing client not ready. See: https://openiap.dev/docs/apis/android/launch-external-link-android class launchExternalLinkAndroidField: const name = "launchExternalLinkAndroid" const snake_name = "launch_external_link_android" @@ -6923,7 +6928,7 @@ class Mutation: const return_type = "Boolean" const is_array = false - ## Open the Google Play offer/promo code redemption flow so the user can enter a code. + ## Open the Google Play offer/promo code redemption flow so the user can enter a code. On Google Play builds, launches the Play Store redeem page (https://play.google.com/redeem). A purchase listener can receive the redeemed purchase while the app is running with an active billing connection; always reconcile with getAvailablePurchases when the app resumes. Does not require the billing client to be initialized (no Play Billing version requirement). Planned OpenIAP availability: Spec 2.5.0 / openiap-google 2.5.0. Android counterpart of presentCodeRedemptionSheetIOS. Returns true when the redemption flow was launched, or false when the current store flavor does not provide an equivalent redemption flow. See: https://openiap.dev/docs/apis/android/open-redeem-offer-code-android class openRedeemOfferCodeAndroidField: const name = "openRedeemOfferCodeAndroid" const snake_name = "open_redeem_offer_code_android" @@ -6932,7 +6937,7 @@ class Mutation: const return_type = "Boolean" const is_array = false - ## Show Google's mandatory information dialog before a developer-rendered, + ## Show Google's mandatory information dialog before a developer-rendered, in-app Billing Choice screen. OpenIAP availability: Spec 2.1.0 / openiap-google 2.3.0 (requires Play Billing 9.1.0+). Throws OpenIapError.NotPrepared if billing client not ready. See: https://openiap.dev/docs/apis/android/show-billing-program-information-dialog-android class showBillingProgramInformationDialogAndroidField: const name = "showBillingProgramInformationDialogAndroid" const snake_name = "show_billing_program_information_dialog_android" @@ -6952,7 +6957,7 @@ class Mutation: const return_type = "BillingResultAndroid" const is_array = false - ## Overlay Play billing in-app messages, such as payment issues or subscription price-change confirmations. + ## Overlay Play billing in-app messages, such as payment issues or subscription price-change confirmations. OpenIAP availability: Spec 2.1.0 / openiap-google 2.3.0 (upstream API available since Play Billing 4.1.0). Returns a response code and, when the subscription status changes, the related purchase token. Throws OpenIapError.NotPrepared if billing client not ready. See: https://openiap.dev/docs/apis/android/show-in-app-messages-android class showInAppMessagesAndroidField: const name = "showInAppMessagesAndroid" const snake_name = "show_in_app_messages_android" @@ -6981,7 +6986,7 @@ class Mutation: # Query API helpers -## Fetch products or subscriptions from the store. +## Fetch products or subscriptions from the store. See: https://openiap.dev/docs/apis/fetch-products static func fetch_products_args(params: ProductRequest) -> Dictionary: var args = {} if params != null: @@ -6991,7 +6996,7 @@ static func fetch_products_args(params: ProductRequest) -> Dictionary: args["params"] = params return args -## List active purchases for the current user. +## List active purchases for the current user. See: https://openiap.dev/docs/apis/get-available-purchases static func get_available_purchases_args(options: Variant = null) -> Dictionary: var args = {} if options != null: @@ -7001,41 +7006,41 @@ static func get_available_purchases_args(options: Variant = null) -> Dictionary: args["options"] = options return args -## Get details of all currently active subscriptions (filters by subscriptionIds when provided). +## Get details of all currently active subscriptions (filters by subscriptionIds when provided). See: https://openiap.dev/docs/apis/get-active-subscriptions static func get_active_subscriptions_args(subscription_ids: Variant = null) -> Dictionary: var args = {} if subscription_ids != null: args["subscriptionIds"] = subscription_ids return args -## Check whether the user has any active subscription. +## Check whether the user has any active subscription. See: https://openiap.dev/docs/apis/has-active-subscriptions static func has_active_subscriptions_args(subscription_ids: Variant = null) -> Dictionary: var args = {} if subscription_ids != null: args["subscriptionIds"] = subscription_ids return args -## Return the store-authoritative country code: ISO 3166-1 alpha-3 on Apple +## Return the store-authoritative country code: ISO 3166-1 alpha-3 on Apple platforms and alpha-2 on Android. The operation fails when the store cannot provide a value; implementations must not synthesize a locale fallback. See: https://openiap.dev/docs/apis/get-storefront static func get_storefront_args() -> Dictionary: return {} -## Deprecated. Get the current App Store storefront ISO 3166-1 alpha-3 country +## Deprecated. Get the current App Store storefront ISO 3166-1 alpha-3 country code — use cross-platform getStorefront instead. See: https://openiap.dev/docs/apis/ios/get-storefront-ios @deprecated Use getStorefront static func get_storefront_ios_args() -> Dictionary: return {} -## Read the App Store-promoted product, if any (iOS 11+). +## Read the App Store-promoted product, if any (iOS 11+). See: https://openiap.dev/docs/apis/ios/get-promoted-product-ios static func get_promoted_product_ios_args() -> Dictionary: return {} -## Check eligibility for the external purchase notice sheet (iOS 17.4+). +## Check eligibility for the external purchase notice sheet (iOS 17.4+). Uses ExternalPurchase.canPresent. See: https://openiap.dev/docs/apis/ios/can-present-external-purchase-notice-ios static func can_present_external_purchase_notice_ios_args() -> Dictionary: return {} -## Check eligibility for the custom-link variant of external purchase (iOS 18.1+). +## Check eligibility for the custom-link variant of external purchase (iOS 18.1+). Returns true if the app can use custom external purchase links. Reference: https://developer.apple.com/documentation/storekit/externalpurchasecustomlink/iseligible See: https://openiap.dev/docs/apis/ios/is-eligible-for-external-purchase-custom-link-ios static func is_eligible_for_external_purchase_custom_link_ios_args() -> Dictionary: return {} -## Fetch a token for Apple's External Purchase Server reporting API (iOS 18.1+). +## Fetch a token for Apple's External Purchase Server reporting API (iOS 18.1+). Use this token to report transactions made through ExternalPurchaseCustomLink. Reference: https://developer.apple.com/documentation/storekit/externalpurchasecustomlink/token(for:) See: https://openiap.dev/docs/apis/ios/get-external-purchase-custom-link-token-ios static func get_external_purchase_custom_link_token_ios_args(token_type: ExternalPurchaseCustomLinkTokenTypeIOS) -> Dictionary: var args = {} if EXTERNAL_PURCHASE_CUSTOM_LINK_TOKEN_TYPE_IOS_VALUES.has(token_type): @@ -7044,59 +7049,59 @@ static func get_external_purchase_custom_link_token_ios_args(token_type: Externa args["tokenType"] = token_type return args -## List unfinished StoreKit transactions in the queue. +## List unfinished StoreKit transactions in the queue. See: https://openiap.dev/docs/apis/ios/get-pending-transactions-ios static func get_pending_transactions_ios_args() -> Dictionary: return {} -## Check intro-offer eligibility for a subscription group. +## Check intro-offer eligibility for a subscription group. See: https://openiap.dev/docs/apis/ios/is-eligible-for-intro-offer-ios static func is_eligible_for_intro_offer_ios_args(group_id: String) -> Dictionary: var args = {} args["groupID"] = group_id return args -## Get subscription status objects from StoreKit 2 (iOS 15+). +## Get subscription status objects from StoreKit 2 (iOS 15+). See: https://openiap.dev/docs/apis/ios/subscription-status-ios static func subscription_status_ios_args(sku: String) -> Dictionary: var args = {} args["sku"] = sku return args -## Get the user's current entitlement for a product, using StoreKit 2 (iOS 15+). +## Get the user's current entitlement for a product, using StoreKit 2 (iOS 15+). See: https://openiap.dev/docs/apis/ios/current-entitlement-ios static func current_entitlement_ios_args(sku: String) -> Dictionary: var args = {} args["sku"] = sku return args -## Get the latest verified transaction for a product, using StoreKit 2. +## Get the latest verified transaction for a product, using StoreKit 2. See: https://openiap.dev/docs/apis/ios/latest-transaction-ios static func latest_transaction_ios_args(sku: String) -> Dictionary: var args = {} args["sku"] = sku return args -## Check whether a transaction's JWS verification passed (StoreKit 2). +## Check whether a transaction's JWS verification passed (StoreKit 2). See: https://openiap.dev/docs/apis/ios/is-transaction-verified-ios static func is_transaction_verified_ios_args(sku: String) -> Dictionary: var args = {} args["sku"] = sku return args -## Return the JWS string for a transaction (StoreKit 2). +## Return the JWS string for a transaction (StoreKit 2). See: https://openiap.dev/docs/apis/ios/get-transaction-jws-ios static func get_transaction_jws_ios_args(sku: String) -> Dictionary: var args = {} args["sku"] = sku return args -## Get base64-encoded receipt data (legacy validation). +## Get base64-encoded receipt data (legacy validation). See: https://openiap.dev/docs/apis/ios/get-receipt-data-ios static func get_receipt_data_ios_args() -> Dictionary: return {} -## Fetch the app transaction (iOS 16+). +## Fetch the app transaction (iOS 16+). See: https://openiap.dev/docs/apis/ios/get-app-transaction-ios static func get_app_transaction_ios_args() -> Dictionary: return {} -## List every StoreKit transaction (finished + unfinished) for the current user. +## List every StoreKit transaction (finished + unfinished) for the current user. Requires the SKIncludeConsumableInAppPurchaseHistory Info.plist key in the host app for finished consumables to be included (iOS 18+). Unlike getAvailablePurchases, always returns the iOS-specific PurchaseIOS shape. See: https://openiap.dev/docs/apis/ios/get-all-transactions-ios static func get_all_transactions_ios_args() -> Dictionary: return {} -## Deprecated. Legacy App Store receipt validation — use verifyPurchase instead. +## Deprecated. Legacy App Store receipt validation — use verifyPurchase instead. See: https://openiap.dev/docs/apis/ios/validate-receipt-ios @deprecated Use verifyPurchase static func validate_receipt_ios_args(options: VerifyPurchaseProps) -> Dictionary: var args = {} if options != null: @@ -7106,7 +7111,7 @@ static func validate_receipt_ios_args(options: VerifyPurchaseProps) -> Dictionar args["options"] = options return args -## Fetch Play Billing assets and loyalty text for developer-rendered Billing Choice screens. +## Fetch Play Billing assets and loyalty text for developer-rendered Billing Choice screens. OpenIAP availability: Spec 2.1.0 / openiap-google 2.3.0 (requires Play Billing 9.1.0+). Throws OpenIapError.NotPrepared if billing client is not ready. See: https://openiap.dev/docs/apis/android/get-billing-choice-info-android static func get_billing_choice_info_android_args(params: GetBillingChoiceInfoParamsAndroid) -> Dictionary: var args = {} if params != null: @@ -7118,7 +7123,7 @@ static func get_billing_choice_info_android_args(params: GetBillingChoiceInfoPar # Mutation API helpers -## Initialize the store connection. Call before any IAP API. +## Initialize the store connection. Call before any IAP API. See: https://openiap.dev/docs/apis/init-connection static func init_connection_args(config: Variant = null) -> Dictionary: var args = {} if config != null: @@ -7128,11 +7133,11 @@ static func init_connection_args(config: Variant = null) -> Dictionary: args["config"] = config return args -## Close the store connection and release resources. +## Close the store connection and release resources. See: https://openiap.dev/docs/apis/end-connection static func end_connection_args() -> Dictionary: return {} -## Initiate a purchase or subscription flow; rely on events for final state. +## Initiate a purchase or subscription flow; rely on events for final state. See: https://openiap.dev/docs/apis/request-purchase static func request_purchase_args(params: RequestPurchaseProps) -> Dictionary: var args = {} if params != null: @@ -7142,7 +7147,7 @@ static func request_purchase_args(params: RequestPurchaseProps) -> Dictionary: args["params"] = params return args -## Complete a transaction after server-side verification. Required on Android within 3 days. +## Complete a transaction after server-side verification. Required on Android within 3 days. See: https://openiap.dev/docs/apis/finish-transaction static func finish_transaction_args(purchase: PurchaseInput, is_consumable: Variant = null) -> Dictionary: var args = {} if purchase != null: @@ -7154,11 +7159,11 @@ static func finish_transaction_args(purchase: PurchaseInput, is_consumable: Vari args["isConsumable"] = is_consumable return args -## Restore non-consumable and active subscription purchases. +## Restore non-consumable and active subscription purchases. See: https://openiap.dev/docs/apis/restore-purchases static func restore_purchases_args() -> Dictionary: return {} -## Open the platform's subscription management UI. +## Open the platform's subscription management UI. See: https://openiap.dev/docs/apis/deep-link-to-subscriptions static func deep_link_to_subscriptions_args(options: Variant = null) -> Dictionary: var args = {} if options != null: @@ -7168,7 +7173,7 @@ static func deep_link_to_subscriptions_args(options: Variant = null) -> Dictiona args["options"] = options return args -## Deprecated. Validate purchase receipts with the configured providers — use verifyPurchase instead. +## Deprecated. Validate purchase receipts with the configured providers — use verifyPurchase instead. See: https://openiap.dev/docs/features/validation#verify-purchase @deprecated Use verifyPurchase static func validate_receipt_args(options: VerifyPurchaseProps) -> Dictionary: var args = {} if options != null: @@ -7178,7 +7183,7 @@ static func validate_receipt_args(options: VerifyPurchaseProps) -> Dictionary: args["options"] = options return args -## Verify a purchase against your own backend. Returns a platform-specific +## Verify a purchase against your own backend. Returns a platform-specific variant of VerifyPurchaseResult — VerifyPurchaseResultIOS exposes isValid + receipt/JWS metadata, VerifyPurchaseResultAndroid carries Play Store receipt fields (no isValid), and VerifyPurchaseResultHorizon uses success. Inspect the concrete variant before reading fields. See: https://openiap.dev/docs/features/validation#verify-purchase static func verify_purchase_args(options: VerifyPurchaseProps) -> Dictionary: var args = {} if options != null: @@ -7188,7 +7193,7 @@ static func verify_purchase_args(options: VerifyPurchaseProps) -> Dictionary: args["options"] = options return args -## Verify via a managed provider without standing up your own server. The +## Verify via a managed provider without standing up your own server. The PurchaseVerificationProvider enum currently exposes only IAPKit; platform availability may differ by implementation. See: https://openiap.dev/docs/features/validation#verify-purchase-with-provider static func verify_purchase_with_provider_args(options: VerifyPurchaseWithProviderProps) -> Dictionary: var args = {} if options != null: @@ -7198,43 +7203,43 @@ static func verify_purchase_with_provider_args(options: VerifyPurchaseWithProvid args["options"] = options return args -## Clear pending transactions in the queue (sandbox helper). +## Clear pending transactions in the queue (sandbox helper). See: https://openiap.dev/docs/apis/ios/clear-transaction-ios static func clear_transaction_ios_args() -> Dictionary: return {} -## Buy the currently promoted product. +## Buy the currently promoted product. See: https://openiap.dev/docs/apis/ios/request-purchase-on-promoted-product-ios @deprecated Use promotedProductListenerIOS to receive the productId, then call requestPurchase with that SKU instead. In StoreKit 2, promoted products can be purchased directly via the standard purchase flow. static func request_purchase_on_promoted_product_ios_args() -> Dictionary: return {} -## Present the manage-subscriptions sheet and return changed purchases (iOS 15+). +## Present the manage-subscriptions sheet and return changed purchases (iOS 15+). See: https://openiap.dev/docs/apis/ios/show-manage-subscriptions-ios static func show_manage_subscriptions_ios_args() -> Dictionary: return {} -## Present the refund request sheet (iOS 15+). See also Features → Refund. +## Present the refund request sheet (iOS 15+). See also Features → Refund. See: https://openiap.dev/docs/apis/ios/begin-refund-request-ios static func begin_refund_request_ios_args(sku: String) -> Dictionary: var args = {} args["sku"] = sku return args -## Force sync transactions with the App Store (iOS 15+). +## Force sync transactions with the App Store (iOS 15+). See: https://openiap.dev/docs/apis/ios/sync-ios static func sync_ios_args() -> Dictionary: return {} -## Show the App Store offer code redemption sheet. +## Show the App Store offer code redemption sheet. See: https://openiap.dev/docs/apis/ios/present-code-redemption-sheet-ios static func present_code_redemption_sheet_ios_args() -> Dictionary: return {} -## Present the external purchase notice sheet (iOS 17.4+). +## Present the external purchase notice sheet (iOS 17.4+). Uses ExternalPurchase.presentNoticeSheet() which returns a token when the user continues. Reference: https://developer.apple.com/documentation/storekit/externalpurchase/presentnoticesheet() See: https://openiap.dev/docs/apis/ios/present-external-purchase-notice-sheet-ios static func present_external_purchase_notice_sheet_ios_args() -> Dictionary: return {} -## Present an external purchase link, StoreKit External (iOS 16+). +## Present an external purchase link, StoreKit External (iOS 16+). See: https://openiap.dev/docs/apis/ios/present-external-purchase-link-ios static func present_external_purchase_link_ios_args(url: String) -> Dictionary: var args = {} args["url"] = url return args -## Present the disclosure sheet required before linking out via ExternalPurchaseCustomLink (iOS 18.1+). +## Present the disclosure sheet required before linking out via ExternalPurchaseCustomLink (iOS 18.1+). Call this after a deliberate customer interaction before linking out to external purchases. Reference: https://developer.apple.com/documentation/storekit/externalpurchasecustomlink/shownotice(type:) See: https://openiap.dev/docs/apis/ios/show-external-purchase-custom-link-notice-ios static func show_external_purchase_custom_link_notice_ios_args(notice_type: ExternalPurchaseCustomLinkNoticeTypeIOS) -> Dictionary: var args = {} if EXTERNAL_PURCHASE_CUSTOM_LINK_NOTICE_TYPE_IOS_VALUES.has(notice_type): @@ -7243,31 +7248,31 @@ static func show_external_purchase_custom_link_notice_ios_args(notice_type: Exte args["noticeType"] = notice_type return args -## Acknowledge a non-consumable purchase. Required within 3 days or Google auto-refunds. +## Acknowledge a non-consumable purchase. Required within 3 days or Google auto-refunds. See: https://openiap.dev/docs/apis/android/acknowledge-purchase-android static func acknowledge_purchase_android_args(purchase_token: String) -> Dictionary: var args = {} args["purchaseToken"] = purchase_token return args -## Consume a consumable purchase so it can be re-bought. +## Consume a consumable purchase so it can be re-bought. See: https://openiap.dev/docs/apis/android/consume-purchase-android static func consume_purchase_android_args(purchase_token: String) -> Dictionary: var args = {} args["purchaseToken"] = purchase_token return args -## Check whether alternative billing is available for the user. Step 1 of the alternative billing flow. +## Check whether alternative billing is available for the user. Step 1 of the alternative billing flow. Returns true if available, false otherwise. Throws OpenIapError.NotPrepared if billing client not ready. See: https://openiap.dev/docs/apis/android/check-alternative-billing-availability-android static func check_alternative_billing_availability_android_args() -> Dictionary: return {} -## Display Google's alternative billing information dialog. Step 2 of the alternative billing flow. +## Display Google's alternative billing information dialog. Step 2 of the alternative billing flow. Must be called BEFORE processing payment in your payment system. Returns true if user accepted, false if user canceled. Throws OpenIapError.NotPrepared if billing client not ready. See: https://openiap.dev/docs/apis/android/show-alternative-billing-dialog-android static func show_alternative_billing_dialog_android_args() -> Dictionary: return {} -## Create a reporting token for an alternative billing flow. Step 3 of the alternative billing flow. +## Create a reporting token for an alternative billing flow. Step 3 of the alternative billing flow. Must be called AFTER successful payment in your payment system. Token must be reported to Google Play backend within 24 hours. Returns token string, or null if creation failed. Throws OpenIapError.NotPrepared if billing client not ready. See: https://openiap.dev/docs/apis/android/create-alternative-billing-token-android static func create_alternative_billing_token_android_args() -> Dictionary: return {} -## Check whether a billing program (e.g., External Payments) is available for the current user. +## Check whether a billing program (e.g., External Payments) is available for the current user. Replaces the deprecated isExternalOfferAvailableAsync API. Introduced in Google Play Billing Library 8.2.0. External Offer and External Content Link integrations must use 8.2.1+ because 8.2.1 fixes this API. Returns availability result with isAvailable flag. Throws OpenIapError.NotPrepared if billing client not ready. See: https://openiap.dev/docs/apis/android/is-billing-program-available-android static func is_billing_program_available_android_args(program: BillingProgramAndroid) -> Dictionary: var args = {} if BILLING_PROGRAM_ANDROID_VALUES.has(program): @@ -7276,7 +7281,7 @@ static func is_billing_program_available_android_args(program: BillingProgramAnd args["program"] = program return args -## Create the reporting details and external transaction token required by a billing program. +## Create the reporting details and external transaction token required by a billing program. Introduced in Play Billing 8.2.0. External Offer and External Content Link integrations must use 8.2.1+ and create fresh details immediately before every redirect session; do not cache the token for a later redirect. The same token may report multiple purchases made during one External Offer session. Replaces the deprecated createExternalOfferReportingDetailsAsync API. Returns external transaction token needed for reporting external transactions. developerBillingType is optional. When program is BILLING_CHOICE and developerBillingType is omitted, native Android defaults it to IN_APP. The Billing Choice extension is available in OpenIAP Spec 2.1.0 / openiap-google 2.3.0 (requires Play Billing 9.1.0+). Throws OpenIapError.NotPrepared if billing client not ready. See: https://openiap.dev/docs/apis/android/create-billing-program-reporting-details-android static func create_billing_program_reporting_details_android_args(program: BillingProgramAndroid, developer_billing_type: Variant = null) -> Dictionary: var args = {} if BILLING_PROGRAM_ANDROID_VALUES.has(program): @@ -7290,7 +7295,7 @@ static func create_billing_program_reporting_details_android_args(program: Billi args["developerBillingType"] = developer_billing_type return args -## Launch an external content/offer link from inside the Billing Programs flow (introduced in +## Launch an external content/offer link from inside the Billing Programs flow (introduced in Play Billing 8.2.0; External Offer and External Content Link require 8.2.1+), including developer-rendered Billing Choice external-link flows. Billing Choice availability: OpenIAP Spec 2.1.0 / openiap-google 2.3.0 (requires Play Billing 9.1.0+). Replaces the deprecated showExternalOfferInformationDialog API. Shows Play Store dialog and optionally launches external URL. Throws OpenIapError.NotPrepared if billing client not ready. See: https://openiap.dev/docs/apis/android/launch-external-link-android static func launch_external_link_android_args(params: LaunchExternalLinkParamsAndroid) -> Dictionary: var args = {} if params != null: @@ -7300,11 +7305,11 @@ static func launch_external_link_android_args(params: LaunchExternalLinkParamsAn args["params"] = params return args -## Open the Google Play offer/promo code redemption flow so the user can enter a code. +## Open the Google Play offer/promo code redemption flow so the user can enter a code. On Google Play builds, launches the Play Store redeem page (https://play.google.com/redeem). A purchase listener can receive the redeemed purchase while the app is running with an active billing connection; always reconcile with getAvailablePurchases when the app resumes. Does not require the billing client to be initialized (no Play Billing version requirement). Planned OpenIAP availability: Spec 2.5.0 / openiap-google 2.5.0. Android counterpart of presentCodeRedemptionSheetIOS. Returns true when the redemption flow was launched, or false when the current store flavor does not provide an equivalent redemption flow. See: https://openiap.dev/docs/apis/android/open-redeem-offer-code-android static func open_redeem_offer_code_android_args() -> Dictionary: return {} -## Show Google's mandatory information dialog before a developer-rendered, +## Show Google's mandatory information dialog before a developer-rendered, in-app Billing Choice screen. OpenIAP availability: Spec 2.1.0 / openiap-google 2.3.0 (requires Play Billing 9.1.0+). Throws OpenIapError.NotPrepared if billing client not ready. See: https://openiap.dev/docs/apis/android/show-billing-program-information-dialog-android static func show_billing_program_information_dialog_android_args(params: BillingProgramInformationDialogParamsAndroid) -> Dictionary: var args = {} if params != null: @@ -7314,7 +7319,7 @@ static func show_billing_program_information_dialog_android_args(params: Billing args["params"] = params return args -## Overlay Play billing in-app messages, such as payment issues or subscription price-change confirmations. +## Overlay Play billing in-app messages, such as payment issues or subscription price-change confirmations. OpenIAP availability: Spec 2.1.0 / openiap-google 2.3.0 (upstream API available since Play Billing 4.1.0). Returns a response code and, when the subscription status changes, the related purchase token. Throws OpenIapError.NotPrepared if billing client not ready. See: https://openiap.dev/docs/apis/android/show-in-app-messages-android static func show_in_app_messages_android_args(params: Variant = null) -> Dictionary: var args = {} if params != null: diff --git a/libraries/godot-iap/scripts/generate-types.sh b/libraries/godot-iap/scripts/generate-types.sh index 3749db4ba..be33c0392 100755 --- a/libraries/godot-iap/scripts/generate-types.sh +++ b/libraries/godot-iap/scripts/generate-types.sh @@ -4,9 +4,13 @@ set -euo pipefail SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) REPO_ROOT=$(cd "$SCRIPT_DIR/.." && pwd) VERSIONS_FILE="$REPO_ROOT/openiap-versions.json" +TARGET_REPOSITORY_PATH="libraries/godot-iap/addons/godot-iap/types.gd" +TARGET_FILE="$REPO_ROOT/addons/godot-iap/types.gd" +HEADER_GUIDANCE="Refresh this file with the generated-types workflow documented for your checkout." VERSION=$(python3 - "$VERSIONS_FILE" <<'PY' import json +import re import sys from pathlib import Path @@ -21,45 +25,82 @@ except json.JSONDecodeError as exc: sys.exit(1) value = data.get("spec") -if not value: +if not isinstance(value, str) or not value.strip(): print("Error: 'spec' version missing in openiap-versions.json", file=sys.stderr) sys.exit(1) +value = value.strip() +if not re.fullmatch(r"[0-9A-Za-z][0-9A-Za-z.+_-]*", value): + print(f"Error: invalid 'spec' version {value!r}", file=sys.stderr) + sys.exit(1) print(value) PY ) -TAG="gql-${VERSION}" - -ASSET_NAME="openiap-gdscript.zip" -DOWNLOAD_URL="https://github.com/hyodotdev/openiap/releases/download/${TAG}/${ASSET_NAME}" -ADDON_DIR="$REPO_ROOT/addons/godot-iap" -EXAMPLE_ADDON_DIR="$REPO_ROOT/Example/addons/godot-iap" - -TEMP_DIR=$(mktemp -d) -ZIP_PATH="$TEMP_DIR/$ASSET_NAME" +TAG="docs-${VERSION}" +DOWNLOAD_URL="https://raw.githubusercontent.com/hyodotdev/openiap/${TAG}/${TARGET_REPOSITORY_PATH}" cleanup() { - rm -rf "$TEMP_DIR" + if [[ -n "${TEMP_FILE:-}" && -f "$TEMP_FILE" ]]; then + rm -f "$TEMP_FILE" + fi } trap cleanup EXIT -echo "⬇️ Downloading $ASSET_NAME from $DOWNLOAD_URL" -curl -fL "$DOWNLOAD_URL" -o "$ZIP_PATH" - -echo "📦 Extracting GDScript types" -unzip -qo "$ZIP_PATH" -d "$TEMP_DIR" -rm -f "$ZIP_PATH" +mkdir -p "$(dirname "$TARGET_FILE")" +TEMP_FILE=$(mktemp "${TARGET_FILE}.tmp.XXXXXX") -mkdir -p "$ADDON_DIR" "$EXAMPLE_ADDON_DIR" +echo "⬇️ Downloading the platform-ready GDScript types from $DOWNLOAD_URL" +curl -fL "$DOWNLOAD_URL" -o "$TEMP_FILE" -# Copy extracted files to target directory -if [ -f "$TEMP_DIR/types.gd" ]; then - cp "$TEMP_DIR/types.gd" "$ADDON_DIR/types.gd" - cp "$TEMP_DIR/types.gd" "$EXAMPLE_ADDON_DIR/types.gd" - echo "✅ types.gd has been updated at $ADDON_DIR/types.gd" - echo "✅ types.gd has been updated at $EXAMPLE_ADDON_DIR/types.gd" +if ! grep -Fq 'AUTO-GENERATED TYPES — DO NOT EDIT DIRECTLY' "$TEMP_FILE" || + ! grep -q '^class ProductRequest:' "$TEMP_FILE" || + [[ -n "$(tail -c 1 "$TEMP_FILE")" ]]; then + echo "Error: downloaded file is not the expected generated GDScript target." >&2 + exit 1 fi -# List all extracted files -echo "📁 Extracted files:" -ls -la "$TEMP_DIR" +python3 - "$TEMP_FILE" "#" "$HEADER_GUIDANCE" <<'PY' +import re +import sys +from pathlib import Path + +path = Path(sys.argv[1]) +prefix = sys.argv[2] +guidance = sys.argv[3] +lines = path.read_text(encoding="utf-8").splitlines(keepends=True) +separator = f"{prefix} " + "=" * 76 +header = f"{prefix} AUTO-GENERATED TYPES — DO NOT EDIT DIRECTLY" +expected = f"{prefix} {guidance}" +plain_lines = [line.rstrip("\r\n") for line in lines] +if len(lines) < 4 or plain_lines[0] != separator or plain_lines[1] != header: + print("Error: downloaded file has an unexpected generated header.", file=sys.stderr) + sys.exit(1) +try: + closing_index = plain_lines.index(separator, 2) +except ValueError: + print("Error: downloaded file has an unterminated generated header.", file=sys.stderr) + sys.exit(1) +candidates = [ + index + for index in range(2, closing_index) + if plain_lines[index] == expected + or re.fullmatch( + re.escape(prefix) + r" Run `[^`\r\n]+`[^\r\n]*\.", + plain_lines[index], + ) +] +if len(candidates) != 1: + print("Error: downloaded file has unexpected generated guidance.", file=sys.stderr) + sys.exit(1) +guidance_index = candidates[0] +if plain_lines[guidance_index] != expected: + ending = "\r\n" if lines[guidance_index].endswith("\r\n") else "\n" + lines[guidance_index] = expected + ending + path.write_text("".join(lines), encoding="utf-8") +PY + +chmod 0644 "$TEMP_FILE" +mv -f "$TEMP_FILE" "$TARGET_FILE" +TEMP_FILE="" + +echo "✅ types.gd has been updated at $TARGET_FILE from tag $TAG" diff --git a/libraries/kmp-iap/CLAUDE.md b/libraries/kmp-iap/CLAUDE.md index 0d5a3514d..8a2be5465 100644 --- a/libraries/kmp-iap/CLAUDE.md +++ b/libraries/kmp-iap/CLAUDE.md @@ -24,6 +24,7 @@ This document outlines the coding conventions and guidelines for the kmp-iap pro - **Important**: The platform identifier should always be a suffix, not a prefix - ✅ Correct: `TransactionStateIOS`, `DiscountPaymentModeIOS` - ❌ Incorrect: `IosTransactionState`, `IosDiscountPaymentMode` + 4. **Field names with platform suffix**: - When the platform acronym appears at the end of a field name, use uppercase - Examples: `quantityIOS`, `appBundleIdIOS`, `environmentIOS` @@ -44,6 +45,12 @@ val quantityIOS: Int // ✅ Correct - Field with iOS suffix val environmentIOS: String // ✅ Correct - Field with iOS suffix ``` +## Generated Types + +- `library/src/commonMain/kotlin/io/github/hyochan/kmpiap/openiap/Types.kt` is generated from `packages/gql`; never edit it manually. +- In this monorepo, run `cd packages/gql && bun run generate` from the repository root so the manifest-backed sync updates every SDK together. +- In a standalone KMP checkout, `./scripts/generate-types.sh` validates and atomically installs the platform-ready KMP target from the raw `docs-${spec}` tag pinned by `openiap-versions.json`. Do not use that download path for unshipped monorepo schema work. + ## API Design Patterns ### Instance Creation diff --git a/libraries/kmp-iap/library/src/commonMain/kotlin/io/github/hyochan/kmpiap/openiap/Types.kt b/libraries/kmp-iap/library/src/commonMain/kotlin/io/github/hyochan/kmpiap/openiap/Types.kt index daceea9b6..b636525e3 100644 --- a/libraries/kmp-iap/library/src/commonMain/kotlin/io/github/hyochan/kmpiap/openiap/Types.kt +++ b/libraries/kmp-iap/library/src/commonMain/kotlin/io/github/hyochan/kmpiap/openiap/Types.kt @@ -1,6 +1,6 @@ // ============================================================================ // AUTO-GENERATED TYPES — DO NOT EDIT DIRECTLY -// Run `bun run generate` after updating any *.graphql schema file. +// Refresh this file with the generated-types workflow documented for your checkout. // ============================================================================ // Suppress unchecked cast warnings for JSON Map parsing - unavoidable due to Kotlin type erasure @@ -13,8 +13,8 @@ package io.github.hyochan.kmpiap.openiap /** * Alternative billing mode for Android * Controls which billing system is used - * @deprecated Use enableBillingProgramAndroid with BillingProgramAndroid instead. * Use USER_CHOICE_BILLING for user choice billing, EXTERNAL_OFFER for alternative only. + * @deprecated Use enableBillingProgramAndroid with BillingProgramAndroid instead. */ public enum class AlternativeBillingModeAndroid(val rawValue: String) { /** @@ -24,13 +24,13 @@ public enum class AlternativeBillingModeAndroid(val rawValue: String) { /** * User choice billing - user can select between Google Play or alternative * Requires Google Play Billing Library 7.0+ - * @deprecated Use BillingProgramAndroid.USER_CHOICE_BILLING instead + * @deprecated Use BillingProgramAndroid.USER_CHOICE_BILLING instead. */ UserChoice("user-choice"), /** * Alternative billing only - no Google Play billing option * Requires Google Play Billing Library 6.2+ - * @deprecated Use BillingProgramAndroid.EXTERNAL_OFFER instead + * @deprecated Use BillingProgramAndroid.EXTERNAL_OFFER instead. */ AlternativeOnly("alternative-only"); @@ -293,8 +293,17 @@ public enum class ErrorCode(val rawValue: String) { RemoteError("remote-error"), NetworkError("network-error"), ServiceError("service-error"), + /** + * @deprecated Use PurchaseVerificationFailed instead + */ ReceiptFailed("receipt-failed"), + /** + * @deprecated Use PurchaseVerificationFinished instead + */ ReceiptFinished("receipt-finished"), + /** + * @deprecated Use PurchaseVerificationFinishFailed instead + */ ReceiptFinishedFailed("receipt-finished-failed"), PurchaseVerificationFailed("purchase-verification-failed"), PurchaseVerificationFinished("purchase-verification-finished"), @@ -1600,6 +1609,9 @@ public interface PurchaseCommon { val id: String val ids: List? val isAutoRenewing: Boolean + /** + * @deprecated Use store instead + */ val platform: IapPlatform val productId: String val purchaseState: PurchaseState @@ -1651,9 +1663,9 @@ public data class ActiveSubscription( val transactionDate: Double, val transactionId: String, /** - * @deprecated iOS only - use daysUntilExpirationIOS instead. * Whether the subscription will expire soon (within 7 days). * Consider using daysUntilExpirationIOS for more precise control. + * @deprecated iOS only - use daysUntilExpirationIOS instead. */ val willExpireSoon: Boolean? = null ) { @@ -2204,8 +2216,8 @@ public data class DiscountDisplayInfoAndroid( /** * Discount information returned from the store. - * @deprecated Use the standardized SubscriptionOffer type instead for cross-platform compatibility. * @see https://openiap.dev/docs/types/subscription-offer + * @deprecated Use the standardized SubscriptionOffer type instead for cross-platform compatibility. */ public data class DiscountIOS( val identifier: String, @@ -2325,7 +2337,9 @@ public data class DiscountOffer( */ val rentalDetailsAndroid: RentalDetailsAndroid? = null, /** - * Type of discount offer + * Offer category. DiscountOffer currently represents Android one-time product + * offers and is populated as OneTime. Introductory and Promotional are used by + * SubscriptionOffer. */ val type: DiscountOfferType, /** @@ -2381,8 +2395,8 @@ public data class DiscountOffer( /** * iOS DiscountOffer (output type). - * @deprecated Use the standardized SubscriptionOffer type instead for cross-platform compatibility. * @see https://openiap.dev/docs/types/subscription-offer + * @deprecated Use the standardized SubscriptionOffer type instead for cross-platform compatibility. */ public data class DiscountOfferIOS( /** @@ -2455,8 +2469,8 @@ public data class EntitlementIOS( /** * External offer availability result (Android) - * @deprecated Use BillingProgramAvailabilityResultAndroid with isBillingProgramAvailableAsync instead * Available in Google Play Billing Library 6.2.0+, deprecated in 8.2.0 + * @deprecated Use BillingProgramAvailabilityResultAndroid with isBillingProgramAvailableAsync instead */ public data class ExternalOfferAvailabilityResultAndroid( /** @@ -2481,8 +2495,8 @@ public data class ExternalOfferAvailabilityResultAndroid( /** * External offer reporting details (Android) - * @deprecated Use BillingProgramReportingDetailsAndroid with createBillingProgramReportingDetailsAsync instead * Available in Google Play Billing Library 6.2.0+, deprecated in 8.2.0 + * @deprecated Use BillingProgramReportingDetailsAndroid with createBillingProgramReportingDetailsAsync instead */ public data class ExternalOfferReportingDetailsAndroid( /** @@ -2985,8 +2999,8 @@ public data class ProductAndroid( /** * One-time purchase offer details (Android). * Available in Google Play Billing Library 8.0+ - * @deprecated Use the standardized DiscountOffer type for Android one-time offers. * @see https://openiap.dev/docs/types/discount-offer + * @deprecated Use the standardized DiscountOffer type for Android one-time offers. */ public data class ProductAndroidOneTimePurchaseOfferDetail( /** @@ -3172,8 +3186,7 @@ public data class ProductSubscriptionAndroid( /** * Legacy nullable compatibility field. Google Play does not populate one-time * purchase offer details for subscription products. - * @deprecated One-time offers belong to ProductAndroid.discountOffers; - * subscriptions use subscriptionOffers. + * @deprecated One-time offers belong to ProductAndroid.discountOffers; subscriptions use subscriptionOffers. */ val oneTimePurchaseOfferDetailsAndroid: List? = null, override val platform: IapPlatform = IapPlatform.Android, @@ -3246,8 +3259,8 @@ public data class ProductSubscriptionAndroid( /** * Subscription offer details (Android). - * @deprecated Use the standardized SubscriptionOffer type instead for cross-platform compatibility. * @see https://openiap.dev/docs/types/subscription-offer + * @deprecated Use the standardized SubscriptionOffer type instead for cross-platform compatibility. */ public data class ProductSubscriptionAndroidOfferDetails( val basePlanId: String, @@ -3425,6 +3438,9 @@ public data class PurchaseAndroid( * Available in Google Play Billing Library 5.0+ */ val pendingPurchaseUpdateAndroid: PendingPurchaseUpdateAndroid? = null, + /** + * @deprecated Use store instead + */ override val platform: IapPlatform, override val productId: String, override val purchaseState: PurchaseState, @@ -3596,6 +3612,9 @@ public data class PurchaseIOS( val originalTransactionDateIOS: Double? = null, val originalTransactionIdentifierIOS: String? = null, val ownershipTypeIOS: String? = null, + /** + * @deprecated Use store instead + */ override val platform: IapPlatform, override val productId: String, override val purchaseState: PurchaseState, @@ -4190,8 +4209,8 @@ public data class SubscriptionOffer( /** * iOS subscription offer details. - * @deprecated Use the standardized SubscriptionOffer type instead for cross-platform compatibility. * @see https://openiap.dev/docs/types/subscription-offer + * @deprecated Use the standardized SubscriptionOffer type instead for cross-platform compatibility. */ public data class SubscriptionOfferIOS( val displayPrice: String, @@ -5017,8 +5036,8 @@ public data class InitConnectionConfig( /** * Alternative billing mode for Android * If not specified, defaults to NONE (standard Google Play billing) - * @deprecated Use enableBillingProgramAndroid instead. * Use USER_CHOICE_BILLING for user choice billing, EXTERNAL_OFFER for alternative only. + * @deprecated Use enableBillingProgramAndroid instead. */ val alternativeBillingModeAndroid: AlternativeBillingModeAndroid? = null, /** @@ -5355,7 +5374,14 @@ public data class RequestPurchaseIosProps( public data class RequestPurchaseProps( val request: Request, + /** + * Explicit purchase type hint (defaults to in-app) + */ val type: ProductQueryType, + /** + * This flag only logs debug info and has no effect on the purchase flow. + * @deprecated Use enableBillingProgramAndroid in InitConnectionConfig instead. + */ val useAlternativeBilling: Boolean? = null ) { init { @@ -5404,7 +5430,13 @@ public data class RequestPurchaseProps( } sealed class Request { + /** + * Per-platform purchase request props + */ data class Purchase(val value: RequestPurchasePropsByPlatforms) : Request() + /** + * Per-platform subscription request props + */ data class Subscription(val value: RequestSubscriptionPropsByPlatforms) : Request() } } @@ -5487,7 +5519,7 @@ public data class RequestSubscriptionAndroidProps( val purchaseToken: String? = null, /** * Replacement mode for subscription changes - * @deprecated Use subscriptionProductReplacementParams instead for item-level replacement (8.1.0+) + * @deprecated Use subscriptionProductReplacementParams instead for item-level replacement (8.1.0+). */ val replacementMode: Int? = null, /** @@ -6305,10 +6337,8 @@ public interface MutationResolver { /** * Buy the currently promoted product. * - * @deprecated Use promotedProductListenerIOS to receive the productId, - * then call requestPurchase with that SKU instead. In StoreKit 2, - * promoted products can be purchased directly via the standard purchase flow. * See: https://openiap.dev/docs/apis/ios/request-purchase-on-promoted-product-ios + * @deprecated Use promotedProductListenerIOS to receive the productId, then call requestPurchase with that SKU instead. In StoreKit 2, promoted products can be purchased directly via the standard purchase flow. */ suspend fun requestPurchaseOnPromotedProductIOS(): Boolean /** @@ -6337,6 +6367,7 @@ public interface MutationResolver { * Call this after a deliberate customer interaction before linking out to external purchases. * Reference: https://developer.apple.com/documentation/storekit/externalpurchasecustomlink/shownotice(type:) * See: https://openiap.dev/docs/apis/ios/show-external-purchase-custom-link-notice-ios + * Parameter noticeType: Notice type determining the style of disclosure */ suspend fun showExternalPurchaseCustomLinkNoticeIOS(noticeType: ExternalPurchaseCustomLinkNoticeTypeIOS): ExternalPurchaseCustomLinkNoticeResultIOS /** @@ -6361,6 +6392,7 @@ public interface MutationResolver { /** * Deprecated. Validate purchase receipts with the configured providers — use verifyPurchase instead. * See: https://openiap.dev/docs/features/validation#verify-purchase + * @deprecated Use verifyPurchase */ suspend fun validateReceipt(options: VerifyPurchaseProps): VerifyPurchaseResult /** @@ -6436,6 +6468,7 @@ public interface QueryResolver { * Use this token to report transactions made through ExternalPurchaseCustomLink. * Reference: https://developer.apple.com/documentation/storekit/externalpurchasecustomlink/token(for:) * See: https://openiap.dev/docs/apis/ios/get-external-purchase-custom-link-token-ios + * Parameter tokenType: Token type: acquisition (new customers) or services (existing customers) */ suspend fun getExternalPurchaseCustomLinkTokenIOS(tokenType: ExternalPurchaseCustomLinkTokenTypeIOS): ExternalPurchaseCustomLinkTokenResultIOS /** @@ -6464,6 +6497,7 @@ public interface QueryResolver { * Deprecated. Get the current App Store storefront ISO 3166-1 alpha-3 country * code — use cross-platform getStorefront instead. * See: https://openiap.dev/docs/apis/ios/get-storefront-ios + * @deprecated Use getStorefront */ suspend fun getStorefrontIOS(): String /** @@ -6506,6 +6540,7 @@ public interface QueryResolver { /** * Deprecated. Legacy App Store receipt validation — use verifyPurchase instead. * See: https://openiap.dev/docs/apis/ios/validate-receipt-ios + * @deprecated Use verifyPurchase */ suspend fun validateReceiptIOS(options: VerifyPurchaseProps): VerifyPurchaseResultIOS } diff --git a/libraries/kmp-iap/scripts/generate-types.sh b/libraries/kmp-iap/scripts/generate-types.sh index c83e674bc..39726e51f 100755 --- a/libraries/kmp-iap/scripts/generate-types.sh +++ b/libraries/kmp-iap/scripts/generate-types.sh @@ -7,6 +7,7 @@ VERSIONS_FILE="$REPO_ROOT/openiap-versions.json" VERSION=$(python3 - "$VERSIONS_FILE" <<'PY' import json +import re import sys from pathlib import Path @@ -21,106 +22,96 @@ except json.JSONDecodeError as exc: sys.exit(1) value = data.get("spec") -if not value: +if not isinstance(value, str) or not value.strip(): print("Error: 'spec' version missing in openiap-versions.json", file=sys.stderr) sys.exit(1) +value = value.strip() +if not re.fullmatch(r"[0-9A-Za-z][0-9A-Za-z.+_-]*", value): + print(f"Error: invalid 'spec' version {value!r}", file=sys.stderr) + sys.exit(1) print(value) PY ) -TAG="gql-${VERSION}" +TAG="docs-${VERSION}" -ASSET_NAME="openiap-kotlin.zip" -DOWNLOAD_URL="https://github.com/hyodotdev/openiap/releases/download/${TAG}/${ASSET_NAME}" +TARGET_REPOSITORY_PATH="libraries/kmp-iap/library/src/commonMain/kotlin/io/github/hyochan/kmpiap/openiap/Types.kt" +DOWNLOAD_URL="https://raw.githubusercontent.com/hyodotdev/openiap/${TAG}/${TARGET_REPOSITORY_PATH}" TARGET_DIR="$REPO_ROOT/library/src/commonMain/kotlin/io/github/hyochan/kmpiap/openiap" TARGET_FILE="$TARGET_DIR/Types.kt" - -TEMP_DIR=$(mktemp -d) -ZIP_PATH="$TEMP_DIR/$ASSET_NAME" +HEADER_GUIDANCE="Refresh this file with the generated-types workflow documented for your checkout." cleanup() { - rm -rf "$TEMP_DIR" + if [[ -n "${TEMP_FILE:-}" && -f "$TEMP_FILE" ]]; then + rm -f "$TEMP_FILE" + fi } trap cleanup EXIT -echo "⬇️ Downloading $ASSET_NAME from $DOWNLOAD_URL" -curl -fL "$DOWNLOAD_URL" -o "$ZIP_PATH" - -echo "📦 Extracting Types.kt" -unzip -qo "$ZIP_PATH" Types.kt -d "$TEMP_DIR" -rm -f "$ZIP_PATH" - mkdir -p "$TARGET_DIR" -mv "$TEMP_DIR/Types.kt" "$TARGET_FILE" - -# Ensure generated file declares the correct package and fix enum syntax -python3 - <<'PY' "$TARGET_FILE" -import sys +TEMP_FILE=$(mktemp "${TARGET_FILE}.tmp.XXXXXX") + +echo "⬇️ Downloading the platform-ready KMP types from $DOWNLOAD_URL" +curl -fL "$DOWNLOAD_URL" -o "$TEMP_FILE" + +PACKAGE_COUNT=$(grep -c '^package ' "$TEMP_FILE" || true) +if [[ "$PACKAGE_COUNT" -ne 1 ]] || + ! grep -qx 'package io.github.hyochan.kmpiap.openiap' "$TEMP_FILE"; then + echo "Error: downloaded KMP types have an unexpected package declaration" >&2 + exit 1 +fi +if ! grep -q 'AUTO-GENERATED TYPES — DO NOT EDIT DIRECTLY' "$TEMP_FILE"; then + echo "Error: downloaded KMP types are missing the canonical generated header" >&2 + exit 1 +fi +if ! grep -q '^public data class ProductRequest(' "$TEMP_FILE" || + [[ -n "$(tail -c 1 "$TEMP_FILE")" ]]; then + echo "Error: downloaded file is not the expected generated KMP target" >&2 + exit 1 +fi + +python3 - "$TEMP_FILE" "//" "$HEADER_GUIDANCE" <<'PY' import re +import sys +from pathlib import Path -path = sys.argv[1] -package_line = "package io.github.hyochan.kmpiap.openiap\n" - -with open(path, "r", encoding="utf-8") as f: - content = f.read() - -# Fix package declaration -lines = content.split('\n') -pkg_idx = next((i for i, l in enumerate(lines) if l.strip().startswith("package ")), None) -if pkg_idx is not None: - lines[pkg_idx] = package_line.strip() -else: - insert_index = next((i + 1 for i, l in enumerate(lines) if l.startswith("@file:Suppress")), 0) - lines.insert(insert_index, "") - lines.insert(insert_index + 1, package_line.strip()) - lines.insert(insert_index + 2, "") - -content = '\n'.join(lines) - -# Fix enum classes: add semicolon after last enum entry before companion object -# Match enum entry ending with ) followed by newline and companion object -content = re.sub( - r'("[^"]+"\))([,]?)\s*\n(\s+companion object)', - r'\1;\n\3', - content -) - -# Step 1: Remove 'override' from interface properties -content = re.sub( - r'(public interface (?:ProductCommon|PurchaseCommon)\s*\{[^}]*?)(\s+override )(val )', - r'\1\3', - content, - flags=re.DOTALL -) - -# Step 2: Add 'override' to implementing class properties -# Find all data classes that implement ProductCommon or PurchaseCommon and add override to matching properties - -# Properties that need override -product_props = r'(currency|debugDescription|description|displayName|displayPrice|id|platform|price|title|type)' -purchase_props = r'(currentPlanId|id|ids|isAutoRenewing|productId|purchaseState|purchaseToken|quantity|transactionDate)' - -# Pattern to match data class declarations with ProductCommon or PurchaseCommon -# and add override to properties in the constructor -def add_override_to_class(match): - class_content = match.group(0) - - # Add override to product properties - class_content = re.sub( - rf'(\n\s+)(val|var) ({product_props}|{purchase_props}):', - r'\1override \2 \3:', - class_content +path = Path(sys.argv[1]) +prefix = sys.argv[2] +guidance = sys.argv[3] +lines = path.read_text(encoding="utf-8").splitlines(keepends=True) +separator = f"{prefix} " + "=" * 76 +header = f"{prefix} AUTO-GENERATED TYPES — DO NOT EDIT DIRECTLY" +expected = f"{prefix} {guidance}" +plain_lines = [line.rstrip("\r\n") for line in lines] +if len(lines) < 4 or plain_lines[0] != separator or plain_lines[1] != header: + print("Error: downloaded file has an unexpected generated header.", file=sys.stderr) + sys.exit(1) +try: + closing_index = plain_lines.index(separator, 2) +except ValueError: + print("Error: downloaded file has an unterminated generated header.", file=sys.stderr) + sys.exit(1) +candidates = [ + index + for index in range(2, closing_index) + if plain_lines[index] == expected + or re.fullmatch( + re.escape(prefix) + r" Run `[^`\r\n]+`[^\r\n]*\.", + plain_lines[index], ) - - return class_content - -# Match data classes that implement ProductCommon or PurchaseCommon -pattern = r'public data class \w+\([^)]*?\)\s*:\s*(?:[^{]*?(?:ProductCommon|PurchaseCommon)[^{]*?)\{' - -content = re.sub(pattern, add_override_to_class, content, flags=re.DOTALL) - -with open(path, "w", encoding="utf-8") as f: - f.write(content) +] +if len(candidates) != 1: + print("Error: downloaded file has unexpected generated guidance.", file=sys.stderr) + sys.exit(1) +guidance_index = candidates[0] +if plain_lines[guidance_index] != expected: + ending = "\r\n" if lines[guidance_index].endswith("\r\n") else "\n" + lines[guidance_index] = expected + ending + path.write_text("".join(lines), encoding="utf-8") PY -echo "✅ Types.kt has been updated at $TARGET_FILE" +chmod 0644 "$TEMP_FILE" +mv -f "$TEMP_FILE" "$TARGET_FILE" +TEMP_FILE="" + +echo "✅ Types.kt has been updated at $TARGET_FILE from tag $TAG" diff --git a/libraries/kmp-iap/scripts/update-types.mjs b/libraries/kmp-iap/scripts/update-types.mjs deleted file mode 100644 index 387e45fcc..000000000 --- a/libraries/kmp-iap/scripts/update-types.mjs +++ /dev/null @@ -1,76 +0,0 @@ -#!/usr/bin/env node - -import fs from 'fs/promises'; -import path from 'path'; -import { fileURLToPath } from 'url'; - -const __dirname = path.dirname(fileURLToPath(import.meta.url)); -const repoRoot = path.resolve(__dirname, '..'); -const versionsPath = path.join(repoRoot, 'openiap-versions.json'); -const targetPath = path.join(repoRoot, 'src', 'types.ts'); -const releaseBase = 'https://github.com/hyodotdev/openiap/releases/download'; -const assetName = 'types.ts'; - -async function loadRequestedTag() { - const cliTag = process.argv[2]; - if (cliTag && cliTag.trim()) { - return cliTag.trim(); - } - - const raw = await fs.readFile(versionsPath, 'utf8'); - const versions = JSON.parse(raw); - if (!versions.spec) { - throw new Error('Missing spec version in openiap-versions.json'); - } - return versions.spec; -} - -async function downloadTypes(tag) { - const url = `${releaseBase}/${tag}/${assetName}`; - const response = await fetch(url); - if (!response.ok) { - throw new Error(`HTTP ${response.status} ${response.statusText}`); - } - - const content = await response.text(); - await fs.mkdir(path.dirname(targetPath), { recursive: true }); - await fs.writeFile(targetPath, content, 'utf8'); -} - -async function main() { - const requestedTag = await loadRequestedTag(); - const candidates = requestedTag.startsWith('gql-') - ? [requestedTag] - : [`gql-${requestedTag}`, requestedTag]; - - let resolvedTag = null; - - for (let i = 0; i < candidates.length; i += 1) { - const tag = candidates[i]; - try { - console.log(`Attempting to download ${assetName} from tag ${tag}...`); - await downloadTypes(tag); - resolvedTag = tag; - break; - } catch (error) { - const hasNext = i < candidates.length - 1; - if (hasNext) { - console.warn(`Fallback: download failed for tag ${tag} (${error.message}). Trying ${candidates[i + 1]} next...`); - } else { - console.warn(`Download failed for tag ${tag}: ${error.message}`); - } - } - } - - if (!resolvedTag) { - console.error(`Unable to download ${assetName} from any candidate tag: ${candidates.join(', ')}`); - process.exit(1); - } - - console.log(`Updated src/types.ts from tag ${resolvedTag}`); -} - -main().catch((error) => { - console.error(error); - process.exit(1); -}); diff --git a/libraries/maui-iap/CLAUDE.md b/libraries/maui-iap/CLAUDE.md index 374bd066c..563ff943e 100644 --- a/libraries/maui-iap/CLAUDE.md +++ b/libraries/maui-iap/CLAUDE.md @@ -55,12 +55,10 @@ libraries/maui-iap/ ## Auto-generated files (DO NOT EDIT) - [`src/OpenIap.Maui/Types.cs`](src/OpenIap.Maui/Types.cs) — synced from - `packages/gql/src/generated/Types.cs` by - [`scripts/sync-versions.sh`](../../scripts/sync-versions.sh) and the gql - package's `bun run sync` step. Regenerate with: + `packages/gql/src/generated/Types.cs` by the GQL manifest pipeline. + Regenerate with: ```bash cd packages/gql && bun run generate - cd ../.. && bash scripts/sync-versions.sh ``` ## Naming conventions (C#) @@ -212,14 +210,13 @@ dotnet build src/OpenIap.Maui/OpenIap.Maui.csproj 1. Regenerate types if `packages/gql/src/*.graphql` changed: `cd packages/gql && bun run generate` -2. Run `bash scripts/sync-versions.sh` from repo root. -3. Run the shared compile checks: +2. Run the shared compile checks: `dotnet build src/OpenIap.Maui/OpenIap.Maui.csproj -p:TargetFrameworks=net9.0` and `dotnet build src/OpenIap.Maui/OpenIap.Maui.csproj -p:TargetFrameworks=net10.0`. -4. Run `tests/OpenIap.Maui.ContractTests` for both net9.0 and net10.0 and +3. Run `tests/OpenIap.Maui.ContractTests` for both net9.0 and net10.0 and `dotnet test tests/OpenIap.Maui.Tests/OpenIap.Maui.Tests.csproj -p:TargetFrameworks=net9.0` using the commands in [Build & test](#build--test). -5. Verify `Types.cs` matches `packages/gql/src/generated/Types.cs` +4. Verify `Types.cs` matches `packages/gql/src/generated/Types.cs` byte-for-byte (the sync should keep them in lockstep). ## Contributing diff --git a/libraries/maui-iap/README.md b/libraries/maui-iap/README.md index 379a1fa46..256b07a56 100644 --- a/libraries/maui-iap/README.md +++ b/libraries/maui-iap/README.md @@ -200,7 +200,6 @@ Regenerate types with: ```bash cd packages/gql && bun run generate -cd ../.. && bash scripts/sync-versions.sh ``` ## Links diff --git a/libraries/maui-iap/src/OpenIap.Maui/Types.cs b/libraries/maui-iap/src/OpenIap.Maui/Types.cs index b3b1db753..390c0759e 100644 --- a/libraries/maui-iap/src/OpenIap.Maui/Types.cs +++ b/libraries/maui-iap/src/OpenIap.Maui/Types.cs @@ -1,6 +1,6 @@ // ============================================================================ // AUTO-GENERATED TYPES — DO NOT EDIT DIRECTLY -// Run `bun run generate` after updating any *.graphql schema file. +// Refresh this file with the generated-types workflow documented for your checkout. // ============================================================================ #nullable enable @@ -19,8 +19,8 @@ namespace OpenIap; /// Alternative billing mode for Android /// Controls which billing system is used -/// @deprecated Use enableBillingProgramAndroid with BillingProgramAndroid instead. /// Use USER_CHOICE_BILLING for user choice billing, EXTERNAL_OFFER for alternative only. +/// @deprecated Use enableBillingProgramAndroid with BillingProgramAndroid instead. [JsonConverter(typeof(AlternativeBillingModeAndroidJsonConverter))] public enum AlternativeBillingModeAndroid { @@ -28,11 +28,11 @@ public enum AlternativeBillingModeAndroid None, /// User choice billing - user can select between Google Play or alternative /// Requires Google Play Billing Library 7.0+ - /// @deprecated Use BillingProgramAndroid.USER_CHOICE_BILLING instead + /// @deprecated Use BillingProgramAndroid.USER_CHOICE_BILLING instead. UserChoice, /// Alternative billing only - no Google Play billing option /// Requires Google Play Billing Library 6.2+ - /// @deprecated Use BillingProgramAndroid.EXTERNAL_OFFER instead + /// @deprecated Use BillingProgramAndroid.EXTERNAL_OFFER instead. AlternativeOnly } @@ -453,8 +453,11 @@ public enum ErrorCode RemoteError, NetworkError, ServiceError, + /// @deprecated Use PurchaseVerificationFailed instead ReceiptFailed, + /// @deprecated Use PurchaseVerificationFinished instead ReceiptFinished, + /// @deprecated Use PurchaseVerificationFinishFailed instead ReceiptFinishedFailed, PurchaseVerificationFailed, PurchaseVerificationFinished, @@ -2632,6 +2635,7 @@ public interface PurchaseCommon string Id { get; } IReadOnlyList? Ids { get; } bool IsAutoRenewing { get; } + /// @deprecated Use store instead IapPlatform Platform { get; } string ProductId { get; } PurchaseState PurchaseState { get; } @@ -2716,9 +2720,9 @@ public sealed record ActiveSubscription public required double TransactionDate { get; init; } [JsonPropertyName("transactionId")] public required string TransactionId { get; init; } - /// @deprecated iOS only - use daysUntilExpirationIOS instead. /// Whether the subscription will expire soon (within 7 days). /// Consider using daysUntilExpirationIOS for more precise control. + /// @deprecated iOS only - use daysUntilExpirationIOS instead. [JsonPropertyName("willExpireSoon")] public bool? WillExpireSoon { get; init; } } @@ -2943,8 +2947,8 @@ public sealed record DiscountDisplayInfoAndroid } /// Discount information returned from the store. -/// @deprecated Use the standardized SubscriptionOffer type instead for cross-platform compatibility. /// @see https://openiap.dev/docs/types/subscription-offer +/// @deprecated Use the standardized SubscriptionOffer type instead for cross-platform compatibility. public sealed record DiscountIOS { [JsonPropertyName("identifier")] @@ -3028,7 +3032,9 @@ public sealed record DiscountOffer /// [Android] Rental details if this is a rental offer. [JsonPropertyName("rentalDetailsAndroid")] public RentalDetailsAndroid? RentalDetailsAndroid { get; init; } - /// Type of discount offer + /// Offer category. DiscountOffer currently represents Android one-time product + /// offers and is populated as OneTime. Introductory and Promotional are used by + /// SubscriptionOffer. [JsonPropertyName("type")] public required DiscountOfferType Type { get; init; } /// [Android] Valid time window for the offer. @@ -3038,8 +3044,8 @@ public sealed record DiscountOffer } /// iOS DiscountOffer (output type). -/// @deprecated Use the standardized SubscriptionOffer type instead for cross-platform compatibility. /// @see https://openiap.dev/docs/types/subscription-offer +/// @deprecated Use the standardized SubscriptionOffer type instead for cross-platform compatibility. public sealed record DiscountOfferIOS { /// Discount identifier @@ -3070,8 +3076,8 @@ public sealed record EntitlementIOS } /// External offer availability result (Android) -/// @deprecated Use BillingProgramAvailabilityResultAndroid with isBillingProgramAvailableAsync instead /// Available in Google Play Billing Library 6.2.0+, deprecated in 8.2.0 +/// @deprecated Use BillingProgramAvailabilityResultAndroid with isBillingProgramAvailableAsync instead public sealed record ExternalOfferAvailabilityResultAndroid { /// Whether external offers are available for the user @@ -3080,8 +3086,8 @@ public sealed record ExternalOfferAvailabilityResultAndroid } /// External offer reporting details (Android) -/// @deprecated Use BillingProgramReportingDetailsAndroid with createBillingProgramReportingDetailsAsync instead /// Available in Google Play Billing Library 6.2.0+, deprecated in 8.2.0 +/// @deprecated Use BillingProgramReportingDetailsAndroid with createBillingProgramReportingDetailsAsync instead public sealed record ExternalOfferReportingDetailsAndroid { /// External transaction token for reporting external offer transactions @@ -3311,8 +3317,8 @@ public sealed record ProductAndroid : Product, ProductCommon /// One-time purchase offer details (Android). /// Available in Google Play Billing Library 8.0+ -/// @deprecated Use the standardized DiscountOffer type for Android one-time offers. /// @see https://openiap.dev/docs/types/discount-offer +/// @deprecated Use the standardized DiscountOffer type for Android one-time offers. public sealed record ProductAndroidOneTimePurchaseOfferDetail { /// Discount display information @@ -3425,8 +3431,7 @@ public sealed record ProductSubscriptionAndroid : ProductSubscription, ProductCo public required string NameAndroid { get; init; } /// Legacy nullable compatibility field. Google Play does not populate one-time /// purchase offer details for subscription products. - /// @deprecated One-time offers belong to ProductAndroid.discountOffers; - /// subscriptions use subscriptionOffers. + /// @deprecated One-time offers belong to ProductAndroid.discountOffers; subscriptions use subscriptionOffers. [JsonPropertyName("oneTimePurchaseOfferDetailsAndroid")] public IReadOnlyList? OneTimePurchaseOfferDetailsAndroid { get; init; } [JsonPropertyName("platform")] @@ -3455,8 +3460,8 @@ public sealed record ProductSubscriptionAndroid : ProductSubscription, ProductCo } /// Subscription offer details (Android). -/// @deprecated Use the standardized SubscriptionOffer type instead for cross-platform compatibility. /// @see https://openiap.dev/docs/types/subscription-offer +/// @deprecated Use the standardized SubscriptionOffer type instead for cross-platform compatibility. public sealed record ProductSubscriptionAndroidOfferDetails { [JsonPropertyName("basePlanId")] @@ -3577,6 +3582,7 @@ public sealed record PurchaseAndroid : Purchase, PurchaseCommon /// Available in Google Play Billing Library 5.0+ [JsonPropertyName("pendingPurchaseUpdateAndroid")] public PendingPurchaseUpdateAndroid? PendingPurchaseUpdateAndroid { get; init; } + /// @deprecated Use store instead [JsonPropertyName("platform")] public required IapPlatform Platform { get; init; } [JsonPropertyName("productId")] @@ -3666,6 +3672,7 @@ public sealed record PurchaseIOS : Purchase, PurchaseCommon public string? OriginalTransactionIdentifierIOS { get; init; } [JsonPropertyName("ownershipTypeIOS")] public string? OwnershipTypeIOS { get; init; } + /// @deprecated Use store instead [JsonPropertyName("platform")] public required IapPlatform Platform { get; init; } [JsonPropertyName("productId")] @@ -3936,8 +3943,8 @@ public sealed record SubscriptionOffer } /// iOS subscription offer details. -/// @deprecated Use the standardized SubscriptionOffer type instead for cross-platform compatibility. /// @see https://openiap.dev/docs/types/subscription-offer +/// @deprecated Use the standardized SubscriptionOffer type instead for cross-platform compatibility. public sealed record SubscriptionOfferIOS { [JsonPropertyName("displayPrice")] @@ -4306,8 +4313,8 @@ public sealed record InitConnectionConfig { /// Alternative billing mode for Android /// If not specified, defaults to NONE (standard Google Play billing) - /// @deprecated Use enableBillingProgramAndroid instead. /// Use USER_CHOICE_BILLING for user choice billing, EXTERNAL_OFFER for alternative only. + /// @deprecated Use enableBillingProgramAndroid instead. [JsonPropertyName("alternativeBillingModeAndroid")] public AlternativeBillingModeAndroid? AlternativeBillingModeAndroid { get; init; } /// Enable a specific billing program for Android (7.0+) @@ -4464,15 +4471,20 @@ public sealed record RequestPurchaseIosProps public sealed record RequestPurchaseProps : IJsonOnDeserialized { + /// Per-platform purchase request props [JsonPropertyName("requestPurchase")] public RequestPurchasePropsByPlatforms? RequestPurchase { get; init; } + /// Per-platform subscription request props [JsonPropertyName("requestSubscription")] public RequestSubscriptionPropsByPlatforms? RequestSubscription { get; init; } + /// Explicit purchase type hint (defaults to in-app) [JsonPropertyName("type")] public required ProductQueryType Type { get; init; } + /// This flag only logs debug info and has no effect on the purchase flow. + /// @deprecated Use enableBillingProgramAndroid in InitConnectionConfig instead. [JsonPropertyName("useAlternativeBilling")] public bool? UseAlternativeBilling { get; init; } @@ -4538,7 +4550,7 @@ public sealed record RequestSubscriptionAndroidProps [JsonPropertyName("originalExternalTransactionId")] public string? OriginalExternalTransactionId { get; init; } /// Replacement mode for subscription changes - /// @deprecated Use subscriptionProductReplacementParams instead for item-level replacement (8.1.0+) + /// @deprecated Use subscriptionProductReplacementParams instead for item-level replacement (8.1.0+). [JsonPropertyName("replacementMode")] public int? ReplacementMode { get; init; } /// Subscription offers @@ -4908,10 +4920,8 @@ public interface MutationResolver /// Buy the currently promoted product. /// - /// @deprecated Use promotedProductListenerIOS to receive the productId, - /// then call requestPurchase with that SKU instead. In StoreKit 2, - /// promoted products can be purchased directly via the standard purchase flow. /// See: https://openiap.dev/docs/apis/ios/request-purchase-on-promoted-product-ios + /// @deprecated Use promotedProductListenerIOS to receive the productId, then call requestPurchase with that SKU instead. In StoreKit 2, promoted products can be purchased directly via the standard purchase flow. Task RequestPurchaseOnPromotedProductIOSAsync(); /// Restore non-consumable and active subscription purchases. @@ -4936,6 +4946,7 @@ public interface MutationResolver /// Call this after a deliberate customer interaction before linking out to external purchases. /// Reference: https://developer.apple.com/documentation/storekit/externalpurchasecustomlink/shownotice(type:) /// See: https://openiap.dev/docs/apis/ios/show-external-purchase-custom-link-notice-ios + /// Parameter noticeType: Notice type determining the style of disclosure Task ShowExternalPurchaseCustomLinkNoticeIOSAsync(ExternalPurchaseCustomLinkNoticeTypeIOS noticeType); /// Overlay Play billing in-app messages, such as payment issues or subscription price-change confirmations. @@ -4956,6 +4967,7 @@ public interface MutationResolver /// Deprecated. Validate purchase receipts with the configured providers — use verifyPurchase instead. /// See: https://openiap.dev/docs/features/validation#verify-purchase + /// @deprecated Use verifyPurchase Task ValidateReceiptAsync(VerifyPurchaseProps options); /// Verify a purchase against your own backend. Returns a platform-specific @@ -5018,6 +5030,7 @@ public interface QueryResolver /// Use this token to report transactions made through ExternalPurchaseCustomLink. /// Reference: https://developer.apple.com/documentation/storekit/externalpurchasecustomlink/token(for:) /// See: https://openiap.dev/docs/apis/ios/get-external-purchase-custom-link-token-ios + /// Parameter tokenType: Token type: acquisition (new customers) or services (existing customers) Task GetExternalPurchaseCustomLinkTokenIOSAsync(ExternalPurchaseCustomLinkTokenTypeIOS tokenType); /// List unfinished StoreKit transactions in the queue. @@ -5041,6 +5054,7 @@ public interface QueryResolver /// Deprecated. Get the current App Store storefront ISO 3166-1 alpha-3 country /// code — use cross-platform getStorefront instead. /// See: https://openiap.dev/docs/apis/ios/get-storefront-ios + /// @deprecated Use getStorefront Task GetStorefrontIOSAsync(); /// Return the JWS string for a transaction (StoreKit 2). @@ -5075,6 +5089,7 @@ public interface QueryResolver /// Deprecated. Legacy App Store receipt validation — use verifyPurchase instead. /// See: https://openiap.dev/docs/apis/ios/validate-receipt-ios + /// @deprecated Use verifyPurchase Task ValidateReceiptIOSAsync(VerifyPurchaseProps options); } diff --git a/libraries/react-native-iap/CLAUDE.md b/libraries/react-native-iap/CLAUDE.md index 426e60506..f7d1a0b67 100644 --- a/libraries/react-native-iap/CLAUDE.md +++ b/libraries/react-native-iap/CLAUDE.md @@ -54,7 +54,7 @@ example/ # React Native example app (workspace) ### Auto-generated Files -- `src/types.ts` is generated; never edit manually. Update the underlying schema/spec and rerun the generators instead. +- `src/types.ts` is generated; never edit manually. In this monorepo, update the schema/spec and run `cd packages/gql && bun run generate` from the repository root. The library-local `yarn generate:types` command is only for a standalone checkout: it atomically refreshes the platform-ready file from the raw `docs-${spec}` tag pinned by `openiap-versions.json` (or the version passed with `--tag`). - When declaring API params/results in JS/TS modules, import the canonical types from `src/types.ts` rather than creating ad-hoc interfaces. ## Development Commands diff --git a/libraries/react-native-iap/scripts/update-types.mjs b/libraries/react-native-iap/scripts/update-types.mjs index 6a8f93d1c..7699024c8 100755 --- a/libraries/react-native-iap/scripts/update-types.mjs +++ b/libraries/react-native-iap/scripts/update-types.mjs @@ -1,81 +1,146 @@ #!/usr/bin/env node -import {mkdtempSync, readFileSync, writeFileSync, rmSync} from 'node:fs'; -import {join} from 'node:path'; -import {tmpdir} from 'node:os'; +import { + chmodSync, + mkdtempSync, + readFileSync, + renameSync, + rmSync, + writeFileSync, +} from 'node:fs'; +import {dirname, join} from 'node:path'; import {execFileSync} from 'node:child_process'; -import {fileURLToPath, URL} from 'node:url'; +import {fileURLToPath} from 'node:url'; -const __dirname = fileURLToPath(new URL('.', import.meta.url)); -let versions; -try { - versions = JSON.parse( - readFileSync(join(__dirname, '..', 'openiap-versions.json'), 'utf8'), - ); -} catch (error) { - throw new Error( - `react-native-iap: Unable to load openiap-versions.json (${error instanceof Error ? error.message : error})`, - ); -} +const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url)); +const PROJECT_ROOT = join(SCRIPT_DIR, '..'); +const TARGET_REPOSITORY_PATH = 'libraries/react-native-iap/src/types.ts'; +const TARGET_FILE = join(PROJECT_ROOT, 'src', 'types.ts'); +const GENERATED_HEADER = 'AUTO-GENERATED TYPES — DO NOT EDIT DIRECTLY'; +const HEADER_GUIDANCE = + 'Refresh this file with the generated-types workflow documented for your checkout.'; +const REPRESENTATIVE_DECLARATION = 'export interface ProductRequest'; +const HEADER_SEPARATOR = + '// ============================================================================'; +const HEADER_LINE = `// ${GENERATED_HEADER}`; -const DEFAULT_TAG = versions?.spec; -if (typeof DEFAULT_TAG !== 'string' || DEFAULT_TAG.length === 0) { - throw new Error( - 'react-native-iap: "spec" version missing in openiap-versions.json. Provide --tag manually or update the file.', - ); +function normalizeDocsTag(value) { + const normalizedVersion = value.trim().replace(/^(?:docs-|gql-v?|v)/, ''); + if ( + normalizedVersion.length === 0 || + !/^[0-9A-Za-z][0-9A-Za-z.+_-]*$/.test(normalizedVersion) + ) { + throw new Error( + `react-native-iap: Invalid OpenIAP spec version ${JSON.stringify(value)}.`, + ); + } + return `docs-${normalizedVersion}`; } -const PROJECT_ROOT = process.cwd(); - function parseArgs() { const args = process.argv.slice(2); - let tag = DEFAULT_TAG; + let version = null; for (let i = 0; i < args.length; i++) { const arg = args[i]; - if (arg === '--tag' && typeof args[i + 1] === 'string') { - tag = args[i + 1]; + if (arg === '--tag') { + const next = args[i + 1]; + if (typeof next !== 'string' || next.startsWith('--')) { + throw new Error('react-native-iap: --tag requires a version.'); + } + version = next; i++; + continue; } + throw new Error(`react-native-iap: Unknown argument ${arg}.`); + } + + return {version}; +} + +function readPinnedSpecVersion() { + let versions; + try { + versions = JSON.parse( + readFileSync(join(PROJECT_ROOT, 'openiap-versions.json'), 'utf8'), + ); + } catch (error) { + throw new Error( + `react-native-iap: Unable to load openiap-versions.json (${error instanceof Error ? error.message : error}). Provide --tag to override it.`, + ); + } + + const version = versions?.spec; + if (typeof version !== 'string' || version.trim().length === 0) { + throw new Error( + 'react-native-iap: "spec" version missing in openiap-versions.json. Provide --tag manually or update the file.', + ); } + return version; +} - return {tag}; +function getDownloadUrl(tag) { + return `https://raw.githubusercontent.com/hyodotdev/openiap/${tag}/${TARGET_REPOSITORY_PATH}`; } -function getReleaseUrl(tag) { - const normalized = - tag.startsWith('gql-') || tag.startsWith('gql-v') - ? tag.replace(/^gql-v/, 'gql-') - : `gql-${tag}`; - return `https://github.com/hyodotdev/openiap/releases/download/${normalized}/openiap-typescript.zip`; +function validateDownloadedTypes(path) { + const contents = readFileSync(path, 'utf8'); + const lines = contents.split(/\r?\n/); + if ( + lines[0] !== HEADER_SEPARATOR || + lines[1] !== HEADER_LINE || + !contents.includes(REPRESENTATIVE_DECLARATION) || + !contents.endsWith('\n') + ) { + throw new Error( + 'react-native-iap: Downloaded file is not the expected generated TypeScript target.', + ); + } +} + +function normalizeGeneratedHeader(path) { + const contents = readFileSync(path, 'utf8'); + const lineEnding = contents.includes('\r\n') ? '\r\n' : '\n'; + const lines = contents.split(/\r?\n/); + const expectedGuidance = `// ${HEADER_GUIDANCE}`; + const closingSeparator = lines.indexOf(HEADER_SEPARATOR, 2); + const guidanceLines = lines + .slice(2, closingSeparator) + .map((line, index) => ({index: index + 2, line})) + .filter( + ({line}) => + line === expectedGuidance || + /^\/\/ Run `[^`\r\n]+`[^\r\n]*\.$/.test(line), + ); + if (closingSeparator < 0 || guidanceLines.length !== 1) { + throw new Error( + 'react-native-iap: Downloaded file has an unexpected generated header.', + ); + } + if (guidanceLines[0].line !== expectedGuidance) { + lines[guidanceLines[0].index] = expectedGuidance; + writeFileSync(path, lines.join(lineEnding)); + } } function main() { - const {tag} = parseArgs(); - const releaseUrl = getReleaseUrl(tag); - const tempDir = mkdtempSync(join(tmpdir(), 'openiap-types-')); - const zipPath = join(tempDir, 'openiap-typescript.zip'); + const {version: versionOverride} = parseArgs(); + const tag = normalizeDocsTag(versionOverride ?? readPinnedSpecVersion()); + const downloadUrl = getDownloadUrl(tag); + const tempDir = mkdtempSync(join(dirname(TARGET_FILE), '.openiap-types-')); + const tempFile = join(tempDir, 'types.ts'); try { - console.log(`Downloading OpenIAP types (tag: ${tag}) from ${releaseUrl}`); - execFileSync('curl', ['-L', '-o', zipPath, releaseUrl], { + console.log(`Downloading OpenIAP types (tag: ${tag}) from ${downloadUrl}`); + execFileSync('curl', ['-fL', '-o', tempFile, downloadUrl], { stdio: 'inherit', }); - console.log('Extracting types.ts from archive'); - execFileSync('unzip', ['-o', zipPath, 'types.ts', '-d', tempDir], { - stdio: 'inherit', - }); - - const extractedPath = join(tempDir, 'types.ts'); - let contents = readFileSync(extractedPath, 'utf8'); - contents = contents.replace( - /Run `[^`]+` after updating any \*\.graphql schema file\./, - 'Run `bun run generate:types` after updating any *.graphql schema file.', - ); - - const destination = join(PROJECT_ROOT, 'src', 'types.ts'); - writeFileSync(destination, contents); - console.log('Updated src/types.ts'); + validateDownloadedTypes(tempFile); + normalizeGeneratedHeader(tempFile); + validateDownloadedTypes(tempFile); + chmodSync(tempFile, 0o644); + renameSync(tempFile, TARGET_FILE); + console.log(`Updated src/types.ts from tag ${tag}`); } finally { rmSync(tempDir, {recursive: true, force: true}); } diff --git a/libraries/react-native-iap/src/types.ts b/libraries/react-native-iap/src/types.ts index a9e259cf7..abed5a93e 100644 --- a/libraries/react-native-iap/src/types.ts +++ b/libraries/react-native-iap/src/types.ts @@ -1,6 +1,6 @@ // ============================================================================ // AUTO-GENERATED TYPES — DO NOT EDIT DIRECTLY -// Run `npm run generate` after updating any *.graphql schema file. +// Refresh this file with the generated-types workflow documented for your checkout. // ============================================================================ export interface ActiveSubscription { @@ -30,9 +30,9 @@ export interface ActiveSubscription { transactionDate: number; transactionId: string; /** - * @deprecated iOS only - use daysUntilExpirationIOS instead. * Whether the subscription will expire soon (within 7 days). * Consider using daysUntilExpirationIOS for more precise control. + * @deprecated iOS only - use daysUntilExpirationIOS instead. */ willExpireSoon?: (boolean | null); } @@ -90,8 +90,8 @@ export interface AdvancedCommerceRefundIOS { /** * Alternative billing mode for Android * Controls which billing system is used - * @deprecated Use enableBillingProgramAndroid with BillingProgramAndroid instead. * Use USER_CHOICE_BILLING for user choice billing, EXTERNAL_OFFER for alternative only. + * @deprecated Use enableBillingProgramAndroid with BillingProgramAndroid instead. */ export type AlternativeBillingModeAndroid = 'none' | 'user-choice' | 'alternative-only'; @@ -327,8 +327,8 @@ export interface DiscountDisplayInfoAndroid { /** * Discount information returned from the store. - * @deprecated Use the standardized SubscriptionOffer type instead for cross-platform compatibility. * @see https://openiap.dev/docs/types/subscription-offer + * @deprecated Use the standardized SubscriptionOffer type instead for cross-platform compatibility. */ export interface DiscountIOS { identifier: string; @@ -407,7 +407,11 @@ export interface DiscountOffer { purchaseOptionIdAndroid?: (string | null); /** [Android] Rental details if this is a rental offer. */ rentalDetailsAndroid?: (RentalDetailsAndroid | null); - /** Type of discount offer */ + /** + * Offer category. DiscountOffer currently represents Android one-time product + * offers and is populated as OneTime. Introductory and Promotional are used by + * SubscriptionOffer. + */ type: DiscountOfferType; /** * [Android] Valid time window for the offer. @@ -418,8 +422,8 @@ export interface DiscountOffer { /** * iOS DiscountOffer (output type). - * @deprecated Use the standardized SubscriptionOffer type instead for cross-platform compatibility. * @see https://openiap.dev/docs/types/subscription-offer + * @deprecated Use the standardized SubscriptionOffer type instead for cross-platform compatibility. */ export interface DiscountOfferIOS { /** Discount identifier */ @@ -485,8 +489,11 @@ export enum ErrorCode { PurchaseVerificationFinishFailed = 'purchase-verification-finish-failed', PurchaseVerificationFinished = 'purchase-verification-finished', QueryProduct = 'query-product', + /** @deprecated Use PurchaseVerificationFailed instead */ ReceiptFailed = 'receipt-failed', + /** @deprecated Use PurchaseVerificationFinished instead */ ReceiptFinished = 'receipt-finished', + /** @deprecated Use PurchaseVerificationFinishFailed instead */ ReceiptFinishedFailed = 'receipt-finished-failed', RemoteError = 'remote-error', ServiceDisconnected = 'service-disconnected', @@ -518,8 +525,8 @@ export type ExternalLinkTypeAndroid = 'unspecified' | 'link-to-digital-content-o /** * External offer availability result (Android) - * @deprecated Use BillingProgramAvailabilityResultAndroid with isBillingProgramAvailableAsync instead * Available in Google Play Billing Library 6.2.0+, deprecated in 8.2.0 + * @deprecated Use BillingProgramAvailabilityResultAndroid with isBillingProgramAvailableAsync instead */ export interface ExternalOfferAvailabilityResultAndroid { /** Whether external offers are available for the user */ @@ -528,8 +535,8 @@ export interface ExternalOfferAvailabilityResultAndroid { /** * External offer reporting details (Android) - * @deprecated Use BillingProgramReportingDetailsAndroid with createBillingProgramReportingDetailsAsync instead * Available in Google Play Billing Library 6.2.0+, deprecated in 8.2.0 + * @deprecated Use BillingProgramReportingDetailsAndroid with createBillingProgramReportingDetailsAsync instead */ export interface ExternalOfferReportingDetailsAndroid { /** External transaction token for reporting external offer transactions */ @@ -676,8 +683,8 @@ export interface InitConnectionConfig { /** * Alternative billing mode for Android * If not specified, defaults to NONE (standard Google Play billing) - * @deprecated Use enableBillingProgramAndroid instead. * Use USER_CHOICE_BILLING for user choice billing, EXTERNAL_OFFER for alternative only. + * @deprecated Use enableBillingProgramAndroid instead. */ alternativeBillingModeAndroid?: (AlternativeBillingModeAndroid | null); /** @@ -892,10 +899,8 @@ export interface Mutation { /** * Buy the currently promoted product. * - * @deprecated Use promotedProductListenerIOS to receive the productId, - * then call requestPurchase with that SKU instead. In StoreKit 2, - * promoted products can be purchased directly via the standard purchase flow. * See: https://openiap.dev/docs/apis/ios/request-purchase-on-promoted-product-ios + * @deprecated Use promotedProductListenerIOS to receive the productId, then call requestPurchase with that SKU instead. In StoreKit 2, promoted products can be purchased directly via the standard purchase flow. */ requestPurchaseOnPromotedProductIOS: Promise; /** @@ -969,8 +974,6 @@ export interface Mutation { verifyPurchaseWithProvider: Promise; } - - export type MutationAcknowledgePurchaseAndroidArgs = string; export type MutationBeginRefundRequestIosArgs = string; @@ -982,7 +985,6 @@ export interface MutationCreateBillingProgramReportingDetailsAndroidArgs { program: BillingProgramAndroid; } - export type MutationDeepLinkToSubscriptionsArgs = (DeepLinkOptions | null) | undefined; export interface MutationFinishTransactionArgs { @@ -990,7 +992,6 @@ export interface MutationFinishTransactionArgs { purchase: PurchaseInput; } - export type MutationInitConnectionArgs = (InitConnectionConfig | null) | undefined; export type MutationIsBillingProgramAvailableAndroidArgs = BillingProgramAndroid; @@ -999,22 +1000,7 @@ export type MutationLaunchExternalLinkAndroidArgs = LaunchExternalLinkParamsAndr export type MutationPresentExternalPurchaseLinkIosArgs = string; -export type MutationRequestPurchaseArgs = - | { - /** Per-platform purchase request props */ - request: RequestPurchasePropsByPlatforms; - type: 'in-app'; - /** Use alternative billing (Google Play alternative billing, Apple external purchase link) */ - useAlternativeBilling?: boolean | null; - } - | { - /** Per-platform subscription request props */ - request: RequestSubscriptionPropsByPlatforms; - type: 'subs'; - /** Use alternative billing (Google Play alternative billing, Apple external purchase link) */ - useAlternativeBilling?: boolean | null; - }; - +export type MutationRequestPurchaseArgs = RequestPurchaseProps; export type MutationShowBillingProgramInformationDialogAndroidArgs = BillingProgramInformationDialogParamsAndroid; @@ -1118,9 +1104,7 @@ export interface ProductAndroid extends ProductCommon { * Available in Google Play Billing Library 8.0.0+ */ productStatusAndroid?: (ProductStatusAndroid | null); - /** - * @deprecated Use subscriptionOffers instead for cross-platform compatibility. - */ + /** @deprecated Use subscriptionOffers instead for cross-platform compatibility. */ subscriptionOfferDetailsAndroid?: (ProductSubscriptionAndroidOfferDetails[] | null); /** * Standardized subscription offers. @@ -1135,8 +1119,8 @@ export interface ProductAndroid extends ProductCommon { /** * One-time purchase offer details (Android). * Available in Google Play Billing Library 8.0+ - * @deprecated Use the standardized DiscountOffer type for Android one-time offers. * @see https://openiap.dev/docs/types/discount-offer + * @deprecated Use the standardized DiscountOffer type for Android one-time offers. */ export interface ProductAndroidOneTimePurchaseOfferDetail { /** @@ -1207,9 +1191,7 @@ export interface ProductIOS extends ProductCommon { * monthly subscriptions with a 12-month commitment. */ pricingTermsIOS?: (SubscriptionPricingTermsIOS[] | null); - /** - * @deprecated Use subscriptionOffers instead for cross-platform compatibility. - */ + /** @deprecated Use subscriptionOffers instead for cross-platform compatibility. */ subscriptionInfoIOS?: (SubscriptionInfoIOS | null); /** * Standardized subscription offers. @@ -1258,8 +1240,7 @@ export interface ProductSubscriptionAndroid extends ProductCommon { /** * Legacy nullable compatibility field. Google Play does not populate one-time * purchase offer details for subscription products. - * @deprecated One-time offers belong to ProductAndroid.discountOffers; - * subscriptions use subscriptionOffers. + * @deprecated One-time offers belong to ProductAndroid.discountOffers; subscriptions use subscriptionOffers. */ oneTimePurchaseOfferDetailsAndroid?: (ProductAndroidOneTimePurchaseOfferDetail[] | null); platform: 'android'; @@ -1272,9 +1253,7 @@ export interface ProductSubscriptionAndroid extends ProductCommon { * Available in Google Play Billing Library 8.0.0+ */ productStatusAndroid?: (ProductStatusAndroid | null); - /** - * @deprecated Use subscriptionOffers instead for cross-platform compatibility. - */ + /** @deprecated Use subscriptionOffers instead for cross-platform compatibility. */ subscriptionOfferDetailsAndroid: ProductSubscriptionAndroidOfferDetails[]; /** * Standardized subscription offers. @@ -1288,8 +1267,8 @@ export interface ProductSubscriptionAndroid extends ProductCommon { /** * Subscription offer details (Android). - * @deprecated Use the standardized SubscriptionOffer type instead for cross-platform compatibility. * @see https://openiap.dev/docs/types/subscription-offer + * @deprecated Use the standardized SubscriptionOffer type instead for cross-platform compatibility. */ export interface ProductSubscriptionAndroidOfferDetails { basePlanId: string; @@ -1309,9 +1288,7 @@ export interface ProductSubscriptionIOS extends ProductCommon { currency: string; debugDescription?: (string | null); description: string; - /** - * @deprecated Use subscriptionOffers instead for cross-platform compatibility. - */ + /** @deprecated Use subscriptionOffers instead for cross-platform compatibility. */ discountsIOS?: (DiscountIOS[] | null); displayName?: (string | null); displayNameIOS: string; @@ -1333,9 +1310,7 @@ export interface ProductSubscriptionIOS extends ProductCommon { pricingTermsIOS?: (SubscriptionPricingTermsIOS[] | null); /** App Store subscription group identifier for intro-offer eligibility checks. */ subscriptionGroupIdIOS?: (string | null); - /** - * @deprecated Use subscriptionOffers for offer metadata and subscriptionGroupIdIOS for the App Store subscription group identifier. - */ + /** @deprecated Use subscriptionOffers for offer metadata and subscriptionGroupIdIOS for the App Store subscription group identifier. */ subscriptionInfoIOS?: (SubscriptionInfoIOS | null); /** * Standardized subscription offers. @@ -1663,8 +1638,6 @@ export interface Query { validateReceiptIOS: Promise; } - - export type QueryCurrentEntitlementIosArgs = string; export type QueryFetchProductsArgs = ProductRequest; @@ -1825,15 +1798,23 @@ export type RequestPurchaseProps = | { /** Per-platform purchase request props */ request: RequestPurchasePropsByPlatforms; + /** Explicit purchase type hint (defaults to in-app) */ type: 'in-app'; - /** Use alternative billing (Google Play alternative billing, Apple external purchase link) */ + /** + * This flag only logs debug info and has no effect on the purchase flow. + * @deprecated Use enableBillingProgramAndroid in InitConnectionConfig instead. + */ useAlternativeBilling?: boolean | null; } | { /** Per-platform subscription request props */ request: RequestSubscriptionPropsByPlatforms; + /** Explicit purchase type hint (defaults to in-app) */ type: 'subs'; - /** Use alternative billing (Google Play alternative billing, Apple external purchase link) */ + /** + * This flag only logs debug info and has no effect on the purchase flow. + * @deprecated Use enableBillingProgramAndroid in InitConnectionConfig instead. + */ useAlternativeBilling?: boolean | null; }; @@ -1885,7 +1866,7 @@ export interface RequestSubscriptionAndroidProps { purchaseToken?: (string | null); /** * Replacement mode for subscription changes - * @deprecated Use subscriptionProductReplacementParams instead for item-level replacement (8.1.0+) + * @deprecated Use subscriptionProductReplacementParams instead for item-level replacement (8.1.0+). */ replacementMode?: (number | null); /** List of subscription SKUs */ @@ -2091,8 +2072,6 @@ export interface Subscription { userChoiceBillingAndroid: UserChoiceBillingDetails; } - - export type SubscriptionPurchaseUpdatedArgs = (PurchaseUpdatedListenerOptions | null) | undefined; export type SubscriptionBillingPlanTypeIOS = 'unknown' | 'monthly' | 'up-front'; @@ -2193,8 +2172,8 @@ export interface SubscriptionOffer { /** * iOS subscription offer details. - * @deprecated Use the standardized SubscriptionOffer type instead for cross-platform compatibility. * @see https://openiap.dev/docs/types/subscription-offer + * @deprecated Use the standardized SubscriptionOffer type instead for cross-platform compatibility. */ export interface SubscriptionOfferIOS { displayPrice: string; @@ -2508,44 +2487,6 @@ export interface WinBackOfferInputIOS { /** The win-back offer ID from App Store Connect */ offerId: string; } -// -- Query helper types (auto-generated) -export type QueryArgsMap = { - canPresentExternalPurchaseNoticeIOS: never; - currentEntitlementIOS: QueryCurrentEntitlementIosArgs; - fetchProducts: QueryFetchProductsArgs; - getActiveSubscriptions: QueryGetActiveSubscriptionsArgs; - getAllTransactionsIOS: never; - getAppTransactionIOS: never; - getAvailablePurchases: QueryGetAvailablePurchasesArgs; - getBillingChoiceInfoAndroid: QueryGetBillingChoiceInfoAndroidArgs; - getExternalPurchaseCustomLinkTokenIOS: QueryGetExternalPurchaseCustomLinkTokenIosArgs; - getPendingTransactionsIOS: never; - getPromotedProductIOS: never; - getReceiptDataIOS: never; - getStorefront: never; - getStorefrontIOS: never; - getTransactionJwsIOS: QueryGetTransactionJwsIosArgs; - hasActiveSubscriptions: QueryHasActiveSubscriptionsArgs; - isEligibleForExternalPurchaseCustomLinkIOS: never; - isEligibleForIntroOfferIOS: QueryIsEligibleForIntroOfferIosArgs; - isTransactionVerifiedIOS: QueryIsTransactionVerifiedIosArgs; - latestTransactionIOS: QueryLatestTransactionIosArgs; - subscriptionStatusIOS: QuerySubscriptionStatusIosArgs; - validateReceiptIOS: QueryValidateReceiptIosArgs; -}; - -export type QueryField = - QueryArgsMap[K] extends never - ? () => NonNullable - : undefined extends QueryArgsMap[K] - ? (args?: QueryArgsMap[K]) => NonNullable - : (args: QueryArgsMap[K]) => NonNullable; - -export type QueryFieldMap = { - [K in keyof Query]?: QueryField; -}; -// -- End query helper types - // -- Mutation helper types (auto-generated) export type MutationArgsMap = { acknowledgePurchaseAndroid: MutationAcknowledgePurchaseAndroidArgs; @@ -2591,6 +2532,44 @@ export type MutationFieldMap = { }; // -- End mutation helper types +// -- Query helper types (auto-generated) +export type QueryArgsMap = { + canPresentExternalPurchaseNoticeIOS: never; + currentEntitlementIOS: QueryCurrentEntitlementIosArgs; + fetchProducts: QueryFetchProductsArgs; + getActiveSubscriptions: QueryGetActiveSubscriptionsArgs; + getAllTransactionsIOS: never; + getAppTransactionIOS: never; + getAvailablePurchases: QueryGetAvailablePurchasesArgs; + getBillingChoiceInfoAndroid: QueryGetBillingChoiceInfoAndroidArgs; + getExternalPurchaseCustomLinkTokenIOS: QueryGetExternalPurchaseCustomLinkTokenIosArgs; + getPendingTransactionsIOS: never; + getPromotedProductIOS: never; + getReceiptDataIOS: never; + getStorefront: never; + getStorefrontIOS: never; + getTransactionJwsIOS: QueryGetTransactionJwsIosArgs; + hasActiveSubscriptions: QueryHasActiveSubscriptionsArgs; + isEligibleForExternalPurchaseCustomLinkIOS: never; + isEligibleForIntroOfferIOS: QueryIsEligibleForIntroOfferIosArgs; + isTransactionVerifiedIOS: QueryIsTransactionVerifiedIosArgs; + latestTransactionIOS: QueryLatestTransactionIosArgs; + subscriptionStatusIOS: QuerySubscriptionStatusIosArgs; + validateReceiptIOS: QueryValidateReceiptIosArgs; +}; + +export type QueryField = + QueryArgsMap[K] extends never + ? () => NonNullable + : undefined extends QueryArgsMap[K] + ? (args?: QueryArgsMap[K]) => NonNullable + : (args: QueryArgsMap[K]) => NonNullable; + +export type QueryFieldMap = { + [K in keyof Query]?: QueryField; +}; +// -- End query helper types + // -- Subscription helper types (auto-generated) export type SubscriptionArgsMap = { developerProvidedBillingAndroid: never; diff --git a/package.json b/package.json index 4b7b73979..80a5deef6 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,7 @@ "audit:docs": "bun run scripts/audit-docs.ts", "audit:release-state": "node scripts/release-branch-policy.mjs audit", "clean": "rm -rf packages/*/node_modules node_modules", - "generate": "cd packages/gql && bun run generate && cd ../apple && ./scripts/generate-types.sh && cd ../google && ./scripts/generate-types.sh", + "generate": "cd packages/gql && bun run generate", "version:sync": "bun scripts/sync-versions.mjs", "version:bump": "bun scripts/bump-version.mjs", "deploy": "./scripts/deploy.sh", diff --git a/packages/apple/.github/workflows/test.yml b/packages/apple/.github/workflows/test.yml index c3bbcf49f..4c99936c4 100644 --- a/packages/apple/.github/workflows/test.yml +++ b/packages/apple/.github/workflows/test.yml @@ -13,10 +13,10 @@ jobs: steps: - uses: actions/checkout@v3 - - name: Verify generated types are not changed + - name: Verify generated types are present run: | - ./scripts/generate-types.sh - git diff --exit-code Sources/Models/Types.swift + test -s Sources/Models/Types.swift + grep -Fq 'AUTO-GENERATED TYPES — DO NOT EDIT DIRECTLY' Sources/Models/Types.swift - name: Select Xcode run: | diff --git a/packages/apple/Sources/Models/Types.swift b/packages/apple/Sources/Models/Types.swift index 9db6f0e54..e781e6760 100644 --- a/packages/apple/Sources/Models/Types.swift +++ b/packages/apple/Sources/Models/Types.swift @@ -1,6 +1,6 @@ // ============================================================================ // AUTO-GENERATED TYPES — DO NOT EDIT DIRECTLY -// Run `bun run generate` after updating any *.graphql schema file. +// Refresh this file with the generated-types workflow documented for your checkout. // ============================================================================ import Foundation @@ -9,18 +9,18 @@ import Foundation /// Alternative billing mode for Android /// Controls which billing system is used -/// @deprecated Use enableBillingProgramAndroid with BillingProgramAndroid instead. /// Use USER_CHOICE_BILLING for user choice billing, EXTERNAL_OFFER for alternative only. +/// @deprecated Use enableBillingProgramAndroid with BillingProgramAndroid instead. public enum AlternativeBillingModeAndroid: String, Codable, CaseIterable { /// Standard Google Play billing (default) case none = "none" /// User choice billing - user can select between Google Play or alternative /// Requires Google Play Billing Library 7.0+ - /// @deprecated Use BillingProgramAndroid.USER_CHOICE_BILLING instead + /// @deprecated Use BillingProgramAndroid.USER_CHOICE_BILLING instead. case userChoice = "user-choice" /// Alternative billing only - no Google Play billing option /// Requires Google Play Billing Library 6.2+ - /// @deprecated Use BillingProgramAndroid.EXTERNAL_OFFER instead + /// @deprecated Use BillingProgramAndroid.EXTERNAL_OFFER instead. case alternativeOnly = "alternative-only" } @@ -121,8 +121,11 @@ public enum ErrorCode: String, Codable, CaseIterable { case remoteError = "remote-error" case networkError = "network-error" case serviceError = "service-error" + /// @deprecated Use PurchaseVerificationFailed instead case receiptFailed = "receipt-failed" + /// @deprecated Use PurchaseVerificationFinished instead case receiptFinished = "receipt-finished" + /// @deprecated Use PurchaseVerificationFinishFailed instead case receiptFinishedFailed = "receipt-finished-failed" case purchaseVerificationFailed = "purchase-verification-failed" case purchaseVerificationFinished = "purchase-verification-finished" @@ -636,6 +639,7 @@ public protocol PurchaseCommon: Codable { var id: String { get } var ids: [String]? { get } var isAutoRenewing: Bool { get } + /// @deprecated Use store instead var platform: IapPlatform { get } var productId: String { get } var purchaseState: PurchaseState { get } @@ -672,9 +676,9 @@ public struct ActiveSubscription: Codable { /// Unix timestamp in milliseconds since January 1, 1970 UTC. public var transactionDate: Double public var transactionId: String - /// @deprecated iOS only - use daysUntilExpirationIOS instead. /// Whether the subscription will expire soon (within 7 days). /// Consider using daysUntilExpirationIOS for more precise control. + /// @deprecated iOS only - use daysUntilExpirationIOS instead. public var willExpireSoon: Bool? = nil } @@ -837,8 +841,8 @@ public struct DiscountDisplayInfoAndroid: Codable { } /// Discount information returned from the store. -/// @deprecated Use the standardized SubscriptionOffer type instead for cross-platform compatibility. /// @see https://openiap.dev/docs/types/subscription-offer +/// @deprecated Use the standardized SubscriptionOffer type instead for cross-platform compatibility. public struct DiscountIOS: Codable { public var identifier: String public var localizedPrice: String? = nil @@ -898,7 +902,9 @@ public struct DiscountOffer: Codable { public var purchaseOptionIdAndroid: String? = nil /// [Android] Rental details if this is a rental offer. public var rentalDetailsAndroid: RentalDetailsAndroid? = nil - /// Type of discount offer + /// Offer category. DiscountOffer currently represents Android one-time product + /// offers and is populated as OneTime. Introductory and Promotional are used by + /// SubscriptionOffer. public var type: DiscountOfferType /// [Android] Valid time window for the offer. /// Contains startTimeMillis and endTimeMillis. @@ -906,8 +912,8 @@ public struct DiscountOffer: Codable { } /// iOS DiscountOffer (output type). -/// @deprecated Use the standardized SubscriptionOffer type instead for cross-platform compatibility. /// @see https://openiap.dev/docs/types/subscription-offer +/// @deprecated Use the standardized SubscriptionOffer type instead for cross-platform compatibility. public struct DiscountOfferIOS: Codable { /// Discount identifier public var identifier: String @@ -928,16 +934,16 @@ public struct EntitlementIOS: Codable { } /// External offer availability result (Android) -/// @deprecated Use BillingProgramAvailabilityResultAndroid with isBillingProgramAvailableAsync instead /// Available in Google Play Billing Library 6.2.0+, deprecated in 8.2.0 +/// @deprecated Use BillingProgramAvailabilityResultAndroid with isBillingProgramAvailableAsync instead public struct ExternalOfferAvailabilityResultAndroid: Codable { /// Whether external offers are available for the user public var isAvailable: Bool } /// External offer reporting details (Android) -/// @deprecated Use BillingProgramReportingDetailsAndroid with createBillingProgramReportingDetailsAsync instead /// Available in Google Play Billing Library 6.2.0+, deprecated in 8.2.0 +/// @deprecated Use BillingProgramReportingDetailsAndroid with createBillingProgramReportingDetailsAsync instead public struct ExternalOfferReportingDetailsAndroid: Codable { /// External transaction token for reporting external offer transactions public var externalTransactionToken: String @@ -1104,8 +1110,8 @@ public struct ProductAndroid: Codable, ProductCommon { /// One-time purchase offer details (Android). /// Available in Google Play Billing Library 8.0+ -/// @deprecated Use the standardized DiscountOffer type for Android one-time offers. /// @see https://openiap.dev/docs/types/discount-offer +/// @deprecated Use the standardized DiscountOffer type for Android one-time offers. public struct ProductAndroidOneTimePurchaseOfferDetail: Codable { /// Discount display information /// Only available for discounted offers @@ -1177,8 +1183,7 @@ public struct ProductSubscriptionAndroid: Codable, ProductCommon { public var nameAndroid: String /// Legacy nullable compatibility field. Google Play does not populate one-time /// purchase offer details for subscription products. - /// @deprecated One-time offers belong to ProductAndroid.discountOffers; - /// subscriptions use subscriptionOffers. + /// @deprecated One-time offers belong to ProductAndroid.discountOffers; subscriptions use subscriptionOffers. public var oneTimePurchaseOfferDetailsAndroid: [ProductAndroidOneTimePurchaseOfferDetail]? = nil public var platform: IapPlatform = .android public var price: Double? = nil @@ -1199,8 +1204,8 @@ public struct ProductSubscriptionAndroid: Codable, ProductCommon { } /// Subscription offer details (Android). -/// @deprecated Use the standardized SubscriptionOffer type instead for cross-platform compatibility. /// @see https://openiap.dev/docs/types/subscription-offer +/// @deprecated Use the standardized SubscriptionOffer type instead for cross-platform compatibility. public struct ProductSubscriptionAndroidOfferDetails: Codable { public var basePlanId: String /// Installment plan details for this subscription offer. @@ -1273,6 +1278,7 @@ public struct PurchaseAndroid: Codable, PurchaseCommon { /// Returns null if no pending update exists. /// Available in Google Play Billing Library 5.0+ public var pendingPurchaseUpdateAndroid: PendingPurchaseUpdateAndroid? = nil + /// @deprecated Use store instead public var platform: IapPlatform public var productId: String public var purchaseState: PurchaseState @@ -1323,6 +1329,7 @@ public struct PurchaseIOS: Codable, PurchaseCommon { public var originalTransactionDateIOS: Double? = nil public var originalTransactionIdentifierIOS: String? = nil public var ownershipTypeIOS: String? = nil + /// @deprecated Use store instead public var platform: IapPlatform public var productId: String public var purchaseState: PurchaseState @@ -1508,8 +1515,8 @@ public struct SubscriptionOffer: Codable { } /// iOS subscription offer details. -/// @deprecated Use the standardized SubscriptionOffer type instead for cross-platform compatibility. /// @see https://openiap.dev/docs/types/subscription-offer +/// @deprecated Use the standardized SubscriptionOffer type instead for cross-platform compatibility. public struct SubscriptionOfferIOS: Codable { public var displayPrice: String public var id: String @@ -1761,10 +1768,15 @@ public struct DeveloperBillingOptionParamsAndroid: Codable { } public struct DiscountOfferInputIOS: Codable { + /// Discount identifier public var identifier: String + /// Key identifier for validation public var keyIdentifier: String + /// Cryptographic nonce public var nonce: String + /// Signature for validation public var signature: String + /// Timestamp of discount offer public var timestamp: Double public init(identifier: String, keyIdentifier: String, nonce: String, signature: String, timestamp: Double) { @@ -1850,8 +1862,8 @@ public struct InAppMessageParamsAndroid: Codable { public struct InitConnectionConfig: Codable { /// Alternative billing mode for Android /// If not specified, defaults to NONE (standard Google Play billing) - /// @deprecated Use enableBillingProgramAndroid instead. /// Use USER_CHOICE_BILLING for user choice billing, EXTERNAL_OFFER for alternative only. + /// @deprecated Use enableBillingProgramAndroid instead. public var alternativeBillingModeAndroid: AlternativeBillingModeAndroid? /// Billing Choice renderer configured in Play Console. Available in OpenIAP /// Spec 2.1.0 / openiap-google 2.3.0 (requires Play Billing 9.1.0+). @@ -2059,7 +2071,10 @@ public struct RequestPurchaseIosProps: Codable { public struct RequestPurchaseProps: Codable { public var request: Request + /// Explicit purchase type hint (defaults to in-app) public var type: ProductQueryType + /// This flag only logs debug info and has no effect on the purchase flow. + /// @deprecated Use enableBillingProgramAndroid in InitConnectionConfig instead. public var useAlternativeBilling: Bool? public init(request: Request, type: ProductQueryType? = nil, useAlternativeBilling: Bool? = nil) { @@ -2127,7 +2142,9 @@ public struct RequestPurchaseProps: Codable { } public enum Request { + /// Per-platform purchase request props case purchase(RequestPurchasePropsByPlatforms) + /// Per-platform subscription request props case subscription(RequestSubscriptionPropsByPlatforms) } } @@ -2181,7 +2198,7 @@ public struct RequestSubscriptionAndroidProps: Codable { /// Purchase token for upgrades/downgrades public var purchaseToken: String? /// Replacement mode for subscription changes - /// @deprecated Use subscriptionProductReplacementParams instead for item-level replacement (8.1.0+) + /// @deprecated Use subscriptionProductReplacementParams instead for item-level replacement (8.1.0+). public var replacementMode: Int? /// List of subscription SKUs public var skus: [String] @@ -2770,6 +2787,7 @@ public enum Purchase: Codable, PurchaseCommon { } } + /// @deprecated Use store instead public var platform: IapPlatform { switch self { case let .purchaseAndroid(value): @@ -2943,10 +2961,8 @@ public protocol MutationResolver { func requestPurchase(_ params: RequestPurchaseProps) async throws -> RequestPurchaseResult? /// Buy the currently promoted product. /// - /// @deprecated Use promotedProductListenerIOS to receive the productId, - /// then call requestPurchase with that SKU instead. In StoreKit 2, - /// promoted products can be purchased directly via the standard purchase flow. /// See: https://openiap.dev/docs/apis/ios/request-purchase-on-promoted-product-ios + /// @deprecated Use promotedProductListenerIOS to receive the productId, then call requestPurchase with that SKU instead. In StoreKit 2, promoted products can be purchased directly via the standard purchase flow. func requestPurchaseOnPromotedProductIOS() async throws -> Bool /// Restore non-consumable and active subscription purchases. /// See: https://openiap.dev/docs/apis/restore-purchases @@ -2967,6 +2983,7 @@ public protocol MutationResolver { /// Call this after a deliberate customer interaction before linking out to external purchases. /// Reference: https://developer.apple.com/documentation/storekit/externalpurchasecustomlink/shownotice(type:) /// See: https://openiap.dev/docs/apis/ios/show-external-purchase-custom-link-notice-ios + /// Parameter noticeType: Notice type determining the style of disclosure func showExternalPurchaseCustomLinkNoticeIOS(_ noticeType: ExternalPurchaseCustomLinkNoticeTypeIOS) async throws -> ExternalPurchaseCustomLinkNoticeResultIOS /// Overlay Play billing in-app messages, such as payment issues or subscription price-change confirmations. /// OpenIAP availability: Spec 2.1.0 / openiap-google 2.3.0 @@ -2983,6 +3000,7 @@ public protocol MutationResolver { func syncIOS() async throws -> Bool /// Deprecated. Validate purchase receipts with the configured providers — use verifyPurchase instead. /// See: https://openiap.dev/docs/features/validation#verify-purchase + /// @deprecated Use verifyPurchase func validateReceipt(_ options: VerifyPurchaseProps) async throws -> VerifyPurchaseResult /// Verify a purchase against your own backend. Returns a platform-specific /// variant of VerifyPurchaseResult — VerifyPurchaseResultIOS exposes isValid @@ -3034,6 +3052,7 @@ public protocol QueryResolver { /// Use this token to report transactions made through ExternalPurchaseCustomLink. /// Reference: https://developer.apple.com/documentation/storekit/externalpurchasecustomlink/token(for:) /// See: https://openiap.dev/docs/apis/ios/get-external-purchase-custom-link-token-ios + /// Parameter tokenType: Token type: acquisition (new customers) or services (existing customers) func getExternalPurchaseCustomLinkTokenIOS(_ tokenType: ExternalPurchaseCustomLinkTokenTypeIOS) async throws -> ExternalPurchaseCustomLinkTokenResultIOS /// List unfinished StoreKit transactions in the queue. /// See: https://openiap.dev/docs/apis/ios/get-pending-transactions-ios @@ -3052,6 +3071,7 @@ public protocol QueryResolver { /// Deprecated. Get the current App Store storefront ISO 3166-1 alpha-3 country /// code — use cross-platform getStorefront instead. /// See: https://openiap.dev/docs/apis/ios/get-storefront-ios + /// @deprecated Use getStorefront func getStorefrontIOS() async throws -> String /// Return the JWS string for a transaction (StoreKit 2). /// See: https://openiap.dev/docs/apis/ios/get-transaction-jws-ios @@ -3078,6 +3098,7 @@ public protocol QueryResolver { func subscriptionStatusIOS(_ sku: String) async throws -> [SubscriptionStatusIOS] /// Deprecated. Legacy App Store receipt validation — use verifyPurchase instead. /// See: https://openiap.dev/docs/apis/ios/validate-receipt-ios + /// @deprecated Use verifyPurchase func validateReceiptIOS(_ options: VerifyPurchaseProps) async throws -> VerifyPurchaseResultIOS } diff --git a/packages/apple/scripts/generate-types.sh b/packages/apple/scripts/generate-types.sh deleted file mode 100755 index 296378ae7..000000000 --- a/packages/apple/scripts/generate-types.sh +++ /dev/null @@ -1,40 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -# Generate types from local gql package in monorepo - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" -MONOREPO_ROOT="$(cd "${REPO_ROOT}/../.." && pwd)" - -# Source and target paths -GQL_DIR="${MONOREPO_ROOT}/packages/gql" -SOURCE_FILE="${GQL_DIR}/src/generated/Types.swift" -OUTPUT_DIR="${REPO_ROOT}/Sources/Models" -OUTPUT_FILE="${OUTPUT_DIR}/Types.swift" - -# Check if gql package exists -if [[ ! -d "$GQL_DIR" ]]; then - echo "Error: gql package not found at $GQL_DIR" >&2 - echo "Please run this from the monorepo structure" >&2 - exit 1 -fi - -# Generate types in gql package first -echo "📦 Generating Swift types in gql package..." -cd "$GQL_DIR" -bun run generate:swift - -# Check if source file was generated -if [[ ! -f "$SOURCE_FILE" ]]; then - echo "Error: Types.swift not found at $SOURCE_FILE" >&2 - echo "Generation may have failed" >&2 - exit 1 -fi - -# Copy to ios package -echo "📋 Copying Types.swift to iOS package..." -mkdir -p "${OUTPUT_DIR}" -cp "${SOURCE_FILE}" "${OUTPUT_FILE}" - -echo "✅ Successfully updated ${OUTPUT_FILE}" diff --git a/packages/docs/CONVENTION.md b/packages/docs/CONVENTION.md index 26a46878f..43745628b 100644 --- a/packages/docs/CONVENTION.md +++ b/packages/docs/CONVENTION.md @@ -2,9 +2,12 @@ ## Enum Values -- Use PascalCase for all enum values, including string literal unions and - documentation snippets. This keeps the codebase and docs aligned with the - runtime values exposed by the SDK. +- Use the generated target-language member name and wire value exactly as + emitted from the GraphQL SSOT. GraphQL enum identifiers are PascalCase, while + serialized string values and TypeScript string-literal unions use the + generated lowercase/kebab-case wire values (for example, `OneTime` maps to + `'one-time'`). Do not normalize documentation snippets to PascalCase when the + runtime value is lowercase or kebab-case. - In documentation examples (e.g., `src/pages/docs/types.tsx`), declare enums before any related type aliases so readers see the enum values ahead of the structures that consume them. diff --git a/packages/docs/public/llms-full.txt b/packages/docs/public/llms-full.txt index 5372fbe94..2a729d0ad 100644 --- a/packages/docs/public/llms-full.txt +++ b/packages/docs/public/llms-full.txt @@ -3,7 +3,7 @@ > OpenIAP: Unified in-app purchase specification for iOS & Android > Documentation: https://openiap.dev > Quick Reference: https://openiap.dev/llms.txt -> Generated: 2026-07-16T23:03:55.942Z +> Generated: 2026-07-23T19:01:12.617Z ## Table of Contents 1. Installation @@ -31,22 +31,22 @@ cd ios && pod install ### Swift (iOS/macOS) ```swift // Swift Package Manager -.package(url: "https://github.com/hyodotdev/openiap.git", from: "2.4.1") +.package(url: "https://github.com/hyodotdev/openiap.git", from: "2.4.2") // CocoaPods -pod 'openiap', '~> 2.4.1' +pod 'openiap', '~> 2.4.2' ``` ### Kotlin (Android) ```kotlin // Gradle (build.gradle.kts) -implementation("io.github.hyochan.openiap:openiap-google:2.4.1") +implementation("io.github.hyochan.openiap:openiap-google:2.5.0") // For Meta Horizon OS -implementation("io.github.hyochan.openiap:openiap-google-horizon:2.4.1") +implementation("io.github.hyochan.openiap:openiap-google-horizon:2.5.0") // For Fire OS (Amazon Appstore) -implementation("io.github.hyochan.openiap:openiap-google-amazon:2.4.1") +implementation("io.github.hyochan.openiap:openiap-google-amazon:2.5.0") ``` ### Flutter @@ -55,13 +55,13 @@ flutter pub add flutter_inapp_purchase ``` ### Godot -Download `godot-iap-2.5.1.zip` from GitHub Releases, extract it to +Download `godot-iap-2.6.0.zip` from GitHub Releases, extract it to `addons/godot-iap/`, then enable the plugin in Project Settings. ### Kotlin Multiplatform ```kotlin dependencies { - implementation("io.github.hyochan:kmp-iap:2.5.1") + implementation("io.github.hyochan:kmp-iap:2.7.0") } ``` @@ -73,7 +73,7 @@ https://central.sonatype.com/artifact/io.github.hyochan/kmp-iap dotnet add package OpenIap.Maui ``` -Current NuGet package version: 1.3.1 +Current NuGet package version: 1.4.0 Requires .NET 9 or .NET 10, the MAUI workload, iOS 15.0+, and Android API 24+. diff --git a/packages/docs/public/llms.txt b/packages/docs/public/llms.txt index afa87cc74..2bf8635c0 100644 --- a/packages/docs/public/llms.txt +++ b/packages/docs/public/llms.txt @@ -3,7 +3,7 @@ > OpenIAP: Unified in-app purchase specification for iOS & Android > Documentation: https://openiap.dev > Full Reference: https://openiap.dev/llms-full.txt -> Generated: 2026-07-16T23:03:55.942Z +> Generated: 2026-07-24T00:42:40.662Z ## Installation @@ -19,14 +19,14 @@ npm install react-native-iap ### Native ```swift // Swift Package Manager -.package(url: "https://github.com/hyodotdev/openiap.git", from: "2.4.1") +.package(url: "https://github.com/hyodotdev/openiap.git", from: "2.4.2") ``` ```kotlin // Gradle -implementation("io.github.hyochan.openiap:openiap-google:2.4.1") -implementation("io.github.hyochan.openiap:openiap-google-horizon:2.4.1") -implementation("io.github.hyochan.openiap:openiap-google-amazon:2.4.1") +implementation("io.github.hyochan.openiap:openiap-google:2.5.0") +implementation("io.github.hyochan.openiap:openiap-google-horizon:2.5.0") +implementation("io.github.hyochan.openiap:openiap-google-amazon:2.5.0") ``` ```bash @@ -36,20 +36,20 @@ flutter pub add flutter_inapp_purchase ```gdscript # Godot -# Install godot-iap 2.5.1 to addons/godot-iap and enable the plugin +# Install godot-iap 2.6.0 to addons/godot-iap and enable the plugin ``` ```kotlin // Kotlin Multiplatform -implementation("io.github.hyochan:kmp-iap:2.5.1") +implementation("io.github.hyochan:kmp-iap:2.7.0") ``` ```xml - + ``` -Current NuGet package version: 1.3.1 +Current NuGet package version: 1.4.0 ## Framework Libraries diff --git a/packages/docs/src/pages/introduction.tsx b/packages/docs/src/pages/introduction.tsx index 0e4605321..44070ea16 100644 --- a/packages/docs/src/pages/introduction.tsx +++ b/packages/docs/src/pages/introduction.tsx @@ -170,14 +170,18 @@ function Introduction() {

      -              {`packages/apple/Sources/Models/Types.swift    # Swift types
      -packages/google/openiap/src/main/java/dev/hyo/openiap/Types.kt  # Kotlin types
      -src/generated/types.ts                       # TypeScript types
      -src/generated/types.dart                     # Dart types
      -src/generated/types.gd                       # GDScript types
      -libraries/maui-iap/src/OpenIap.Maui/Types.cs # C# / MAUI types`}
      +              {`packages/gql/src/generated/types.ts    # TypeScript
      +packages/gql/src/generated/Types.swift   # Swift
      +packages/gql/src/generated/Types.kt      # Kotlin
      +packages/gql/src/generated/types.dart    # Dart
      +packages/gql/src/generated/types.gd      # GDScript
      +packages/gql/src/generated/Types.cs      # C# / .NET`}
                   
      +

      + The canonical sync manifest then distributes these files to Apple, + Google, React Native, Expo, Flutter, Godot, KMP, and MAUI. +

      Native Modules

      diff --git a/packages/google/CONTRIBUTING.md b/packages/google/CONTRIBUTING.md index 7caa15f98..b5f1d1dd3 100644 --- a/packages/google/CONTRIBUTING.md +++ b/packages/google/CONTRIBUTING.md @@ -109,7 +109,7 @@ adb logcat | grep -E "OpenIap|Amazon" - All GraphQL models in `openiap/src/main/java/dev/hyo/openiap/Types.kt` are generated from the [`openiap` monorepo](https://github.com/hyodotdev/openiap/tree/main/packages/gql). When you update API behavior, adjust the upstream type generator first so the Kotlin output stays in sync across platforms. - The canonical workflow is documented in `CONVENTION.md`. Read it before touching generated models or related helpers. -- To refresh the generated file locally, run `./scripts/generate-types.sh`. If you need to experiment with manual edits, you can pass `--skip-download true` to reuse the current `Types.kt` while still applying the post-processing step, but remember that ad-hoc edits will not ship in published releases unless the upstream generator incorporates them. +- To refresh generated files locally, run `./scripts/generate-types.sh`. This compatibility entry point delegates to the complete `packages/gql` generation and manifest-backed sync; it does not support partial or ad-hoc generated-file modes. - For changes that require generator support, open an issue or pull request in the [`packages/gql`](https://github.com/hyodotdev/openiap/tree/main/packages/gql) directory of the monorepo. ## Code Style diff --git a/packages/google/CONVENTION.md b/packages/google/CONVENTION.md index b453a3fbc..477b02f37 100644 --- a/packages/google/CONVENTION.md +++ b/packages/google/CONVENTION.md @@ -44,7 +44,7 @@ operation/handler identifier that must match the schema. ## Generated GraphQL/Kotlin Models -- `openiap/src/main/java/dev/hyo/openiap/Types.kt` is auto-generated. Regenerate it with `./scripts/generate-types.sh` after changing any GraphQL schema files. +- `openiap/src/main/java/dev/hyo/openiap/Types.kt` is auto-generated. Regenerate it with `./scripts/generate-types.sh`, which delegates to the canonical `packages/gql` pipeline and sync manifest. - Never edit `Types.kt` manually. Regeneration guarantees consistency across platforms and avoids merge conflicts. - When additional parsing or conversion helpers are needed for GraphQL payloads, place them in a utility file (for example `openiap/src/main/java/dev/hyo/openiap/utils/JsonUtils.kt`). Keep all custom helpers outside of generated sources and have the hand-written code call into them. @@ -112,7 +112,7 @@ Some implementation helpers exist only on specific Android flavors: ## Regeneration Checklist -- Run `./scripts/generate-types.sh` whenever GraphQL schema definitions change. +- Run `./scripts/generate-types.sh` whenever GraphQL schema definitions change; do not add a Google-local generator or copy map. - After regenerating, run the relevant Gradle targets for every flavor: ```bash ./gradlew :openiap:compilePlayDebugKotlin diff --git a/packages/google/openiap/src/main/java/dev/hyo/openiap/Types.kt b/packages/google/openiap/src/main/java/dev/hyo/openiap/Types.kt index 7353d22a2..3bff6ec46 100644 --- a/packages/google/openiap/src/main/java/dev/hyo/openiap/Types.kt +++ b/packages/google/openiap/src/main/java/dev/hyo/openiap/Types.kt @@ -1,6 +1,6 @@ // ============================================================================ // AUTO-GENERATED TYPES — DO NOT EDIT DIRECTLY -// Run `bun run generate` after updating any *.graphql schema file. +// Refresh this file with the generated-types workflow documented for your checkout. // ============================================================================ // Suppress unchecked cast warnings for JSON Map parsing - unavoidable due to Kotlin type erasure @@ -12,8 +12,8 @@ package dev.hyo.openiap /** * Alternative billing mode for Android * Controls which billing system is used - * @deprecated Use enableBillingProgramAndroid with BillingProgramAndroid instead. * Use USER_CHOICE_BILLING for user choice billing, EXTERNAL_OFFER for alternative only. + * @deprecated Use enableBillingProgramAndroid with BillingProgramAndroid instead. */ public enum class AlternativeBillingModeAndroid(val rawValue: String) { /** @@ -23,23 +23,26 @@ public enum class AlternativeBillingModeAndroid(val rawValue: String) { /** * User choice billing - user can select between Google Play or alternative * Requires Google Play Billing Library 7.0+ - * @deprecated Use BillingProgramAndroid.USER_CHOICE_BILLING instead + * @deprecated Use BillingProgramAndroid.USER_CHOICE_BILLING instead. */ UserChoice("user-choice"), /** * Alternative billing only - no Google Play billing option * Requires Google Play Billing Library 6.2+ - * @deprecated Use BillingProgramAndroid.EXTERNAL_OFFER instead + * @deprecated Use BillingProgramAndroid.EXTERNAL_OFFER instead. */ AlternativeOnly("alternative-only"); companion object { fun fromJson(value: String): AlternativeBillingModeAndroid = when (value) { "none" -> AlternativeBillingModeAndroid.None + "NONE" -> AlternativeBillingModeAndroid.None "None" -> AlternativeBillingModeAndroid.None "user-choice" -> AlternativeBillingModeAndroid.UserChoice + "USER_CHOICE" -> AlternativeBillingModeAndroid.UserChoice "UserChoice" -> AlternativeBillingModeAndroid.UserChoice "alternative-only" -> AlternativeBillingModeAndroid.AlternativeOnly + "ALTERNATIVE_ONLY" -> AlternativeBillingModeAndroid.AlternativeOnly "AlternativeOnly" -> AlternativeBillingModeAndroid.AlternativeOnly else -> throw IllegalArgumentException("Unknown AlternativeBillingModeAndroid value: $value") } @@ -69,10 +72,13 @@ public enum class BillingChoiceImageLayoutAndroid(val rawValue: String) { companion object { fun fromJson(value: String): BillingChoiceImageLayoutAndroid = when (value) { "rectangular-four-by-one" -> BillingChoiceImageLayoutAndroid.RectangularFourByOne + "RECTANGULAR_FOUR_BY_ONE" -> BillingChoiceImageLayoutAndroid.RectangularFourByOne "RectangularFourByOne" -> BillingChoiceImageLayoutAndroid.RectangularFourByOne "rectangular-three-by-one" -> BillingChoiceImageLayoutAndroid.RectangularThreeByOne + "RECTANGULAR_THREE_BY_ONE" -> BillingChoiceImageLayoutAndroid.RectangularThreeByOne "RectangularThreeByOne" -> BillingChoiceImageLayoutAndroid.RectangularThreeByOne "rectangular-two-by-two" -> BillingChoiceImageLayoutAndroid.RectangularTwoByTwo + "RECTANGULAR_TWO_BY_TWO" -> BillingChoiceImageLayoutAndroid.RectangularTwoByTwo "RectangularTwoByTwo" -> BillingChoiceImageLayoutAndroid.RectangularTwoByTwo else -> throw IllegalArgumentException("Unknown BillingChoiceImageLayoutAndroid value: $value") } @@ -102,10 +108,13 @@ public enum class BillingChoiceScreenTypeAndroid(val rawValue: String) { companion object { fun fromJson(value: String): BillingChoiceScreenTypeAndroid = when (value) { "unspecified" -> BillingChoiceScreenTypeAndroid.Unspecified + "UNSPECIFIED" -> BillingChoiceScreenTypeAndroid.Unspecified "Unspecified" -> BillingChoiceScreenTypeAndroid.Unspecified "developer-rendered" -> BillingChoiceScreenTypeAndroid.DeveloperRendered + "DEVELOPER_RENDERED" -> BillingChoiceScreenTypeAndroid.DeveloperRendered "DeveloperRendered" -> BillingChoiceScreenTypeAndroid.DeveloperRendered "google-rendered" -> BillingChoiceScreenTypeAndroid.GoogleRendered + "GOOGLE_RENDERED" -> BillingChoiceScreenTypeAndroid.GoogleRendered "GoogleRendered" -> BillingChoiceScreenTypeAndroid.GoogleRendered else -> throw IllegalArgumentException("Unknown BillingChoiceScreenTypeAndroid value: $value") } @@ -161,16 +170,22 @@ public enum class BillingProgramAndroid(val rawValue: String) { companion object { fun fromJson(value: String): BillingProgramAndroid = when (value) { "unspecified" -> BillingProgramAndroid.Unspecified + "UNSPECIFIED" -> BillingProgramAndroid.Unspecified "Unspecified" -> BillingProgramAndroid.Unspecified "user-choice-billing" -> BillingProgramAndroid.UserChoiceBilling + "USER_CHOICE_BILLING" -> BillingProgramAndroid.UserChoiceBilling "UserChoiceBilling" -> BillingProgramAndroid.UserChoiceBilling "external-content-link" -> BillingProgramAndroid.ExternalContentLink + "EXTERNAL_CONTENT_LINK" -> BillingProgramAndroid.ExternalContentLink "ExternalContentLink" -> BillingProgramAndroid.ExternalContentLink "external-offer" -> BillingProgramAndroid.ExternalOffer + "EXTERNAL_OFFER" -> BillingProgramAndroid.ExternalOffer "ExternalOffer" -> BillingProgramAndroid.ExternalOffer "external-payments" -> BillingProgramAndroid.ExternalPayments + "EXTERNAL_PAYMENTS" -> BillingProgramAndroid.ExternalPayments "ExternalPayments" -> BillingProgramAndroid.ExternalPayments "billing-choice" -> BillingProgramAndroid.BillingChoice + "BILLING_CHOICE" -> BillingProgramAndroid.BillingChoice "BillingChoice" -> BillingProgramAndroid.BillingChoice else -> throw IllegalArgumentException("Unknown BillingProgramAndroid value: $value") } @@ -203,10 +218,13 @@ public enum class DeveloperBillingLaunchModeAndroid(val rawValue: String) { companion object { fun fromJson(value: String): DeveloperBillingLaunchModeAndroid = when (value) { "unspecified" -> DeveloperBillingLaunchModeAndroid.Unspecified + "UNSPECIFIED" -> DeveloperBillingLaunchModeAndroid.Unspecified "Unspecified" -> DeveloperBillingLaunchModeAndroid.Unspecified "launch-in-external-browser-or-app" -> DeveloperBillingLaunchModeAndroid.LaunchInExternalBrowserOrApp + "LAUNCH_IN_EXTERNAL_BROWSER_OR_APP" -> DeveloperBillingLaunchModeAndroid.LaunchInExternalBrowserOrApp "LaunchInExternalBrowserOrApp" -> DeveloperBillingLaunchModeAndroid.LaunchInExternalBrowserOrApp "caller-will-launch-link" -> DeveloperBillingLaunchModeAndroid.CallerWillLaunchLink + "CALLER_WILL_LAUNCH_LINK" -> DeveloperBillingLaunchModeAndroid.CallerWillLaunchLink "CallerWillLaunchLink" -> DeveloperBillingLaunchModeAndroid.CallerWillLaunchLink else -> throw IllegalArgumentException("Unknown DeveloperBillingLaunchModeAndroid value: $value") } @@ -236,10 +254,13 @@ public enum class DeveloperBillingTypeAndroid(val rawValue: String) { companion object { fun fromJson(value: String): DeveloperBillingTypeAndroid = when (value) { "developer-billing-type-unspecified" -> DeveloperBillingTypeAndroid.DeveloperBillingTypeUnspecified + "DEVELOPER_BILLING_TYPE_UNSPECIFIED" -> DeveloperBillingTypeAndroid.DeveloperBillingTypeUnspecified "DeveloperBillingTypeUnspecified" -> DeveloperBillingTypeAndroid.DeveloperBillingTypeUnspecified "in-app" -> DeveloperBillingTypeAndroid.InApp + "IN_APP" -> DeveloperBillingTypeAndroid.InApp "InApp" -> DeveloperBillingTypeAndroid.InApp "external-link" -> DeveloperBillingTypeAndroid.ExternalLink + "EXTERNAL_LINK" -> DeveloperBillingTypeAndroid.ExternalLink "ExternalLink" -> DeveloperBillingTypeAndroid.ExternalLink else -> throw IllegalArgumentException("Unknown DeveloperBillingTypeAndroid value: $value") } @@ -269,10 +290,13 @@ public enum class DiscountOfferType(val rawValue: String) { companion object { fun fromJson(value: String): DiscountOfferType = when (value) { "introductory" -> DiscountOfferType.Introductory + "INTRODUCTORY" -> DiscountOfferType.Introductory "Introductory" -> DiscountOfferType.Introductory "promotional" -> DiscountOfferType.Promotional + "PROMOTIONAL" -> DiscountOfferType.Promotional "Promotional" -> DiscountOfferType.Promotional "one-time" -> DiscountOfferType.OneTime + "ONE_TIME" -> DiscountOfferType.OneTime "OneTime" -> DiscountOfferType.OneTime else -> throw IllegalArgumentException("Unknown DiscountOfferType value: $value") } @@ -289,8 +313,17 @@ public enum class ErrorCode(val rawValue: String) { RemoteError("remote-error"), NetworkError("network-error"), ServiceError("service-error"), + /** + * @deprecated Use PurchaseVerificationFailed instead + */ ReceiptFailed("receipt-failed"), + /** + * @deprecated Use PurchaseVerificationFinished instead + */ ReceiptFinished("receipt-finished"), + /** + * @deprecated Use PurchaseVerificationFinishFailed instead + */ ReceiptFinishedFailed("receipt-finished-failed"), PurchaseVerificationFailed("purchase-verification-failed"), PurchaseVerificationFinished("purchase-verification-finished"), @@ -325,82 +358,121 @@ public enum class ErrorCode(val rawValue: String) { companion object { fun fromJson(value: String): ErrorCode = when (value) { "unknown" -> ErrorCode.Unknown + "UNKNOWN" -> ErrorCode.Unknown "Unknown" -> ErrorCode.Unknown "user-cancelled" -> ErrorCode.UserCancelled + "USER_CANCELLED" -> ErrorCode.UserCancelled "UserCancelled" -> ErrorCode.UserCancelled "user-error" -> ErrorCode.UserError + "USER_ERROR" -> ErrorCode.UserError "UserError" -> ErrorCode.UserError "item-unavailable" -> ErrorCode.ItemUnavailable + "ITEM_UNAVAILABLE" -> ErrorCode.ItemUnavailable "ItemUnavailable" -> ErrorCode.ItemUnavailable "remote-error" -> ErrorCode.RemoteError + "REMOTE_ERROR" -> ErrorCode.RemoteError "RemoteError" -> ErrorCode.RemoteError "network-error" -> ErrorCode.NetworkError + "NETWORK_ERROR" -> ErrorCode.NetworkError "NetworkError" -> ErrorCode.NetworkError "service-error" -> ErrorCode.ServiceError + "SERVICE_ERROR" -> ErrorCode.ServiceError "ServiceError" -> ErrorCode.ServiceError "receipt-failed" -> ErrorCode.ReceiptFailed + "RECEIPT_FAILED" -> ErrorCode.ReceiptFailed "ReceiptFailed" -> ErrorCode.ReceiptFailed "receipt-finished" -> ErrorCode.ReceiptFinished + "RECEIPT_FINISHED" -> ErrorCode.ReceiptFinished "ReceiptFinished" -> ErrorCode.ReceiptFinished "receipt-finished-failed" -> ErrorCode.ReceiptFinishedFailed + "RECEIPT_FINISHED_FAILED" -> ErrorCode.ReceiptFinishedFailed "ReceiptFinishedFailed" -> ErrorCode.ReceiptFinishedFailed "purchase-verification-failed" -> ErrorCode.PurchaseVerificationFailed + "PURCHASE_VERIFICATION_FAILED" -> ErrorCode.PurchaseVerificationFailed "PurchaseVerificationFailed" -> ErrorCode.PurchaseVerificationFailed "purchase-verification-finished" -> ErrorCode.PurchaseVerificationFinished + "PURCHASE_VERIFICATION_FINISHED" -> ErrorCode.PurchaseVerificationFinished "PurchaseVerificationFinished" -> ErrorCode.PurchaseVerificationFinished "purchase-verification-finish-failed" -> ErrorCode.PurchaseVerificationFinishFailed + "PURCHASE_VERIFICATION_FINISH_FAILED" -> ErrorCode.PurchaseVerificationFinishFailed "PurchaseVerificationFinishFailed" -> ErrorCode.PurchaseVerificationFinishFailed "not-prepared" -> ErrorCode.NotPrepared + "NOT_PREPARED" -> ErrorCode.NotPrepared "NotPrepared" -> ErrorCode.NotPrepared "not-ended" -> ErrorCode.NotEnded + "NOT_ENDED" -> ErrorCode.NotEnded "NotEnded" -> ErrorCode.NotEnded "already-owned" -> ErrorCode.AlreadyOwned + "ALREADY_OWNED" -> ErrorCode.AlreadyOwned "AlreadyOwned" -> ErrorCode.AlreadyOwned "developer-error" -> ErrorCode.DeveloperError + "DEVELOPER_ERROR" -> ErrorCode.DeveloperError "DeveloperError" -> ErrorCode.DeveloperError "billing-response-json-parse-error" -> ErrorCode.BillingResponseJsonParseError + "BILLING_RESPONSE_JSON_PARSE_ERROR" -> ErrorCode.BillingResponseJsonParseError "BillingResponseJsonParseError" -> ErrorCode.BillingResponseJsonParseError "deferred-payment" -> ErrorCode.DeferredPayment + "DEFERRED_PAYMENT" -> ErrorCode.DeferredPayment "DeferredPayment" -> ErrorCode.DeferredPayment "interrupted" -> ErrorCode.Interrupted + "INTERRUPTED" -> ErrorCode.Interrupted "Interrupted" -> ErrorCode.Interrupted "iap-not-available" -> ErrorCode.IapNotAvailable + "IAP_NOT_AVAILABLE" -> ErrorCode.IapNotAvailable "IapNotAvailable" -> ErrorCode.IapNotAvailable "purchase-error" -> ErrorCode.PurchaseError + "PURCHASE_ERROR" -> ErrorCode.PurchaseError "PurchaseError" -> ErrorCode.PurchaseError "sync-error" -> ErrorCode.SyncError + "SYNC_ERROR" -> ErrorCode.SyncError "SyncError" -> ErrorCode.SyncError "transaction-validation-failed" -> ErrorCode.TransactionValidationFailed + "TRANSACTION_VALIDATION_FAILED" -> ErrorCode.TransactionValidationFailed "TransactionValidationFailed" -> ErrorCode.TransactionValidationFailed "activity-unavailable" -> ErrorCode.ActivityUnavailable + "ACTIVITY_UNAVAILABLE" -> ErrorCode.ActivityUnavailable "ActivityUnavailable" -> ErrorCode.ActivityUnavailable "already-prepared" -> ErrorCode.AlreadyPrepared + "ALREADY_PREPARED" -> ErrorCode.AlreadyPrepared "AlreadyPrepared" -> ErrorCode.AlreadyPrepared "pending" -> ErrorCode.Pending + "PENDING" -> ErrorCode.Pending "Pending" -> ErrorCode.Pending "connection-closed" -> ErrorCode.ConnectionClosed + "CONNECTION_CLOSED" -> ErrorCode.ConnectionClosed "ConnectionClosed" -> ErrorCode.ConnectionClosed "init-connection" -> ErrorCode.InitConnection + "INIT_CONNECTION" -> ErrorCode.InitConnection "InitConnection" -> ErrorCode.InitConnection "service-disconnected" -> ErrorCode.ServiceDisconnected + "SERVICE_DISCONNECTED" -> ErrorCode.ServiceDisconnected "ServiceDisconnected" -> ErrorCode.ServiceDisconnected "service-timeout" -> ErrorCode.ServiceTimeout + "SERVICE_TIMEOUT" -> ErrorCode.ServiceTimeout "ServiceTimeout" -> ErrorCode.ServiceTimeout "query-product" -> ErrorCode.QueryProduct + "QUERY_PRODUCT" -> ErrorCode.QueryProduct "QueryProduct" -> ErrorCode.QueryProduct "sku-not-found" -> ErrorCode.SkuNotFound + "SKU_NOT_FOUND" -> ErrorCode.SkuNotFound "SkuNotFound" -> ErrorCode.SkuNotFound "sku-offer-mismatch" -> ErrorCode.SkuOfferMismatch + "SKU_OFFER_MISMATCH" -> ErrorCode.SkuOfferMismatch "SkuOfferMismatch" -> ErrorCode.SkuOfferMismatch "item-not-owned" -> ErrorCode.ItemNotOwned + "ITEM_NOT_OWNED" -> ErrorCode.ItemNotOwned "ItemNotOwned" -> ErrorCode.ItemNotOwned "billing-unavailable" -> ErrorCode.BillingUnavailable + "BILLING_UNAVAILABLE" -> ErrorCode.BillingUnavailable "BillingUnavailable" -> ErrorCode.BillingUnavailable "feature-not-supported" -> ErrorCode.FeatureNotSupported + "FEATURE_NOT_SUPPORTED" -> ErrorCode.FeatureNotSupported "FeatureNotSupported" -> ErrorCode.FeatureNotSupported "empty-sku-list" -> ErrorCode.EmptySkuList + "EMPTY_SKU_LIST" -> ErrorCode.EmptySkuList "EmptySkuList" -> ErrorCode.EmptySkuList "duplicate-purchase" -> ErrorCode.DuplicatePurchase + "DUPLICATE_PURCHASE" -> ErrorCode.DuplicatePurchase "DuplicatePurchase" -> ErrorCode.DuplicatePurchase else -> throw IllegalArgumentException("Unknown ErrorCode value: $value") } @@ -433,10 +505,13 @@ public enum class ExternalLinkLaunchModeAndroid(val rawValue: String) { fun fromJson(value: String): ExternalLinkLaunchModeAndroid = when (value) { "unspecified" -> ExternalLinkLaunchModeAndroid.Unspecified "UNSPECIFIED" -> ExternalLinkLaunchModeAndroid.Unspecified + "Unspecified" -> ExternalLinkLaunchModeAndroid.Unspecified "launch-in-external-browser-or-app" -> ExternalLinkLaunchModeAndroid.LaunchInExternalBrowserOrApp "LAUNCH_IN_EXTERNAL_BROWSER_OR_APP" -> ExternalLinkLaunchModeAndroid.LaunchInExternalBrowserOrApp + "LaunchInExternalBrowserOrApp" -> ExternalLinkLaunchModeAndroid.LaunchInExternalBrowserOrApp "caller-will-launch-link" -> ExternalLinkLaunchModeAndroid.CallerWillLaunchLink "CALLER_WILL_LAUNCH_LINK" -> ExternalLinkLaunchModeAndroid.CallerWillLaunchLink + "CallerWillLaunchLink" -> ExternalLinkLaunchModeAndroid.CallerWillLaunchLink else -> throw IllegalArgumentException("Unknown ExternalLinkLaunchModeAndroid value: $value") } } @@ -466,10 +541,13 @@ public enum class ExternalLinkTypeAndroid(val rawValue: String) { companion object { fun fromJson(value: String): ExternalLinkTypeAndroid = when (value) { "unspecified" -> ExternalLinkTypeAndroid.Unspecified + "UNSPECIFIED" -> ExternalLinkTypeAndroid.Unspecified "Unspecified" -> ExternalLinkTypeAndroid.Unspecified "link-to-digital-content-offer" -> ExternalLinkTypeAndroid.LinkToDigitalContentOffer + "LINK_TO_DIGITAL_CONTENT_OFFER" -> ExternalLinkTypeAndroid.LinkToDigitalContentOffer "LinkToDigitalContentOffer" -> ExternalLinkTypeAndroid.LinkToDigitalContentOffer "link-to-app-download" -> ExternalLinkTypeAndroid.LinkToAppDownload + "LINK_TO_APP_DOWNLOAD" -> ExternalLinkTypeAndroid.LinkToAppDownload "LinkToAppDownload" -> ExternalLinkTypeAndroid.LinkToAppDownload else -> throw IllegalArgumentException("Unknown ExternalLinkTypeAndroid value: $value") } @@ -493,6 +571,7 @@ public enum class ExternalPurchaseCustomLinkNoticeTypeIOS(val rawValue: String) companion object { fun fromJson(value: String): ExternalPurchaseCustomLinkNoticeTypeIOS = when (value) { "browser" -> ExternalPurchaseCustomLinkNoticeTypeIOS.Browser + "BROWSER" -> ExternalPurchaseCustomLinkNoticeTypeIOS.Browser "Browser" -> ExternalPurchaseCustomLinkNoticeTypeIOS.Browser else -> throw IllegalArgumentException("Unknown ExternalPurchaseCustomLinkNoticeTypeIOS value: $value") } @@ -521,8 +600,10 @@ public enum class ExternalPurchaseCustomLinkTokenTypeIOS(val rawValue: String) { companion object { fun fromJson(value: String): ExternalPurchaseCustomLinkTokenTypeIOS = when (value) { "acquisition" -> ExternalPurchaseCustomLinkTokenTypeIOS.Acquisition + "ACQUISITION" -> ExternalPurchaseCustomLinkTokenTypeIOS.Acquisition "Acquisition" -> ExternalPurchaseCustomLinkTokenTypeIOS.Acquisition "services" -> ExternalPurchaseCustomLinkTokenTypeIOS.Services + "SERVICES" -> ExternalPurchaseCustomLinkTokenTypeIOS.Services "Services" -> ExternalPurchaseCustomLinkTokenTypeIOS.Services else -> throw IllegalArgumentException("Unknown ExternalPurchaseCustomLinkTokenTypeIOS value: $value") } @@ -547,8 +628,10 @@ public enum class ExternalPurchaseNoticeAction(val rawValue: String) { companion object { fun fromJson(value: String): ExternalPurchaseNoticeAction = when (value) { "continue" -> ExternalPurchaseNoticeAction.Continue + "CONTINUE" -> ExternalPurchaseNoticeAction.Continue "Continue" -> ExternalPurchaseNoticeAction.Continue "dismissed" -> ExternalPurchaseNoticeAction.Dismissed + "DISMISSED" -> ExternalPurchaseNoticeAction.Dismissed "Dismissed" -> ExternalPurchaseNoticeAction.Dismissed else -> throw IllegalArgumentException("Unknown ExternalPurchaseNoticeAction value: $value") } @@ -581,17 +664,23 @@ public enum class IapEvent(val rawValue: String) { companion object { fun fromJson(value: String): IapEvent = when (value) { "purchase-updated" -> IapEvent.PurchaseUpdated + "PURCHASE_UPDATED" -> IapEvent.PurchaseUpdated "PurchaseUpdated" -> IapEvent.PurchaseUpdated "purchase-error" -> IapEvent.PurchaseError + "PURCHASE_ERROR" -> IapEvent.PurchaseError "PurchaseError" -> IapEvent.PurchaseError "promoted-product-ios" -> IapEvent.PromotedProductIos - "PromotedProductIos" -> IapEvent.PromotedProductIos + "PROMOTED_PRODUCT_IOS" -> IapEvent.PromotedProductIos "PromotedProductIOS" -> IapEvent.PromotedProductIos + "PromotedProductIos" -> IapEvent.PromotedProductIos "user-choice-billing-android" -> IapEvent.UserChoiceBillingAndroid + "USER_CHOICE_BILLING_ANDROID" -> IapEvent.UserChoiceBillingAndroid "UserChoiceBillingAndroid" -> IapEvent.UserChoiceBillingAndroid "developer-provided-billing-android" -> IapEvent.DeveloperProvidedBillingAndroid + "DEVELOPER_PROVIDED_BILLING_ANDROID" -> IapEvent.DeveloperProvidedBillingAndroid "DeveloperProvidedBillingAndroid" -> IapEvent.DeveloperProvidedBillingAndroid "subscription-billing-issue" -> IapEvent.SubscriptionBillingIssue + "SUBSCRIPTION_BILLING_ISSUE" -> IapEvent.SubscriptionBillingIssue "SubscriptionBillingIssue" -> IapEvent.SubscriptionBillingIssue else -> throw IllegalArgumentException("Unknown IapEvent value: $value") } @@ -611,10 +700,13 @@ public enum class IapkitClientPayloadFormat(val rawValue: String) { companion object { fun fromJson(value: String): IapkitClientPayloadFormat = when (value) { "toml" -> IapkitClientPayloadFormat.Toml + "TOML" -> IapkitClientPayloadFormat.Toml "Toml" -> IapkitClientPayloadFormat.Toml "json" -> IapkitClientPayloadFormat.Json + "JSON" -> IapkitClientPayloadFormat.Json "Json" -> IapkitClientPayloadFormat.Json "text" -> IapkitClientPayloadFormat.Text + "TEXT" -> IapkitClientPayloadFormat.Text "Text" -> IapkitClientPayloadFormat.Text else -> throw IllegalArgumentException("Unknown IapkitClientPayloadFormat value: $value") } @@ -667,22 +759,31 @@ public enum class IapkitPurchaseState(val rawValue: String) { companion object { fun fromJson(value: String): IapkitPurchaseState = when (value) { "entitled" -> IapkitPurchaseState.Entitled + "ENTITLED" -> IapkitPurchaseState.Entitled "Entitled" -> IapkitPurchaseState.Entitled "pending-acknowledgment" -> IapkitPurchaseState.PendingAcknowledgment + "PENDING_ACKNOWLEDGMENT" -> IapkitPurchaseState.PendingAcknowledgment "PendingAcknowledgment" -> IapkitPurchaseState.PendingAcknowledgment "pending" -> IapkitPurchaseState.Pending + "PENDING" -> IapkitPurchaseState.Pending "Pending" -> IapkitPurchaseState.Pending "canceled" -> IapkitPurchaseState.Canceled + "CANCELED" -> IapkitPurchaseState.Canceled "Canceled" -> IapkitPurchaseState.Canceled "expired" -> IapkitPurchaseState.Expired + "EXPIRED" -> IapkitPurchaseState.Expired "Expired" -> IapkitPurchaseState.Expired "ready-to-consume" -> IapkitPurchaseState.ReadyToConsume + "READY_TO_CONSUME" -> IapkitPurchaseState.ReadyToConsume "ReadyToConsume" -> IapkitPurchaseState.ReadyToConsume "consumed" -> IapkitPurchaseState.Consumed + "CONSUMED" -> IapkitPurchaseState.Consumed "Consumed" -> IapkitPurchaseState.Consumed "unknown" -> IapkitPurchaseState.Unknown + "UNKNOWN" -> IapkitPurchaseState.Unknown "Unknown" -> IapkitPurchaseState.Unknown "inauthentic" -> IapkitPurchaseState.Inauthentic + "INAUTHENTIC" -> IapkitPurchaseState.Inauthentic "Inauthentic" -> IapkitPurchaseState.Inauthentic else -> throw IllegalArgumentException("Unknown IapkitPurchaseState value: $value") } @@ -698,9 +799,10 @@ public enum class IapPlatform(val rawValue: String) { companion object { fun fromJson(value: String): IapPlatform = when (value) { "ios" -> IapPlatform.Ios - "Ios" -> IapPlatform.Ios "IOS" -> IapPlatform.Ios + "Ios" -> IapPlatform.Ios "android" -> IapPlatform.Android + "ANDROID" -> IapPlatform.Android "Android" -> IapPlatform.Android else -> throw IllegalArgumentException("Unknown IapPlatform value: $value") } @@ -719,14 +821,19 @@ public enum class IapStore(val rawValue: String) { companion object { fun fromJson(value: String): IapStore = when (value) { "unknown" -> IapStore.Unknown + "UNKNOWN" -> IapStore.Unknown "Unknown" -> IapStore.Unknown "apple" -> IapStore.Apple + "APPLE" -> IapStore.Apple "Apple" -> IapStore.Apple "google" -> IapStore.Google + "GOOGLE" -> IapStore.Google "Google" -> IapStore.Google "horizon" -> IapStore.Horizon + "HORIZON" -> IapStore.Horizon "Horizon" -> IapStore.Horizon "amazon" -> IapStore.Amazon + "AMAZON" -> IapStore.Amazon "Amazon" -> IapStore.Amazon else -> throw IllegalArgumentException("Unknown IapStore value: $value") } @@ -753,8 +860,10 @@ public enum class InAppMessageCategoryAndroid(val rawValue: String) { companion object { fun fromJson(value: String): InAppMessageCategoryAndroid = when (value) { "unknown-in-app-message-category-id" -> InAppMessageCategoryAndroid.UnknownInAppMessageCategoryId + "UNKNOWN_IN_APP_MESSAGE_CATEGORY_ID" -> InAppMessageCategoryAndroid.UnknownInAppMessageCategoryId "UnknownInAppMessageCategoryId" -> InAppMessageCategoryAndroid.UnknownInAppMessageCategoryId "transactional" -> InAppMessageCategoryAndroid.Transactional + "TRANSACTIONAL" -> InAppMessageCategoryAndroid.Transactional "Transactional" -> InAppMessageCategoryAndroid.Transactional else -> throw IllegalArgumentException("Unknown InAppMessageCategoryAndroid value: $value") } @@ -781,8 +890,10 @@ public enum class InAppMessageResponseCodeAndroid(val rawValue: String) { companion object { fun fromJson(value: String): InAppMessageResponseCodeAndroid = when (value) { "no-action-needed" -> InAppMessageResponseCodeAndroid.NoActionNeeded + "NO_ACTION_NEEDED" -> InAppMessageResponseCodeAndroid.NoActionNeeded "NoActionNeeded" -> InAppMessageResponseCodeAndroid.NoActionNeeded "subscription-status-updated" -> InAppMessageResponseCodeAndroid.SubscriptionStatusUpdated + "SUBSCRIPTION_STATUS_UPDATED" -> InAppMessageResponseCodeAndroid.SubscriptionStatusUpdated "SubscriptionStatusUpdated" -> InAppMessageResponseCodeAndroid.SubscriptionStatusUpdated else -> throw IllegalArgumentException("Unknown InAppMessageResponseCodeAndroid value: $value") } @@ -816,12 +927,16 @@ public enum class PaymentMode(val rawValue: String) { companion object { fun fromJson(value: String): PaymentMode = when (value) { "free-trial" -> PaymentMode.FreeTrial + "FREE_TRIAL" -> PaymentMode.FreeTrial "FreeTrial" -> PaymentMode.FreeTrial "pay-as-you-go" -> PaymentMode.PayAsYouGo + "PAY_AS_YOU_GO" -> PaymentMode.PayAsYouGo "PayAsYouGo" -> PaymentMode.PayAsYouGo "pay-up-front" -> PaymentMode.PayUpFront + "PAY_UP_FRONT" -> PaymentMode.PayUpFront "PayUpFront" -> PaymentMode.PayUpFront "unknown" -> PaymentMode.Unknown + "UNKNOWN" -> PaymentMode.Unknown "Unknown" -> PaymentMode.Unknown else -> throw IllegalArgumentException("Unknown PaymentMode value: $value") } @@ -839,12 +954,16 @@ public enum class PaymentModeIOS(val rawValue: String) { companion object { fun fromJson(value: String): PaymentModeIOS = when (value) { "empty" -> PaymentModeIOS.Empty + "EMPTY" -> PaymentModeIOS.Empty "Empty" -> PaymentModeIOS.Empty "free-trial" -> PaymentModeIOS.FreeTrial + "FREE_TRIAL" -> PaymentModeIOS.FreeTrial "FreeTrial" -> PaymentModeIOS.FreeTrial "pay-as-you-go" -> PaymentModeIOS.PayAsYouGo + "PAY_AS_YOU_GO" -> PaymentModeIOS.PayAsYouGo "PayAsYouGo" -> PaymentModeIOS.PayAsYouGo "pay-up-front" -> PaymentModeIOS.PayUpFront + "PAY_UP_FRONT" -> PaymentModeIOS.PayUpFront "PayUpFront" -> PaymentModeIOS.PayUpFront else -> throw IllegalArgumentException("Unknown PaymentModeIOS value: $value") } @@ -861,10 +980,13 @@ public enum class ProductQueryType(val rawValue: String) { companion object { fun fromJson(value: String): ProductQueryType = when (value) { "in-app" -> ProductQueryType.InApp + "IN_APP" -> ProductQueryType.InApp "InApp" -> ProductQueryType.InApp "subs" -> ProductQueryType.Subs + "SUBS" -> ProductQueryType.Subs "Subs" -> ProductQueryType.Subs "all" -> ProductQueryType.All + "ALL" -> ProductQueryType.All "All" -> ProductQueryType.All else -> throw IllegalArgumentException("Unknown ProductQueryType value: $value") } @@ -900,12 +1022,16 @@ public enum class ProductStatusAndroid(val rawValue: String) { companion object { fun fromJson(value: String): ProductStatusAndroid = when (value) { "ok" -> ProductStatusAndroid.Ok + "OK" -> ProductStatusAndroid.Ok "Ok" -> ProductStatusAndroid.Ok "not-found" -> ProductStatusAndroid.NotFound + "NOT_FOUND" -> ProductStatusAndroid.NotFound "NotFound" -> ProductStatusAndroid.NotFound "no-offers-available" -> ProductStatusAndroid.NoOffersAvailable + "NO_OFFERS_AVAILABLE" -> ProductStatusAndroid.NoOffersAvailable "NoOffersAvailable" -> ProductStatusAndroid.NoOffersAvailable "unknown" -> ProductStatusAndroid.Unknown + "UNKNOWN" -> ProductStatusAndroid.Unknown "Unknown" -> ProductStatusAndroid.Unknown else -> throw IllegalArgumentException("Unknown ProductStatusAndroid value: $value") } @@ -921,8 +1047,10 @@ public enum class ProductType(val rawValue: String) { companion object { fun fromJson(value: String): ProductType = when (value) { "in-app" -> ProductType.InApp + "IN_APP" -> ProductType.InApp "InApp" -> ProductType.InApp "subs" -> ProductType.Subs + "SUBS" -> ProductType.Subs "Subs" -> ProductType.Subs else -> throw IllegalArgumentException("Unknown ProductType value: $value") } @@ -940,12 +1068,16 @@ public enum class ProductTypeIOS(val rawValue: String) { companion object { fun fromJson(value: String): ProductTypeIOS = when (value) { "consumable" -> ProductTypeIOS.Consumable + "CONSUMABLE" -> ProductTypeIOS.Consumable "Consumable" -> ProductTypeIOS.Consumable "non-consumable" -> ProductTypeIOS.NonConsumable + "NON_CONSUMABLE" -> ProductTypeIOS.NonConsumable "NonConsumable" -> ProductTypeIOS.NonConsumable "auto-renewable-subscription" -> ProductTypeIOS.AutoRenewableSubscription + "AUTO_RENEWABLE_SUBSCRIPTION" -> ProductTypeIOS.AutoRenewableSubscription "AutoRenewableSubscription" -> ProductTypeIOS.AutoRenewableSubscription "non-renewing-subscription" -> ProductTypeIOS.NonRenewingSubscription + "NON_RENEWING_SUBSCRIPTION" -> ProductTypeIOS.NonRenewingSubscription "NonRenewingSubscription" -> ProductTypeIOS.NonRenewingSubscription else -> throw IllegalArgumentException("Unknown ProductTypeIOS value: $value") } @@ -962,10 +1094,13 @@ public enum class PurchaseState(val rawValue: String) { companion object { fun fromJson(value: String): PurchaseState = when (value) { "pending" -> PurchaseState.Pending + "PENDING" -> PurchaseState.Pending "Pending" -> PurchaseState.Pending "purchased" -> PurchaseState.Purchased + "PURCHASED" -> PurchaseState.Purchased "Purchased" -> PurchaseState.Purchased "unknown" -> PurchaseState.Unknown + "UNKNOWN" -> PurchaseState.Unknown "Unknown" -> PurchaseState.Unknown else -> throw IllegalArgumentException("Unknown PurchaseState value: $value") } @@ -980,6 +1115,7 @@ public enum class PurchaseVerificationProvider(val rawValue: String) { companion object { fun fromJson(value: String): PurchaseVerificationProvider = when (value) { "iapkit" -> PurchaseVerificationProvider.Iapkit + "IAPKIT" -> PurchaseVerificationProvider.Iapkit "Iapkit" -> PurchaseVerificationProvider.Iapkit else -> throw IllegalArgumentException("Unknown PurchaseVerificationProvider value: $value") } @@ -1009,10 +1145,13 @@ public enum class SubResponseCodeAndroid(val rawValue: String) { companion object { fun fromJson(value: String): SubResponseCodeAndroid = when (value) { "no-applicable-sub-response-code" -> SubResponseCodeAndroid.NoApplicableSubResponseCode + "NO_APPLICABLE_SUB_RESPONSE_CODE" -> SubResponseCodeAndroid.NoApplicableSubResponseCode "NoApplicableSubResponseCode" -> SubResponseCodeAndroid.NoApplicableSubResponseCode "payment-declined-due-to-insufficient-funds" -> SubResponseCodeAndroid.PaymentDeclinedDueToInsufficientFunds + "PAYMENT_DECLINED_DUE_TO_INSUFFICIENT_FUNDS" -> SubResponseCodeAndroid.PaymentDeclinedDueToInsufficientFunds "PaymentDeclinedDueToInsufficientFunds" -> SubResponseCodeAndroid.PaymentDeclinedDueToInsufficientFunds "user-ineligible" -> SubResponseCodeAndroid.UserIneligible + "USER_INELIGIBLE" -> SubResponseCodeAndroid.UserIneligible "UserIneligible" -> SubResponseCodeAndroid.UserIneligible else -> throw IllegalArgumentException("Unknown SubResponseCodeAndroid value: $value") } @@ -1038,10 +1177,13 @@ public enum class SubscriptionBillingPlanTypeIOS(val rawValue: String) { companion object { fun fromJson(value: String): SubscriptionBillingPlanTypeIOS = when (value) { "unknown" -> SubscriptionBillingPlanTypeIOS.Unknown + "UNKNOWN" -> SubscriptionBillingPlanTypeIOS.Unknown "Unknown" -> SubscriptionBillingPlanTypeIOS.Unknown "monthly" -> SubscriptionBillingPlanTypeIOS.Monthly + "MONTHLY" -> SubscriptionBillingPlanTypeIOS.Monthly "Monthly" -> SubscriptionBillingPlanTypeIOS.Monthly "up-front" -> SubscriptionBillingPlanTypeIOS.UpFront + "UP_FRONT" -> SubscriptionBillingPlanTypeIOS.UpFront "UpFront" -> SubscriptionBillingPlanTypeIOS.UpFront else -> throw IllegalArgumentException("Unknown SubscriptionBillingPlanTypeIOS value: $value") } @@ -1062,10 +1204,13 @@ public enum class SubscriptionOfferTypeIOS(val rawValue: String) { companion object { fun fromJson(value: String): SubscriptionOfferTypeIOS = when (value) { "introductory" -> SubscriptionOfferTypeIOS.Introductory + "INTRODUCTORY" -> SubscriptionOfferTypeIOS.Introductory "Introductory" -> SubscriptionOfferTypeIOS.Introductory "promotional" -> SubscriptionOfferTypeIOS.Promotional + "PROMOTIONAL" -> SubscriptionOfferTypeIOS.Promotional "Promotional" -> SubscriptionOfferTypeIOS.Promotional "win-back" -> SubscriptionOfferTypeIOS.WinBack + "WIN_BACK" -> SubscriptionOfferTypeIOS.WinBack "WinBack" -> SubscriptionOfferTypeIOS.WinBack else -> throw IllegalArgumentException("Unknown SubscriptionOfferTypeIOS value: $value") } @@ -1084,14 +1229,19 @@ public enum class SubscriptionPeriodIOS(val rawValue: String) { companion object { fun fromJson(value: String): SubscriptionPeriodIOS = when (value) { "day" -> SubscriptionPeriodIOS.Day + "DAY" -> SubscriptionPeriodIOS.Day "Day" -> SubscriptionPeriodIOS.Day "week" -> SubscriptionPeriodIOS.Week + "WEEK" -> SubscriptionPeriodIOS.Week "Week" -> SubscriptionPeriodIOS.Week "month" -> SubscriptionPeriodIOS.Month + "MONTH" -> SubscriptionPeriodIOS.Month "Month" -> SubscriptionPeriodIOS.Month "year" -> SubscriptionPeriodIOS.Year + "YEAR" -> SubscriptionPeriodIOS.Year "Year" -> SubscriptionPeriodIOS.Year "empty" -> SubscriptionPeriodIOS.Empty + "EMPTY" -> SubscriptionPeriodIOS.Empty "Empty" -> SubscriptionPeriodIOS.Empty else -> throw IllegalArgumentException("Unknown SubscriptionPeriodIOS value: $value") } @@ -1113,14 +1263,19 @@ public enum class SubscriptionPeriodUnit(val rawValue: String) { companion object { fun fromJson(value: String): SubscriptionPeriodUnit = when (value) { "day" -> SubscriptionPeriodUnit.Day + "DAY" -> SubscriptionPeriodUnit.Day "Day" -> SubscriptionPeriodUnit.Day "week" -> SubscriptionPeriodUnit.Week + "WEEK" -> SubscriptionPeriodUnit.Week "Week" -> SubscriptionPeriodUnit.Week "month" -> SubscriptionPeriodUnit.Month + "MONTH" -> SubscriptionPeriodUnit.Month "Month" -> SubscriptionPeriodUnit.Month "year" -> SubscriptionPeriodUnit.Year + "YEAR" -> SubscriptionPeriodUnit.Year "Year" -> SubscriptionPeriodUnit.Year "unknown" -> SubscriptionPeriodUnit.Unknown + "UNKNOWN" -> SubscriptionPeriodUnit.Unknown "Unknown" -> SubscriptionPeriodUnit.Unknown else -> throw IllegalArgumentException("Unknown SubscriptionPeriodUnit value: $value") } @@ -1167,18 +1322,25 @@ public enum class SubscriptionReplacementModeAndroid(val rawValue: String) { companion object { fun fromJson(value: String): SubscriptionReplacementModeAndroid = when (value) { "unknown-replacement-mode" -> SubscriptionReplacementModeAndroid.UnknownReplacementMode + "UNKNOWN_REPLACEMENT_MODE" -> SubscriptionReplacementModeAndroid.UnknownReplacementMode "UnknownReplacementMode" -> SubscriptionReplacementModeAndroid.UnknownReplacementMode "with-time-proration" -> SubscriptionReplacementModeAndroid.WithTimeProration + "WITH_TIME_PRORATION" -> SubscriptionReplacementModeAndroid.WithTimeProration "WithTimeProration" -> SubscriptionReplacementModeAndroid.WithTimeProration "charge-prorated-price" -> SubscriptionReplacementModeAndroid.ChargeProratedPrice + "CHARGE_PRORATED_PRICE" -> SubscriptionReplacementModeAndroid.ChargeProratedPrice "ChargeProratedPrice" -> SubscriptionReplacementModeAndroid.ChargeProratedPrice "charge-full-price" -> SubscriptionReplacementModeAndroid.ChargeFullPrice + "CHARGE_FULL_PRICE" -> SubscriptionReplacementModeAndroid.ChargeFullPrice "ChargeFullPrice" -> SubscriptionReplacementModeAndroid.ChargeFullPrice "without-proration" -> SubscriptionReplacementModeAndroid.WithoutProration + "WITHOUT_PRORATION" -> SubscriptionReplacementModeAndroid.WithoutProration "WithoutProration" -> SubscriptionReplacementModeAndroid.WithoutProration "deferred" -> SubscriptionReplacementModeAndroid.Deferred + "DEFERRED" -> SubscriptionReplacementModeAndroid.Deferred "Deferred" -> SubscriptionReplacementModeAndroid.Deferred "keep-existing" -> SubscriptionReplacementModeAndroid.KeepExisting + "KEEP_EXISTING" -> SubscriptionReplacementModeAndroid.KeepExisting "KeepExisting" -> SubscriptionReplacementModeAndroid.KeepExisting else -> throw IllegalArgumentException("Unknown SubscriptionReplacementModeAndroid value: $value") } @@ -1200,20 +1362,28 @@ public enum class SubscriptionState(val rawValue: String) { companion object { fun fromJson(value: String): SubscriptionState = when (value) { "active" -> SubscriptionState.Active + "ACTIVE" -> SubscriptionState.Active "Active" -> SubscriptionState.Active "in-grace-period" -> SubscriptionState.InGracePeriod + "IN_GRACE_PERIOD" -> SubscriptionState.InGracePeriod "InGracePeriod" -> SubscriptionState.InGracePeriod "in-billing-retry" -> SubscriptionState.InBillingRetry + "IN_BILLING_RETRY" -> SubscriptionState.InBillingRetry "InBillingRetry" -> SubscriptionState.InBillingRetry "expired" -> SubscriptionState.Expired + "EXPIRED" -> SubscriptionState.Expired "Expired" -> SubscriptionState.Expired "revoked" -> SubscriptionState.Revoked + "REVOKED" -> SubscriptionState.Revoked "Revoked" -> SubscriptionState.Revoked "refunded" -> SubscriptionState.Refunded + "REFUNDED" -> SubscriptionState.Refunded "Refunded" -> SubscriptionState.Refunded "paused" -> SubscriptionState.Paused + "PAUSED" -> SubscriptionState.Paused "Paused" -> SubscriptionState.Paused "unknown" -> SubscriptionState.Unknown + "UNKNOWN" -> SubscriptionState.Unknown "Unknown" -> SubscriptionState.Unknown else -> throw IllegalArgumentException("Unknown SubscriptionState value: $value") } @@ -1265,10 +1435,13 @@ public enum class WebhookEventEnvironment(val rawValue: String) { companion object { fun fromJson(value: String): WebhookEventEnvironment = when (value) { "production" -> WebhookEventEnvironment.Production + "PRODUCTION" -> WebhookEventEnvironment.Production "Production" -> WebhookEventEnvironment.Production "sandbox" -> WebhookEventEnvironment.Sandbox + "SANDBOX" -> WebhookEventEnvironment.Sandbox "Sandbox" -> WebhookEventEnvironment.Sandbox "xcode" -> WebhookEventEnvironment.Xcode + "XCODE" -> WebhookEventEnvironment.Xcode "Xcode" -> WebhookEventEnvironment.Xcode else -> throw IllegalArgumentException("Unknown WebhookEventEnvironment value: $value") } @@ -1292,10 +1465,13 @@ public enum class WebhookEventSource(val rawValue: String) { companion object { fun fromJson(value: String): WebhookEventSource = when (value) { "apple-app-store-server-notifications-v2" -> WebhookEventSource.AppleAppStoreServerNotificationsV2 + "APPLE_APP_STORE_SERVER_NOTIFICATIONS_V2" -> WebhookEventSource.AppleAppStoreServerNotificationsV2 "AppleAppStoreServerNotificationsV2" -> WebhookEventSource.AppleAppStoreServerNotificationsV2 "google-play-real-time-developer-notifications" -> WebhookEventSource.GooglePlayRealTimeDeveloperNotifications + "GOOGLE_PLAY_REAL_TIME_DEVELOPER_NOTIFICATIONS" -> WebhookEventSource.GooglePlayRealTimeDeveloperNotifications "GooglePlayRealTimeDeveloperNotifications" -> WebhookEventSource.GooglePlayRealTimeDeveloperNotifications "meta-horizon-reconciler" -> WebhookEventSource.MetaHorizonReconciler + "META_HORIZON_RECONCILER" -> WebhookEventSource.MetaHorizonReconciler "MetaHorizonReconciler" -> WebhookEventSource.MetaHorizonReconciler else -> throw IllegalArgumentException("Unknown WebhookEventSource value: $value") } @@ -1408,36 +1584,52 @@ public enum class WebhookEventType(val rawValue: String) { companion object { fun fromJson(value: String): WebhookEventType = when (value) { "subscription-started" -> WebhookEventType.SubscriptionStarted + "SUBSCRIPTION_STARTED" -> WebhookEventType.SubscriptionStarted "SubscriptionStarted" -> WebhookEventType.SubscriptionStarted "subscription-renewed" -> WebhookEventType.SubscriptionRenewed + "SUBSCRIPTION_RENEWED" -> WebhookEventType.SubscriptionRenewed "SubscriptionRenewed" -> WebhookEventType.SubscriptionRenewed "subscription-expired" -> WebhookEventType.SubscriptionExpired + "SUBSCRIPTION_EXPIRED" -> WebhookEventType.SubscriptionExpired "SubscriptionExpired" -> WebhookEventType.SubscriptionExpired "subscription-in-grace-period" -> WebhookEventType.SubscriptionInGracePeriod + "SUBSCRIPTION_IN_GRACE_PERIOD" -> WebhookEventType.SubscriptionInGracePeriod "SubscriptionInGracePeriod" -> WebhookEventType.SubscriptionInGracePeriod "subscription-in-billing-retry" -> WebhookEventType.SubscriptionInBillingRetry + "SUBSCRIPTION_IN_BILLING_RETRY" -> WebhookEventType.SubscriptionInBillingRetry "SubscriptionInBillingRetry" -> WebhookEventType.SubscriptionInBillingRetry "subscription-recovered" -> WebhookEventType.SubscriptionRecovered + "SUBSCRIPTION_RECOVERED" -> WebhookEventType.SubscriptionRecovered "SubscriptionRecovered" -> WebhookEventType.SubscriptionRecovered "subscription-canceled" -> WebhookEventType.SubscriptionCanceled + "SUBSCRIPTION_CANCELED" -> WebhookEventType.SubscriptionCanceled "SubscriptionCanceled" -> WebhookEventType.SubscriptionCanceled "subscription-uncanceled" -> WebhookEventType.SubscriptionUncanceled + "SUBSCRIPTION_UNCANCELED" -> WebhookEventType.SubscriptionUncanceled "SubscriptionUncanceled" -> WebhookEventType.SubscriptionUncanceled "subscription-revoked" -> WebhookEventType.SubscriptionRevoked + "SUBSCRIPTION_REVOKED" -> WebhookEventType.SubscriptionRevoked "SubscriptionRevoked" -> WebhookEventType.SubscriptionRevoked "subscription-price-change" -> WebhookEventType.SubscriptionPriceChange + "SUBSCRIPTION_PRICE_CHANGE" -> WebhookEventType.SubscriptionPriceChange "SubscriptionPriceChange" -> WebhookEventType.SubscriptionPriceChange "subscription-product-changed" -> WebhookEventType.SubscriptionProductChanged + "SUBSCRIPTION_PRODUCT_CHANGED" -> WebhookEventType.SubscriptionProductChanged "SubscriptionProductChanged" -> WebhookEventType.SubscriptionProductChanged "subscription-paused" -> WebhookEventType.SubscriptionPaused + "SUBSCRIPTION_PAUSED" -> WebhookEventType.SubscriptionPaused "SubscriptionPaused" -> WebhookEventType.SubscriptionPaused "subscription-resumed" -> WebhookEventType.SubscriptionResumed + "SUBSCRIPTION_RESUMED" -> WebhookEventType.SubscriptionResumed "SubscriptionResumed" -> WebhookEventType.SubscriptionResumed "purchase-refunded" -> WebhookEventType.PurchaseRefunded + "PURCHASE_REFUNDED" -> WebhookEventType.PurchaseRefunded "PurchaseRefunded" -> WebhookEventType.PurchaseRefunded "purchase-consumption-request" -> WebhookEventType.PurchaseConsumptionRequest + "PURCHASE_CONSUMPTION_REQUEST" -> WebhookEventType.PurchaseConsumptionRequest "PurchaseConsumptionRequest" -> WebhookEventType.PurchaseConsumptionRequest "test-notification" -> WebhookEventType.TestNotification + "TEST_NOTIFICATION" -> WebhookEventType.TestNotification "TestNotification" -> WebhookEventType.TestNotification else -> throw IllegalArgumentException("Unknown WebhookEventType value: $value") } @@ -1472,6 +1664,9 @@ public interface PurchaseCommon { val id: String val ids: List? val isAutoRenewing: Boolean + /** + * @deprecated Use store instead + */ val platform: IapPlatform val productId: String val purchaseState: PurchaseState @@ -1523,9 +1718,9 @@ public data class ActiveSubscription( val transactionDate: Double, val transactionId: String, /** - * @deprecated iOS only - use daysUntilExpirationIOS instead. * Whether the subscription will expire soon (within 7 days). * Consider using daysUntilExpirationIOS for more precise control. + * @deprecated iOS only - use daysUntilExpirationIOS instead. */ val willExpireSoon: Boolean? = null ) { @@ -2076,8 +2271,8 @@ public data class DiscountDisplayInfoAndroid( /** * Discount information returned from the store. - * @deprecated Use the standardized SubscriptionOffer type instead for cross-platform compatibility. * @see https://openiap.dev/docs/types/subscription-offer + * @deprecated Use the standardized SubscriptionOffer type instead for cross-platform compatibility. */ public data class DiscountIOS( val identifier: String, @@ -2197,7 +2392,9 @@ public data class DiscountOffer( */ val rentalDetailsAndroid: RentalDetailsAndroid? = null, /** - * Type of discount offer + * Offer category. DiscountOffer currently represents Android one-time product + * offers and is populated as OneTime. Introductory and Promotional are used by + * SubscriptionOffer. */ val type: DiscountOfferType, /** @@ -2253,8 +2450,8 @@ public data class DiscountOffer( /** * iOS DiscountOffer (output type). - * @deprecated Use the standardized SubscriptionOffer type instead for cross-platform compatibility. * @see https://openiap.dev/docs/types/subscription-offer + * @deprecated Use the standardized SubscriptionOffer type instead for cross-platform compatibility. */ public data class DiscountOfferIOS( /** @@ -2327,8 +2524,8 @@ public data class EntitlementIOS( /** * External offer availability result (Android) - * @deprecated Use BillingProgramAvailabilityResultAndroid with isBillingProgramAvailableAsync instead * Available in Google Play Billing Library 6.2.0+, deprecated in 8.2.0 + * @deprecated Use BillingProgramAvailabilityResultAndroid with isBillingProgramAvailableAsync instead */ public data class ExternalOfferAvailabilityResultAndroid( /** @@ -2353,8 +2550,8 @@ public data class ExternalOfferAvailabilityResultAndroid( /** * External offer reporting details (Android) - * @deprecated Use BillingProgramReportingDetailsAndroid with createBillingProgramReportingDetailsAsync instead * Available in Google Play Billing Library 6.2.0+, deprecated in 8.2.0 + * @deprecated Use BillingProgramReportingDetailsAndroid with createBillingProgramReportingDetailsAsync instead */ public data class ExternalOfferReportingDetailsAndroid( /** @@ -2857,8 +3054,8 @@ public data class ProductAndroid( /** * One-time purchase offer details (Android). * Available in Google Play Billing Library 8.0+ - * @deprecated Use the standardized DiscountOffer type for Android one-time offers. * @see https://openiap.dev/docs/types/discount-offer + * @deprecated Use the standardized DiscountOffer type for Android one-time offers. */ public data class ProductAndroidOneTimePurchaseOfferDetail( /** @@ -3044,8 +3241,7 @@ public data class ProductSubscriptionAndroid( /** * Legacy nullable compatibility field. Google Play does not populate one-time * purchase offer details for subscription products. - * @deprecated One-time offers belong to ProductAndroid.discountOffers; - * subscriptions use subscriptionOffers. + * @deprecated One-time offers belong to ProductAndroid.discountOffers; subscriptions use subscriptionOffers. */ val oneTimePurchaseOfferDetailsAndroid: List? = null, override val platform: IapPlatform = IapPlatform.Android, @@ -3118,8 +3314,8 @@ public data class ProductSubscriptionAndroid( /** * Subscription offer details (Android). - * @deprecated Use the standardized SubscriptionOffer type instead for cross-platform compatibility. * @see https://openiap.dev/docs/types/subscription-offer + * @deprecated Use the standardized SubscriptionOffer type instead for cross-platform compatibility. */ public data class ProductSubscriptionAndroidOfferDetails( val basePlanId: String, @@ -3297,6 +3493,9 @@ public data class PurchaseAndroid( * Available in Google Play Billing Library 5.0+ */ val pendingPurchaseUpdateAndroid: PendingPurchaseUpdateAndroid? = null, + /** + * @deprecated Use store instead + */ override val platform: IapPlatform, override val productId: String, override val purchaseState: PurchaseState, @@ -3468,6 +3667,9 @@ public data class PurchaseIOS( val originalTransactionDateIOS: Double? = null, val originalTransactionIdentifierIOS: String? = null, val ownershipTypeIOS: String? = null, + /** + * @deprecated Use store instead + */ override val platform: IapPlatform, override val productId: String, override val purchaseState: PurchaseState, @@ -4062,8 +4264,8 @@ public data class SubscriptionOffer( /** * iOS subscription offer details. - * @deprecated Use the standardized SubscriptionOffer type instead for cross-platform compatibility. * @see https://openiap.dev/docs/types/subscription-offer + * @deprecated Use the standardized SubscriptionOffer type instead for cross-platform compatibility. */ public data class SubscriptionOfferIOS( val displayPrice: String, @@ -4889,8 +5091,8 @@ public data class InitConnectionConfig( /** * Alternative billing mode for Android * If not specified, defaults to NONE (standard Google Play billing) - * @deprecated Use enableBillingProgramAndroid instead. * Use USER_CHOICE_BILLING for user choice billing, EXTERNAL_OFFER for alternative only. + * @deprecated Use enableBillingProgramAndroid instead. */ val alternativeBillingModeAndroid: AlternativeBillingModeAndroid? = null, /** @@ -5227,7 +5429,14 @@ public data class RequestPurchaseIosProps( public data class RequestPurchaseProps( val request: Request, + /** + * Explicit purchase type hint (defaults to in-app) + */ val type: ProductQueryType, + /** + * This flag only logs debug info and has no effect on the purchase flow. + * @deprecated Use enableBillingProgramAndroid in InitConnectionConfig instead. + */ val useAlternativeBilling: Boolean? = null ) { init { @@ -5276,7 +5485,13 @@ public data class RequestPurchaseProps( } sealed class Request { + /** + * Per-platform purchase request props + */ data class Purchase(val value: RequestPurchasePropsByPlatforms) : Request() + /** + * Per-platform subscription request props + */ data class Subscription(val value: RequestSubscriptionPropsByPlatforms) : Request() } } @@ -5359,7 +5574,7 @@ public data class RequestSubscriptionAndroidProps( val purchaseToken: String? = null, /** * Replacement mode for subscription changes - * @deprecated Use subscriptionProductReplacementParams instead for item-level replacement (8.1.0+) + * @deprecated Use subscriptionProductReplacementParams instead for item-level replacement (8.1.0+). */ val replacementMode: Int? = null, /** @@ -6177,10 +6392,8 @@ public interface MutationResolver { /** * Buy the currently promoted product. * - * @deprecated Use promotedProductListenerIOS to receive the productId, - * then call requestPurchase with that SKU instead. In StoreKit 2, - * promoted products can be purchased directly via the standard purchase flow. * See: https://openiap.dev/docs/apis/ios/request-purchase-on-promoted-product-ios + * @deprecated Use promotedProductListenerIOS to receive the productId, then call requestPurchase with that SKU instead. In StoreKit 2, promoted products can be purchased directly via the standard purchase flow. */ suspend fun requestPurchaseOnPromotedProductIOS(): Boolean /** @@ -6209,6 +6422,7 @@ public interface MutationResolver { * Call this after a deliberate customer interaction before linking out to external purchases. * Reference: https://developer.apple.com/documentation/storekit/externalpurchasecustomlink/shownotice(type:) * See: https://openiap.dev/docs/apis/ios/show-external-purchase-custom-link-notice-ios + * Parameter noticeType: Notice type determining the style of disclosure */ suspend fun showExternalPurchaseCustomLinkNoticeIOS(noticeType: ExternalPurchaseCustomLinkNoticeTypeIOS): ExternalPurchaseCustomLinkNoticeResultIOS /** @@ -6233,6 +6447,7 @@ public interface MutationResolver { /** * Deprecated. Validate purchase receipts with the configured providers — use verifyPurchase instead. * See: https://openiap.dev/docs/features/validation#verify-purchase + * @deprecated Use verifyPurchase */ suspend fun validateReceipt(options: VerifyPurchaseProps): VerifyPurchaseResult /** @@ -6308,6 +6523,7 @@ public interface QueryResolver { * Use this token to report transactions made through ExternalPurchaseCustomLink. * Reference: https://developer.apple.com/documentation/storekit/externalpurchasecustomlink/token(for:) * See: https://openiap.dev/docs/apis/ios/get-external-purchase-custom-link-token-ios + * Parameter tokenType: Token type: acquisition (new customers) or services (existing customers) */ suspend fun getExternalPurchaseCustomLinkTokenIOS(tokenType: ExternalPurchaseCustomLinkTokenTypeIOS): ExternalPurchaseCustomLinkTokenResultIOS /** @@ -6336,6 +6552,7 @@ public interface QueryResolver { * Deprecated. Get the current App Store storefront ISO 3166-1 alpha-3 country * code — use cross-platform getStorefront instead. * See: https://openiap.dev/docs/apis/ios/get-storefront-ios + * @deprecated Use getStorefront */ suspend fun getStorefrontIOS(): String /** @@ -6378,6 +6595,7 @@ public interface QueryResolver { /** * Deprecated. Legacy App Store receipt validation — use verifyPurchase instead. * See: https://openiap.dev/docs/apis/ios/validate-receipt-ios + * @deprecated Use verifyPurchase */ suspend fun validateReceiptIOS(options: VerifyPurchaseProps): VerifyPurchaseResultIOS } diff --git a/packages/google/scripts/generate-types.sh b/packages/google/scripts/generate-types.sh index deb79b848..dead7fc7e 100755 --- a/packages/google/scripts/generate-types.sh +++ b/packages/google/scripts/generate-types.sh @@ -7,11 +7,8 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" MONOREPO_ROOT="$(cd "${REPO_ROOT}/../.." && pwd)" -# Source and target paths +# Canonical generation path GQL_DIR="${MONOREPO_ROOT}/packages/gql" -SOURCE_FILE="${GQL_DIR}/src/generated/Types.kt" -TARGET_DIR="${REPO_ROOT}/openiap/src/main/java/dev/hyo/openiap" -TARGET_FILE="${TARGET_DIR}/Types.kt" # Check if gql package exists if [[ ! -d "$GQL_DIR" ]]; then @@ -20,239 +17,8 @@ if [[ ! -d "$GQL_DIR" ]]; then exit 1 fi -# Generate types in gql package first -echo "📦 Generating Kotlin types in gql package..." +# Generate and sync through the GQL package. Its sync step owns the Google +# target mapping and invokes the canonical, fail-closed Kotlin post-processor. +echo "📦 Generating and syncing types through the GQL package..." cd "$GQL_DIR" -bun run generate:kotlin - -# Check if source file was generated -if [[ ! -f "$SOURCE_FILE" ]]; then - echo "Error: Types.kt not found at $SOURCE_FILE" >&2 - echo "Generation may have failed" >&2 - exit 1 -fi - -# Copy to android package -echo "📋 Copying Types.kt to android package..." -mkdir -p "${TARGET_DIR}" -cp "${SOURCE_FILE}" "${TARGET_FILE}" - -# Post-process the file (same as original script) -echo "🔧 Post-processing Types.kt..." -TARGET_FILE="${TARGET_FILE}" python3 <<'PY' -from pathlib import Path -import os -import re - -target = Path(os.environ["TARGET_FILE"]) -text = target.read_text() - -lines = text.splitlines() - -def first_index(predicate): - for idx, line in enumerate(lines): - if predicate(line): - return idx - return None - - -package_idx = first_index(lambda line: line.startswith('package ')) - -annotation_indices = [idx for idx, line in enumerate(lines) if line.startswith('@file:')] - -if package_idx is None: - insert_idx = annotation_indices[0] + 1 if annotation_indices else 0 - lines.insert(insert_idx, 'package dev.hyo.openiap') - package_idx = insert_idx -else: - lines[package_idx] = 'package dev.hyo.openiap' - -if annotation_indices and annotation_indices[0] > package_idx: - annotation_block = [lines[idx] for idx in annotation_indices] - for idx in reversed(annotation_indices): - lines.pop(idx) - package_idx = first_index(lambda line: line.startswith('package ')) - for offset, line in enumerate(annotation_block): - lines.insert(package_idx + offset, line) - package_idx += len(annotation_block) - -text = '\n'.join(lines) - -# Kotlin enums that declare a companion object require a trailing semicolon -enum_pattern = re.compile(r"(\n\s*\w+\([^)]*\))\n\n(\s+companion object)") -text = enum_pattern.sub(lambda m: f"{m.group(1)};\n\n{m.group(2)}", text) - -# Ensure data classes implementing shared interfaces mark interface properties with override -class_pattern = re.compile( - r"public data class [^(]+\((?P.*?)\)\s*:\s*(?P[^\{]+)\{", - re.S, -) - -product_props = { - "currency", - "debugDescription", - "description", - "displayName", - "displayPrice", - "id", - "platform", - "price", - "title", - "type", -} - -purchase_props = { - "currentPlanId", - "id", - "ids", - "isAutoRenewing", - "platform", - "productId", - "purchaseState", - "purchaseToken", - "quantity", - "transactionDate", -} - -def needs_product_common(interfaces): - return any(name in interfaces for name in ("ProductCommon", "Product", "ProductSubscription")) - - -def needs_purchase_common(interfaces): - return any(name in interfaces for name in ("PurchaseCommon", "Purchase")) - - -def patch_class(match): - body = match.group("body") - raw_interfaces = match.group("interfaces") - interfaces = {token.strip() for token in raw_interfaces.replace("\n", " ").split(",")} - - override_targets = set() - if needs_product_common(interfaces): - override_targets.update(product_props) - if needs_purchase_common(interfaces): - override_targets.update(purchase_props) - - if not override_targets: - return match.group(0) - - prop_pattern = re.compile(r"(^\s*)(val|var)\s+(\w+)(.*)$", re.M) - - def replace_prop(prop_match): - indent, keyword, name, rest = prop_match.groups() - if name not in override_targets: - return prop_match.group(0) - # Avoid double prefixing if the generator ever adds override in the future - if keyword.startswith("override"): - return prop_match.group(0) - return f"{indent}override {keyword} {name}{rest}" - - patched_body = prop_pattern.sub(replace_prop, body) - return match.group(0).replace(body, patched_body) - - -text = class_pattern.sub(patch_class, text) - -lines = text.splitlines() - -pattern1 = re.compile(r'(.)([A-Z][a-z0-9]+)') -pattern2 = re.compile(r'([a-z0-9])([A-Z])') - - -def camel_to_kebab(name: str) -> str: - s1 = pattern1.sub(r'\1-\2', name) - s2 = pattern2.sub(r'\1-\2', s1) - return s2.replace('_', '-').lower() - - -i = 0 -while i < len(lines): - line = lines[i] - header_match = re.match(r'^public enum class (\w+)\(val rawValue: String\) \{$', line) - if not header_match: - i += 1 - continue - enum_name = header_match.group(1) - - constant_indices = [] - j = i + 1 - while j < len(lines): - constant_indices.append(j) - if lines[j].strip().endswith(';'): - break - j += 1 - if not constant_indices: - i = j - continue - - constants = [] - for idx in constant_indices: - const_line = lines[idx] - match = re.match(r'^(\s*)(\w+)\("([^"]+)"\)(,|;)$', const_line) - if not match: - continue - indent, name, old_raw, trailing = match.groups() - new_raw = camel_to_kebab(name) - constants.append( - { - "index": idx, - "indent": indent, - "name": name, - "old_raw": old_raw, - "new_raw": new_raw, - "trailing": trailing, - } - ) - if old_raw != new_raw: - lines[idx] = f'{indent}{name}("{new_raw}"){trailing}' - - k = j + 1 - while k < len(lines) and 'when (value)' not in lines[k]: - k += 1 - if k >= len(lines): - i = j - continue - - case_start = k + 1 - else_idx = case_start - while else_idx < len(lines) and 'else ->' not in lines[else_idx]: - else_idx += 1 - if else_idx >= len(lines): - i = j - continue - - while case_start < else_idx and not lines[case_start].strip(): - case_start += 1 - if case_start >= else_idx: - i = else_idx - continue - - indent_match = re.match(r'^(\s*)', lines[case_start]) - case_indent = indent_match.group(1) if indent_match else ' ' * 12 - - new_case_lines = [] - for const in constants: - seen = set() - candidates = [const["new_raw"], const["old_raw"], const["name"]] - if const["name"].endswith("Ios"): - ios_upper = const["name"][:-3] + "IOS" - if ios_upper: - candidates.append(ios_upper) - for candidate in candidates: - if candidate and candidate not in seen: - new_case_lines.append( - f'{case_indent}"{candidate}" -> {enum_name}.{const["name"]}' - ) - seen.add(candidate) - - lines[case_start:else_idx] = new_case_lines - i = else_idx - -text = '\n'.join(lines) -if not text.endswith('\n'): - text += '\n' - -target.write_text(text) -PY - -echo "✅ Types.kt updated at ${TARGET_FILE}" +bun run generate diff --git a/packages/google/scripts/post-process-types.sh b/packages/google/scripts/post-process-types.sh deleted file mode 100755 index f7cbb3162..000000000 --- a/packages/google/scripts/post-process-types.sh +++ /dev/null @@ -1,204 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -# Post-process Types.kt after it's copied from gql package - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" -TARGET_FILE="${REPO_ROOT}/openiap/src/main/java/dev/hyo/openiap/Types.kt" - -if [[ ! -f "$TARGET_FILE" ]]; then - echo "⚠️ Types.kt not found at $TARGET_FILE" - exit 0 -fi - -echo "🔧 Post-processing Types.kt..." - -TARGET_FILE="${TARGET_FILE}" python3 <<'PY' -from pathlib import Path -import os -import re - -target = Path(os.environ["TARGET_FILE"]) -text = target.read_text() - -lines = text.splitlines() - -def first_index(predicate): - for idx, line in enumerate(lines): - if predicate(line): - return idx - return None - -# Fix package declaration -package_idx = first_index(lambda line: line.startswith('package ')) -annotation_indices = [idx for idx, line in enumerate(lines) if line.startswith('@file:')] - -if package_idx is None: - insert_idx = annotation_indices[0] + 1 if annotation_indices else 0 - lines.insert(insert_idx, 'package dev.hyo.openiap') - package_idx = insert_idx -else: - lines[package_idx] = 'package dev.hyo.openiap' - -if annotation_indices and annotation_indices[0] > package_idx: - annotation_block = [lines[idx] for idx in annotation_indices] - for idx in reversed(annotation_indices): - lines.pop(idx) - package_idx = first_index(lambda line: line.startswith('package ')) - for offset, line in enumerate(annotation_block): - lines.insert(package_idx + offset, line) - package_idx += len(annotation_block) - -text = '\n'.join(lines) - -# Kotlin enums that declare a companion object require a trailing semicolon -enum_pattern = re.compile(r"(\n\s*\w+\([^)]*\))\n\n(\s+companion object)") -text = enum_pattern.sub(lambda m: f"{m.group(1)};\n\n{m.group(2)}", text) - -# Ensure data classes implementing shared interfaces mark interface properties with override -class_pattern = re.compile( - r"public data class [^(]+\((?P.*?)\)\s*:\s*(?P[^\{]+)\{", - re.S, -) - -product_props = { - "currency", "debugDescription", "description", "displayName", - "displayPrice", "id", "platform", "price", "title", "type", -} - -purchase_props = { - "currentPlanId", "id", "ids", "isAutoRenewing", "platform", - "productId", "purchaseState", "purchaseToken", "quantity", "transactionDate", -} - -def needs_product_common(interfaces): - return any(name in interfaces for name in ("ProductCommon", "Product", "ProductSubscription")) - -def needs_purchase_common(interfaces): - return any(name in interfaces for name in ("PurchaseCommon", "Purchase")) - -def patch_class(match): - body = match.group("body") - raw_interfaces = match.group("interfaces") - interfaces = {token.strip() for token in raw_interfaces.replace("\n", " ").split(",")} - - override_targets = set() - if needs_product_common(interfaces): - override_targets.update(product_props) - if needs_purchase_common(interfaces): - override_targets.update(purchase_props) - - if not override_targets: - return match.group(0) - - prop_pattern = re.compile(r"(^\s*)(val|var)\s+(\w+)(.*)$", re.M) - - def replace_prop(prop_match): - indent, keyword, name, rest = prop_match.groups() - if name not in override_targets: - return prop_match.group(0) - if keyword.startswith("override"): - return prop_match.group(0) - return f"{indent}override {keyword} {name}{rest}" - - patched_body = prop_pattern.sub(replace_prop, body) - return match.group(0).replace(body, patched_body) - -text = class_pattern.sub(patch_class, text) -lines = text.splitlines() - -# Fix enum raw values -pattern1 = re.compile(r'(.)([A-Z][a-z0-9]+)') -pattern2 = re.compile(r'([a-z0-9])([A-Z])') - -def camel_to_kebab(name: str) -> str: - s1 = pattern1.sub(r'\1-\2', name) - s2 = pattern2.sub(r'\1-\2', s1) - return s2.replace('_', '-').lower() - -i = 0 -while i < len(lines): - line = lines[i] - header_match = re.match(r'^public enum class (\w+)\(val rawValue: String\) \{$', line) - if not header_match: - i += 1 - continue - enum_name = header_match.group(1) - - constant_indices = [] - j = i + 1 - while j < len(lines): - constant_indices.append(j) - if lines[j].strip().endswith(';'): - break - j += 1 - if not constant_indices: - i = j - continue - - constants = [] - for idx in constant_indices: - const_line = lines[idx] - match = re.match(r'^(\s*)(\w+)\("([^"]+)"\)(,|;)$', const_line) - if not match: - continue - indent, name, old_raw, trailing = match.groups() - new_raw = camel_to_kebab(name) - constants.append({ - "index": idx, "indent": indent, "name": name, - "old_raw": old_raw, "new_raw": new_raw, "trailing": trailing, - }) - if old_raw != new_raw: - lines[idx] = f'{indent}{name}("{new_raw}"){trailing}' - - k = j + 1 - while k < len(lines) and 'when (value)' not in lines[k]: - k += 1 - if k >= len(lines): - i = j - continue - - case_start = k + 1 - else_idx = case_start - while else_idx < len(lines) and 'else ->' not in lines[else_idx]: - else_idx += 1 - if else_idx >= len(lines): - i = j - continue - - while case_start < else_idx and not lines[case_start].strip(): - case_start += 1 - if case_start >= else_idx: - i = else_idx - continue - - indent_match = re.match(r'^(\s*)', lines[case_start]) - case_indent = indent_match.group(1) if indent_match else ' ' * 12 - - new_case_lines = [] - for const in constants: - seen = set() - candidates = [const["new_raw"], const["old_raw"], const["name"]] - if const["name"].endswith("Ios"): - ios_upper = const["name"][:-3] + "IOS" - if ios_upper: - candidates.append(ios_upper) - for candidate in candidates: - if candidate and candidate not in seen: - new_case_lines.append( - f'{case_indent}"{candidate}" -> {enum_name}.{const["name"]}' - ) - seen.add(candidate) - - lines[case_start:else_idx] = new_case_lines - i = else_idx - -text = '\n'.join(lines) -if not text.endswith('\n'): - text += '\n' - -target.write_text(text) -PY - -echo "✅ Post-processing complete" diff --git a/packages/gql/.github/workflows/generate-types.yml b/packages/gql/.github/workflows/generate-types.yml deleted file mode 100644 index 9a4c0f8c8..000000000 --- a/packages/gql/.github/workflows/generate-types.yml +++ /dev/null @@ -1,32 +0,0 @@ -name: Generate Types - -on: - push: - branches: - - main - pull_request: - branches: - - main - -permissions: - contents: read - -jobs: - verify: - runs-on: ubuntu-latest - steps: - - name: Checkout repository - uses: actions/checkout@v4 - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version: 20 - cache: npm - - name: Install dependencies - run: npm ci - - name: Regenerate types - run: npm run generate - - name: Ensure workspace is clean - run: | - git status --short - git diff --exit-code diff --git a/packages/gql/.github/workflows/release-types.yml b/packages/gql/.github/workflows/release-types.yml deleted file mode 100644 index de8709799..000000000 --- a/packages/gql/.github/workflows/release-types.yml +++ /dev/null @@ -1,60 +0,0 @@ -name: Release Types - -on: - workflow_dispatch: - inputs: - version: - description: 'Release version (e.g. 1.1.0)' - required: true - type: string - -permissions: - contents: write - -jobs: - release: - runs-on: ubuntu-latest - steps: - - name: Checkout repository - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - name: Validate version input - run: | - set -euo pipefail - VERSION="${{ inputs.version }}" - if [[ "$VERSION" == v* ]]; then - echo "Release version must not start with 'v'." - exit 1 - fi - if [[ ! "$VERSION" =~ ^[0-9]+(\.[0-9]+){2}(-[0-9A-Za-z.-]+)?$ ]]; then - echo "Release version must follow semantic versioning (e.g. 1.2.3)." - exit 1 - fi - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version: 20 - cache: npm - - name: Install dependencies - run: npm ci - - name: Regenerate types - run: npm run generate - - name: Package artifacts - run: | - mkdir -p artifacts - zip -j artifacts/openiap-typescript.zip src/generated/types.ts - zip -j artifacts/openiap-dart.zip src/generated/types.dart - zip -j artifacts/openiap-kotlin.zip src/generated/Types.kt - zip -j artifacts/openiap-swift.zip src/generated/Types.swift - - name: Publish release - uses: softprops/action-gh-release@v2 - with: - tag_name: ${{ inputs.version }} - name: ${{ inputs.version }} - files: | - artifacts/openiap-typescript.zip - artifacts/openiap-dart.zip - artifacts/openiap-kotlin.zip - artifacts/openiap-swift.zip - generate_release_notes: true diff --git a/packages/gql/.gitignore b/packages/gql/.gitignore index 4b869dc6c..17119b9bc 100644 --- a/packages/gql/.gitignore +++ b/packages/gql/.gitignore @@ -1,7 +1,3 @@ node_modules/ .vscode/ -generators/dart/.dart_tool/ -generators/dart/build/ -generators/dart/lib/generated/ -generators/swift/Generated/ .claude diff --git a/packages/gql/CONVENTION.md b/packages/gql/CONVENTION.md index 75526b70b..5765e97b9 100644 --- a/packages/gql/CONVENTION.md +++ b/packages/gql/CONVENTION.md @@ -4,6 +4,19 @@ This repo standardizes schema and identifier naming to improve clarity across pl ## Files +- `schema-files.mjs` is the ordered production schema inventory SSOT. Generator + code must import it directly. Do not add parallel schema lists or external + generator manifests. +- `schema-source-utils.mjs` owns source identity normalization and block-string + line detection shared by every SDL metadata extractor. +- `schema-markers.mjs` is the only parser for `# Future` and `# => Union`. +- `schema-deprecations.mjs` is the only extractor and validator for canonical + deprecation metadata. +- `custom-input-contracts.ts` is the typed shape/default SSOT for every input + that a generator projects or aliases specially. Plugins must not maintain + parallel field lists. +- `generated-sync-manifest.mjs` is the only generated source/target path map + used by platform sync and commit-time drift checks. - `src/type.graphql`: common cross‑platform SDL only. - `src/type-ios.graphql`: iOS‑specific SDL only. - `src/type-android.graphql`: Android‑specific SDL only. @@ -55,6 +68,11 @@ This repo standardizes schema and identifier naming to improve clarity across pl ## Unions - Cross‑platform unions combine platform types (e.g., `Product = ProductAndroid | ProductIOS`). +- `ProductOrSubscription` intentionally composes the generated `Product` and + `ProductSubscription` union wrappers. This nested-union form is an OpenIAP + code-generation DSL extension, not a portable executable GraphQL service + schema. Use `bun run generate`; do not feed these SDL files directly to + general-purpose client generators. - When a wrapper object should behave like a union in generated code (e.g., `FetchProductsResult`, `RequestPurchaseResult`), precede the type definition with a `# => Union` comment in the SDL: @@ -67,9 +85,12 @@ This repo standardizes schema and identifier naming to improve clarity across pl } ``` - The codegen scripts detect this marker and flatten the wrapper into the - appropriate union type in TypeScript/Dart/Swift/Kotlin outputs while keeping - the SDL schema intact. + A marked wrapper must be a non-root object with at least one field, and every + field must be nullable so exactly one result branch can be represented. + Query, Mutation, Subscription, empty wrappers, and wrappers with required + fields are rejected. The shared transformer then flattens the wrapper into + the appropriate union type in TypeScript/Dart/Swift/Kotlin outputs while + keeping the SDL schema intact. - Only `*Args` wrapper inputs (and `VoidResult`) are collapsed to inline scalars in generated clients. Structural wrappers (e.g., @@ -100,10 +121,18 @@ This repo standardizes schema and identifier naming to improve clarity across pl - Enum values are API‑visible; changing them is a breaking change. - Keep platform suffixes consistent to avoid ambiguity in codegen and resolvers. +- Use standard `@deprecated(reason: "...")` only on fields, arguments, input + fields, and enum values. Named types use the project-scoped + `@openiapDeprecated(reason: "...")` directive declared in `schema.graphql`. + Descriptions must not duplicate either directive as an `@deprecated` tag. + When an object implements a deprecated interface field, the interface owns + the canonical reason, but every concrete field must repeat that exact + directive because GraphQL introspection does not inherit field metadata. + The IR transformer rejects an omitted or conflicting concrete projection + and emits only one generated deprecation tag per concrete field. - Resolver fields (Query/Mutation) model asynchronous behavior. The docs refer to these as `Future`. Use a `# Future` inline comment in the SDL to make that - intent explicit for documentation tooling, even though the generated - TypeScript types currently expose their raw GraphQL types. + intent explicit for documentation tooling and generated Promise signatures. - When feeding new APIs into the openiap.dev docs, always add this `# Future` comment so the codegen post-processing rewrites the generated types to return `Promise<…>` and the documentation stays accurate. @@ -112,24 +141,28 @@ This repo standardizes schema and identifier naming to improve clarity across pl ## Code Generation Architecture -The GQL package uses an **IR-based (Intermediate Representation)** code generation system. +The GQL package uses a guarded TypeScript lane and an IR-based native/framework +lane over the same schema inventory and contract metadata. ### Generation Flow ```text GraphQL Schema (src/*.graphql) ↓ - [1] Parser (codegen/core/parser.ts) + Inventory + metadata + custom-input contracts ↓ - [2] Transformer → IR (codegen/core/transformer.ts) - ↓ - [3] Language Plugins (codegen/plugins/*.ts) - ↓ - Generated Files (src/generated/*) - ↓ - [4] Sync (scripts/sync-to-platforms.mjs) - ↓ - Platform Packages (packages/apple, packages/google) + ┌────────────────────────────┬─────────────────────────────┐ + │ TypeScript │ Native/framework languages │ + │ graphql-codegen │ strict parser → IR │ + │ + guarded post-processor │ → Swift/Kotlin/Dart/ │ + │ │ GDScript/C# plugins │ + └────────────────────────────┴─────────────────────────────┘ + ↓ + Generated Files (src/generated/*) + ↓ + generated-sync-manifest.mjs → sync-to-platforms.mjs + ↓ + Apple, Google, RN, Expo, Flutter, Godot, KMP, and MAUI copies ``` ### Directory Structure @@ -141,6 +174,7 @@ codegen/ │ ├── types.ts # IR type definitions │ ├── parser.ts # GraphQL schema parser │ ├── transformer.ts # AST → IR transformer +│ ├── generated-header.ts # Shared generated-file banner │ └── utils.ts # Case conversion, keyword escaping └── plugins/ ├── base-plugin.ts # Abstract base class @@ -178,7 +212,8 @@ codegen/ # Generate all platform types bun run generate -# Generate specific platform +# Diagnostic single-plugin generation (always finish with `bun run generate` +# before committing so every manifest target is synchronized) bun run generate:swift bun run generate:kotlin bun run generate:dart diff --git a/packages/gql/README.md b/packages/gql/README.md index 8905a0563..696f27c47 100644 --- a/packages/gql/README.md +++ b/packages/gql/README.md @@ -18,10 +18,22 @@ files live in `src/` and are split into common (`type.graphql`, `api.graphql`), taxonomy (`error.graphql`), and platform-specific (`*-ios.graphql`, `*-android.graphql`) definitions. -To keep every consumer in sync, code generation helpers are provided for -TypeScript, Swift, Kotlin, Dart, GDScript, and C#. Each section below explains -the tooling, commands, and output locations. Update the schema files first, then -rerun the appropriate generator for your target language. +The repository-owned generator is the only supported generation path for +TypeScript, Swift, Kotlin, Dart, GDScript, and C#. It understands OpenIAP's +code-generation SDL extensions (including nested union wrappers and comment +markers), validates their ownership, and keeps every published SDK copy in +sync. Update the schema files first, then run `bun run generate`. + +TypeScript uses graphql-codegen followed by guarded AST post-processing. The +other five outputs use the strict parser, shared IR transformer, and language +plugins under `codegen/`. Both paths consume the same schema inventory, +marker/deprecation metadata, and typed custom-input contracts. Platform copies +are distributed through `generated-sync-manifest.mjs`; do not add a second +copy list or generator entrypoint. + +`# => Union` is a closed wrapper contract: it may only annotate a non-root +object with one or more nullable result fields. Invalid owners, empty wrappers, +and required fields stop every language generator. Generated outputs: @@ -38,95 +50,16 @@ Generated outputs: Uses [`@graphql-codegen/cli`](https://www.the-guild.dev/graphql/codegen). -1. Ensure Node 18+ is installed. +1. Install the repository-pinned Bun version. 2. Install dependencies once from the monorepo root: `bun install --frozen-lockfile` -3. Generate types: `bun run generate:ts` -4. Generated output: `src/generated/types.ts` +3. Run the complete canonical pipeline: `bun run generate` +4. Generated TypeScript output: `src/generated/types.ts` Configuration lives in `codegen.ts`. The script merges every SDL file and emits a schema-first type layer that mirrors the documented shapes. - ---- - -## Dart - -Uses [`graphql_codegen`](https://pub.dev/packages/graphql_codegen) with -`build_runner`. A ready-to-use package scaffold is located in -`generators/dart/`. - -1. Install Dart 3.0+. -2. `cd generators/dart` -3. Fetch dependencies: `dart pub get` -4. Add your `.graphql` operation documents under `lib/` or `graphql/`. -5. Generate code: `dart run build_runner build` -6. Generated output: `generators/dart/lib/generated/` - -The `pubspec.yaml` and `build.yaml` already point the generator at the shared -schema files in `../src`. Customize package name, output path, and scalars as -needed for your application. - ---- - -## Swift - -Relies on the official [Apollo iOS CLI](https://www.apollographql.com/docs/ios/) -for schema codegen. A helper script is provided in `generators/swift/`. - -1. Install the CLI (one time): `brew install apollo-ios-cli` -2. Run the helper: `generators/swift/generate-swift.sh` -3. Generated output: `generators/swift/Generated/` - -The script passes every SDL file to the CLI and emits an embedded module named -`OpenIAPGraphQL`. Adjust the script flags to fit your Xcode project (e.g. -`--module-type swiftPackage` or supply operation files via `--operation-paths`). - ---- - -## Kotlin - -Recommended tooling is [Apollo Kotlin](https://www.apollographql.com/docs/kotlin). -Use the Gradle plugin inside your Android project to consume the schema. Add a -codegen module (e.g. `:openiap-graphql`) and configure it as follows: - -```kotlin -plugins { - id("com.apollographql.apollo3") version "4.0.0" -} - -apollo { - service("openIap") { - packageName.set("dev.openiap.graphql") - schemaFiles.from( - file("../../src/type.graphql"), - file("../../src/type-ios.graphql"), - file("../../src/type-android.graphql"), - file("../../src/api.graphql"), - file("../../src/api-ios.graphql"), - file("../../src/api-android.graphql"), - ) - // Point to your .graphql operations inside the module - srcDir("src/main/graphql") - } -} - -dependencies { - implementation("com.apollographql.apollo3:apollo-runtime:4.0.0") -} -``` - -Then run `./gradlew :openiap-graphql:generateApolloSources` to regenerate the -models. Keep your query/mutation documents under `src/main/graphql` inside that -module. - -If you prefer to consume the pre-generated `src/generated/Types.kt` models from -this repo (via `npm run generate:kotlin`), add the JSON serialization runtime to -your Gradle module: - -```kotlin -dependencies { - implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3") -} -``` +`generate:ts` remains an internal diagnostic stage of the complete command; +do not commit its partial output without the final native generation and +manifest sync stages. --- @@ -134,10 +67,16 @@ dependencies { - Treat the SDL files in `src/` as the canonical schema. Commit schema updates before shipping generated code. -- Regenerate types whenever you change schema shape or add operations: - `bun run generate` for all languages, or `bun run generate:` for a - single target (`ts`, `swift`, `kotlin`, `dart`, `gdscript`, `csharp`). +- Do not feed the SDL directly to general-purpose GraphQL client generators. + `ProductOrSubscription` intentionally composes generated union wrappers, so + the SDL is an OpenIAP code-generation DSL rather than a portable executable + GraphQL service schema. +- Regenerate with `bun run generate` whenever you change schema shape, + generator code, or operations. The `generate:` commands are + diagnostic plugin entry points; before committing, always finish with the + complete command so every manifest target is synchronized. - If you introduce custom scalars, make sure to extend the respective generator config/plugin so they map to the desired native types. -- Use version control to keep generated artifacts out of long-lived diffs unless - they are part of the published SDKs. +- Commit every changed generated and synchronized artifact with its schema or + generator change. The pre-commit and CI gates regenerate from scratch and + reject unstaged or non-reproducible output drift. diff --git a/packages/gql/codegen.ts b/packages/gql/codegen.ts index 6bc8cb7e8..2e7484bde 100644 --- a/packages/gql/codegen.ts +++ b/packages/gql/codegen.ts @@ -1,30 +1,19 @@ import { CodegenConfig } from '@graphql-codegen/cli'; +import { generatedFileHeader } from './codegen/core/generated-header.js'; +import { GRAPHQL_TO_TYPESCRIPT } from './codegen/core/utils.js'; +import { GENERATED_SYNC_MANIFEST, gqlPackageRelativePath } from './generated-sync-manifest.mjs'; +import { SCHEMA_FILE_NAMES } from './schema-files.mjs'; + +const typescriptOutputPath = gqlPackageRelativePath(GENERATED_SYNC_MANIFEST.typescript.source); const config: CodegenConfig = { - schema: [ - 'src/schema.graphql', - 'src/type.graphql', - 'src/type-ios.graphql', - 'src/type-android.graphql', - 'src/api.graphql', - 'src/api-ios.graphql', - 'src/api-android.graphql', - 'src/error.graphql', - 'src/event.graphql', - 'src/webhook.graphql', - ], + schema: SCHEMA_FILE_NAMES.map((fileName) => `src/${fileName}`), generates: { - 'src/generated/types.ts': { + [typescriptOutputPath]: { plugins: [ { add: { - content: [ - '// ============================================================================', - '// AUTO-GENERATED TYPES — DO NOT EDIT DIRECTLY', - '// Run `npm run generate` after updating any *.graphql schema file.', - '// ============================================================================', - '', - ].join('\n'), + content: [...generatedFileHeader(), ''].join('\n'), }, }, 'typescript', @@ -34,13 +23,7 @@ const config: CodegenConfig = { maybeValue: 'T | null', inputMaybeValue: 'T | null', declarationKind: 'interface', - scalars: { - ID: { input: 'string', output: 'string' }, - String: { input: 'string', output: 'string' }, - Boolean: { input: 'boolean', output: 'boolean' }, - Int: { input: 'number', output: 'number' }, - Float: { input: 'number', output: 'number' }, - }, + scalars: GRAPHQL_TO_TYPESCRIPT, }, }, }, diff --git a/packages/gql/codegen/README.md b/packages/gql/codegen/README.md index 2afd8318b..85cd93ede 100644 --- a/packages/gql/codegen/README.md +++ b/packages/gql/codegen/README.md @@ -1,6 +1,8 @@ # Code Generation System -IR-based code generation system for multiple target languages. +IR-based code generation system for Swift, Kotlin, Dart, GDScript, and C#. +TypeScript uses the sibling graphql-codegen plus guarded AST pipeline described +in `../CONVENTION.md`. ## Architecture @@ -19,11 +21,6 @@ codegen/ │ ├── dart.ts # Dart plugin (~870 lines) │ ├── gdscript.ts # GDScript plugin (~610 lines) │ └── csharp.ts # C# plugin (.NET MAUI) -├── templates/ # Handlebars templates (optional) -│ ├── swift/ -│ ├── kotlin/ -│ ├── dart/ -│ └── gdscript/ ``` ## How It Works @@ -113,7 +110,6 @@ These patterns are difficult to express cleanly in templates. The current plugin 1. Create `plugins/.ts` extending `CodegenPlugin` 2. Implement abstract methods 3. Register in `index.ts` -4. Optionally create templates in `templates//` ## Testing diff --git a/packages/gql/codegen/core/generated-header.ts b/packages/gql/codegen/core/generated-header.ts new file mode 100644 index 000000000..841cbd225 --- /dev/null +++ b/packages/gql/codegen/core/generated-header.ts @@ -0,0 +1,6 @@ +export const generatedFileHeader = (commentPrefix = '//'): string[] => [ + `${commentPrefix} ============================================================================`, + `${commentPrefix} AUTO-GENERATED TYPES — DO NOT EDIT DIRECTLY`, + `${commentPrefix} Refresh this file with the generated-types workflow documented for your checkout.`, + `${commentPrefix} ============================================================================`, +]; diff --git a/packages/gql/codegen/core/parser.ts b/packages/gql/codegen/core/parser.ts index 671fc5f41..eb2e71634 100644 --- a/packages/gql/codegen/core/parser.ts +++ b/packages/gql/codegen/core/parser.ts @@ -7,13 +7,11 @@ import { readFileSync } from 'node:fs'; import { resolve, dirname } from 'node:path'; import { fileURLToPath } from 'node:url'; -import { - buildASTSchema, - parse, - type DocumentNode, - type GraphQLSchema, -} from 'graphql'; -import type { SchemaMarkers } from './types.js'; +import { buildASTSchema, Kind, parse, type DocumentNode, type GraphQLSchema } from 'graphql'; +import type { SchemaDeprecations, SchemaMarkers } from './types.js'; +import { SCHEMA_FILE_NAMES } from '../../schema-files.mjs'; +import { extractSchemaMarkers } from '../../schema-markers.mjs'; +import { extractSchemaDeprecations } from '../../schema-deprecations.mjs'; // ============================================================================ // Configuration @@ -23,18 +21,7 @@ const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); /** Default schema paths relative to the gql package */ -const DEFAULT_SCHEMA_PATHS = [ - '../src/schema.graphql', - '../src/type.graphql', - '../src/type-ios.graphql', - '../src/type-android.graphql', - '../src/api.graphql', - '../src/api-ios.graphql', - '../src/api-android.graphql', - '../src/error.graphql', - '../src/event.graphql', - '../src/webhook.graphql', -]; +const DEFAULT_SCHEMA_PATHS = SCHEMA_FILE_NAMES.map((fileName) => `../src/${fileName}`); // ============================================================================ // Parser Interface @@ -45,6 +32,8 @@ export interface ParsedSchema { schema: GraphQLSchema; /** Markers extracted from SDL comments */ markers: SchemaMarkers; + /** Canonical deprecation metadata extracted from SDL directives */ + deprecations: SchemaDeprecations; /** Raw SDL content for each file */ sdlContents: Map; } @@ -68,9 +57,7 @@ export class SchemaParser { // Default base directory is the gql/scripts folder this.baseDir = config.baseDir ?? resolve(__dirname, '../../scripts'); - this.schemaPaths = (config.schemaPaths ?? DEFAULT_SCHEMA_PATHS).map( - (relativePath) => resolve(this.baseDir, relativePath) - ); + this.schemaPaths = (config.schemaPaths ?? DEFAULT_SCHEMA_PATHS).map((relativePath) => resolve(this.baseDir, relativePath)); } /** @@ -87,88 +74,25 @@ export class SchemaParser { // Build combined document const documentNode: DocumentNode = { - kind: 'Document', + kind: Kind.DOCUMENT, definitions: this.schemaPaths.flatMap((schemaPath) => { const sdl = sdlContents.get(schemaPath)!; return parse(sdl).definitions; }), }; - // Build schema - const schema = buildASTSchema(documentNode, { assumeValidSDL: true }); + // Validate directive locations and SDL ownership while building. OpenIAP's + // nested-union codegen extension is validated separately by exact tests. + const schema = buildASTSchema(documentNode); - // Extract markers from SDL comments - const markers = this.extractMarkers(sdlContents); + const sources = [...sdlContents].map(([sourceId, sdl]) => ({ + sourceId, + sdl, + })); + const markers = extractSchemaMarkers(sources); + const deprecations = extractSchemaDeprecations(sources); - return { schema, markers, sdlContents }; - } - - /** - * Extract markers from SDL comments - * - * Supported markers: - * - `# => Union` - Marks the following type as a union wrapper - * - `# Future` - Marks the following field as async (wrap in Promise) - */ - private extractMarkers(sdlContents: Map): SchemaMarkers { - const unionWrappers = new Set(); - const futureFields = new Set(); - - for (const sdl of sdlContents.values()) { - const lines = sdl.split(/\r?\n/); - let expectUnionType = false; - let expectFutureField = false; - let currentTypeName: string | null = null; - - for (const line of lines) { - const trimmed = line.trim(); - - // Track current type context - const typeMatch = trimmed.match(/^(?:extend\s+)?type\s+([A-Za-z0-9_]+)/); - if (typeMatch) { - currentTypeName = typeMatch[1]; - if (expectUnionType) { - unionWrappers.add(currentTypeName); - expectUnionType = false; - } - continue; - } - - // Check for # => Union marker - if (trimmed.startsWith('#') && trimmed.toLowerCase().includes('=> union')) { - expectUnionType = true; - continue; - } - - // Check for # Future marker (strict matching to avoid false positives) - if (/^#\s*future\b/i.test(trimmed)) { - expectFutureField = true; - continue; - } - - // Handle field after # Future marker - if (expectFutureField && currentTypeName) { - const fieldMatch = trimmed.match(/^([A-Za-z0-9_]+)\s*[:(]/); - if (fieldMatch) { - futureFields.add(`${currentTypeName}.${fieldMatch[1]}`); - expectFutureField = false; - } - // Skip empty lines and comments while waiting for field - if (trimmed.length === 0 || trimmed.startsWith('#')) { - continue; - } - // Reset if we hit something unexpected - expectFutureField = false; - } - - // Reset union expectation if we hit non-empty, non-comment, non-type line - if (expectUnionType && trimmed.length > 0 && !trimmed.startsWith('#')) { - expectUnionType = false; - } - } - } - - return { unionWrappers, futureFields }; + return { deprecations, markers, schema, sdlContents }; } /** diff --git a/packages/gql/codegen/core/schema-linter.ts b/packages/gql/codegen/core/schema-linter.ts index ab3b1e348..2248d239b 100644 --- a/packages/gql/codegen/core/schema-linter.ts +++ b/packages/gql/codegen/core/schema-linter.ts @@ -11,6 +11,7 @@ import { Kind, parse, type DefinitionNode, type TypeNode } from 'graphql'; import type { ParsedSchema } from './parser.js'; +import { schemaMarkerIssueMessage, schemaMarkerIssueRule } from '../../schema-markers.mjs'; export interface LintResult { level: 'error' | 'warning'; @@ -20,11 +21,6 @@ export interface LintResult { rule: string; } -export interface LintOptions { - /** Treat warnings as errors */ - strict?: boolean; -} - const IOS_TYPE_SUFFIX_EXCEPTIONS = new Set([ // StoreKit names this payload AppTransaction; keep the public OpenIAP type stable. 'AppTransaction', @@ -53,24 +49,13 @@ const PLATFORM_SELECTOR_TYPE_TOKENS: Record = { amazon: ['Amazon'], }; -function isAllowedPlatformTypeName( - typeName: string, - platform: 'ios' | 'android', -): boolean { - if ( - typeName === 'Query' || - typeName === 'Mutation' || - typeName === 'Subscription' - ) { +function isAllowedPlatformTypeName(typeName: string, platform: 'ios' | 'android'): boolean { + if (typeName === 'Query' || typeName === 'Mutation' || typeName === 'Subscription') { return true; } if (platform === 'ios') { - return ( - typeName.endsWith('IOS') || - (typeName.includes('Ios') && !typeName.endsWith('Ios')) || - IOS_TYPE_SUFFIX_EXCEPTIONS.has(typeName) - ); + return typeName.endsWith('IOS') || (typeName.includes('Ios') && !typeName.endsWith('Ios')) || IOS_TYPE_SUFFIX_EXCEPTIONS.has(typeName); } return ( @@ -83,19 +68,14 @@ function isAllowedPlatformTypeName( function namedTypeName(type: TypeNode): string { let current = type; - while ( - current.kind === Kind.LIST_TYPE || - current.kind === Kind.NON_NULL_TYPE - ) { + while (current.kind === Kind.LIST_TYPE || current.kind === Kind.NON_NULL_TYPE) { current = current.type; } return current.name.value; } function platformTypeName(definition: DefinitionNode): string | null { - return 'name' in definition && definition.kind.includes('Type') - ? definition.name.value - : null; + return 'name' in definition && definition.kind.includes('Type') ? definition.name.value : null; } function referencedPlatform(typeName: string): 'ios' | 'android' | null { @@ -107,159 +87,80 @@ function referencedPlatform(typeName: string): 'ios' | 'android' | null { ) { return 'android'; } - if ( - typeName.includes('IOS') || - typeName.includes('Ios') || - IOS_TYPE_SUFFIX_EXCEPTIONS.has(typeName) - ) { + if (typeName.includes('IOS') || typeName.includes('Ios') || IOS_TYPE_SUFFIX_EXCEPTIONS.has(typeName)) { return 'ios'; } return null; } -function parentProvidesPlatformContext( - typeName: string, - platform: 'ios' | 'android', -): boolean { +function parentProvidesPlatformContext(typeName: string, platform: 'ios' | 'android'): boolean { return referencedPlatform(typeName) === platform; } -function isPlatformSelectorField( - fieldName: string, - referencedType: string, -): boolean { - return ( - PLATFORM_SELECTOR_TYPE_TOKENS[fieldName]?.some((token) => - referencedType.includes(token), - ) ?? false - ); +function isPlatformSelectorField(fieldName: string, referencedType: string): boolean { + return PLATFORM_SELECTOR_TYPE_TOKENS[fieldName]?.some((token) => referencedType.includes(token)) ?? false; } /** * Lint schema conventions and return findings. */ -export function lintSchema( - parsedSchema: ParsedSchema, - _options: LintOptions = {}, -): LintResult[] { +export function lintSchema(parsedSchema: ParsedSchema): LintResult[] { const results: LintResult[] = []; + for (const issue of parsedSchema.markers.issues) { + const fileName = issue.sourceId.split('/').pop() ?? issue.sourceId; + const message = schemaMarkerIssueMessage(issue, (sourceId) => sourceId.split('/').pop() ?? sourceId); + results.push({ + level: 'error', + file: fileName, + line: issue.markerLine, + message, + rule: schemaMarkerIssueRule(issue), + }); + } + + for (const issue of parsedSchema.deprecations.issues) { + results.push({ + level: 'error', + file: issue.file.split('/').pop() ?? issue.file, + line: issue.line, + message: issue.message, + rule: issue.rule, + }); + } + for (const [filePath, sdl] of parsedSchema.sdlContents.entries()) { const fileName = filePath.split('/').pop() ?? filePath; - const lines = sdl.split(/\r?\n/); const isIOSFile = fileName.includes('-ios') || fileName.includes('_ios'); - const isAndroidFile = - fileName.includes('-android') || fileName.includes('_android'); - - let pendingUnionMarker = false; - let pendingUnionLine = 0; - let pendingFutureMarker = false; - let pendingFutureLine = 0; - - for (let i = 0; i < lines.length; i++) { - const trimmed = lines[i].trim(); - const lineNum = i + 1; - - // Track union marker - if (trimmed.startsWith('#') && trimmed.toLowerCase().includes('=> union')) { - pendingUnionMarker = true; - pendingUnionLine = lineNum; - continue; - } - - // Track Future marker - if (/^#\s*future\b/i.test(trimmed)) { - pendingFutureMarker = true; - pendingFutureLine = lineNum; - continue; - } - - // Check Future marker is followed by a valid field - if (pendingFutureMarker) { - const fieldMatch = trimmed.match(/^([A-Za-z0-9_]+)\s*[:(]/); - if (fieldMatch) { - pendingFutureMarker = false; - } else if (trimmed.length > 0 && !trimmed.startsWith('#') && trimmed !== '}') { - results.push({ - level: 'warning', - file: fileName, - line: pendingFutureLine, - message: `"# Future" marker at line ${pendingFutureLine} is not followed by a valid field definition`, - rule: 'future-marker-target', - }); - pendingFutureMarker = false; - } - } - - if (pendingUnionMarker) { - if (/^(?:extend\s+)?type\s+/.test(trimmed)) { - pendingUnionMarker = false; - } else if (trimmed.length > 0 && !trimmed.startsWith('#')) { - results.push({ - level: 'error', - file: fileName, - line: pendingUnionLine, - message: `"# => Union" marker at line ${pendingUnionLine} is not followed by a type definition`, - rule: 'union-marker-target', - }); - pendingUnionMarker = false; - } - } - } - - // End-of-file checks - if (pendingUnionMarker) { - results.push({ - level: 'error', - file: fileName, - line: pendingUnionLine, - message: `"# => Union" marker at line ${pendingUnionLine} has no following type definition (end of file)`, - rule: 'union-marker-target', - }); - } - - if (pendingFutureMarker) { - results.push({ - level: 'warning', - file: fileName, - line: pendingFutureLine, - message: `"# Future" marker at line ${pendingFutureLine} has no following field definition (end of file)`, - rule: 'future-marker-target', - }); - } + const isAndroidFile = fileName.includes('-android') || fileName.includes('_android'); const document = parse(sdl); + for (const definition of document.definitions) { const typeName = platformTypeName(definition); - if ( - typeName && - isIOSFile && - !isAllowedPlatformTypeName(typeName, 'ios') - ) { + if (typeName && isIOSFile && !isAllowedPlatformTypeName(typeName, 'ios')) { results.push({ level: 'error', file: fileName, - line: definition.name.loc?.startToken.line, + line: 'name' in definition ? definition.name?.loc?.startToken.line : undefined, message: `Type "${typeName}" in iOS file should end with "IOS" suffix`, rule: 'ios-type-suffix', }); } - if ( - typeName && - isAndroidFile && - !isAllowedPlatformTypeName(typeName, 'android') - ) { + if (typeName && isAndroidFile && !isAllowedPlatformTypeName(typeName, 'android')) { results.push({ level: 'error', file: fileName, - line: definition.name.loc?.startToken.line, + line: 'name' in definition ? definition.name?.loc?.startToken.line : undefined, message: `Type "${typeName}" in Android file should end with "Android" suffix`, rule: 'android-type-suffix', }); } - if (!('fields' in definition) || !definition.fields) continue; + if (!('name' in definition) || !definition.name || !('fields' in definition) || !definition.fields) { + continue; + } const operationName = definition.name.value; @@ -276,7 +177,7 @@ export function lintSchema( } const references = [ { selector: fieldName, type: namedTypeName(field.type) }, - ...(field.arguments ?? []).map((argument) => ({ + ...('arguments' in field ? (field.arguments ?? []) : []).map((argument) => ({ selector: argument.name.value, type: namedTypeName(argument.type), })), @@ -309,9 +210,7 @@ export function lintSchema( if ( (operationName === 'Query' || operationName === 'Mutation') && fieldName !== '_placeholder' && - !parsedSchema.markers.futureFields.has( - `${operationName}.${fieldName}`, - ) + !parsedSchema.markers.futureFields.has(`${operationName}.${fieldName}`) ) { results.push({ level: 'error', @@ -346,9 +245,7 @@ export function formatLintResults(results: LintResult[]): string { const warnings = results.filter((r) => r.level === 'warning').length; lines.push(''); - lines.push( - `[schema-lint] ${errors} error(s), ${warnings} warning(s)`, - ); + lines.push(`[schema-lint] ${errors} error(s), ${warnings} warning(s)`); return lines.join('\n'); } diff --git a/packages/gql/codegen/core/template-engine.ts b/packages/gql/codegen/core/template-engine.ts deleted file mode 100644 index 744f99765..000000000 --- a/packages/gql/codegen/core/template-engine.ts +++ /dev/null @@ -1,315 +0,0 @@ -/** - * Template Engine for Code Generation - * - * Provides Handlebars-based template rendering with language-specific helpers. - */ - -import Handlebars from 'handlebars'; -import type { IRType, IRField, IREnum, IREnumValue, IROperationField } from './types.js'; - -// ============================================================================ -// Template Context Types -// ============================================================================ - -export interface EnumContext { - name: string; - description?: string; - values: EnumValueContext[]; - isErrorCode: boolean; -} - -export interface EnumValueContext { - name: string; - caseName: string; - rawValue: string; - description?: string; - legacyAliases: string[]; - isLast: boolean; -} - -export interface FieldContext { - name: string; - propertyName: string; - type: string; - description?: string; - nullable: boolean; - isOverride: boolean; - defaultValue: string; - isLast: boolean; -} - -export interface InterfaceContext { - name: string; - description?: string; - fields: FieldContext[]; -} - -export interface ObjectContext { - name: string; - description?: string; - fields: FieldContext[]; - conformances: string[]; - hasFields: boolean; - isResultUnion: boolean; - resultUnionEntries?: ResultUnionEntryContext[]; -} - -export interface ResultUnionEntryContext { - fieldName: string; - caseName: string; - type: string; - isLast: boolean; -} - -export interface InputContext { - name: string; - description?: string; - fields: FieldContext[]; - hasRequiredFields: boolean; - isCustomType: boolean; - customTypeKind?: string; -} - -export interface UnionContext { - name: string; - description?: string; - members: UnionMemberContext[]; - sharedInterfaces: string[]; - conformances: string; - hasNestedUnions: boolean; - nestedUnionWrappers: NestedUnionWrapperContext[]; - concreteMembers: ConcreteMemberContext[]; -} - -export interface UnionMemberContext { - name: string; - caseName: string; - isNested: boolean; -} - -export interface NestedUnionWrapperContext { - wrapperName: string; - unionName: string; - parentUnionName: string; -} - -export interface ConcreteMemberContext { - typeName: string; - delegateTo: string; - isNested: boolean; - wrapperName?: string; -} - -export interface OperationContext { - kind: 'Query' | 'Mutation' | 'Subscription'; - name: string; - description?: string; - protocolName: string; - fields: OperationFieldContext[]; -} - -export interface OperationFieldContext { - name: string; - escapedName: string; - description?: string; - returnType: string; - args: ArgContext[]; - hasArgs: boolean; - hasSingleArg: boolean; - hasMultipleArgs: boolean; - aliasName: string; - argsSignature: string; - paramsSignature: string; - isLast: boolean; -} - -export interface ArgContext { - name: string; - type: string; - defaultValue: string; - isLast: boolean; -} - -// ============================================================================ -// Template Engine -// ============================================================================ - -export class TemplateEngine { - private handlebars: typeof Handlebars; - private templates: Map = new Map(); - - constructor() { - this.handlebars = Handlebars.create(); - this.registerBuiltinHelpers(); - } - - private registerBuiltinHelpers(): void { - // Conditional helpers - this.handlebars.registerHelper('if_eq', function (a: unknown, b: unknown, options: Handlebars.HelperOptions) { - return a === b ? options.fn(this) : options.inverse(this); - }); - - this.handlebars.registerHelper('unless_eq', function (a: unknown, b: unknown, options: Handlebars.HelperOptions) { - return a !== b ? options.fn(this) : options.inverse(this); - }); - - this.handlebars.registerHelper('if_gt', function (a: unknown, b: unknown, options: Handlebars.HelperOptions) { - return (a as number) > (b as number) ? options.fn(this) : options.inverse(this); - }); - - // String helpers - this.handlebars.registerHelper('capitalize', (str: string) => { - return str ? str.charAt(0).toUpperCase() + str.slice(1) : ''; - }); - - this.handlebars.registerHelper('lowercase', (str: string) => { - return str ? str.toLowerCase() : ''; - }); - - // GDScript doc comment helper - prefixes each line with ## - this.handlebars.registerHelper('gd_doc', (str: string) => { - if (!str) return ''; - return str.split('\n').map(line => `## ${line}`).join('\n'); - }); - - // Equality helper for use in subexpressions - this.handlebars.registerHelper('eq', (a: unknown, b: unknown) => { - return a === b; - }); - - // Array helpers - this.handlebars.registerHelper('join', (arr: string[], separator: string) => { - return Array.isArray(arr) ? arr.join(separator) : ''; - }); - - this.handlebars.registerHelper('length', (arr: unknown[]) => { - return Array.isArray(arr) ? arr.length : 0; - }); - - // Logic helpers - use regular functions for correct 'this' binding in Handlebars - this.handlebars.registerHelper('and', function (...args: unknown[]) { - const options = args.pop() as Handlebars.HelperOptions; - return args.every(Boolean) ? options.fn(this) : options.inverse(this); - }); - - this.handlebars.registerHelper('or', function (...args: unknown[]) { - const options = args.pop() as Handlebars.HelperOptions; - return args.some(Boolean) ? options.fn(this) : options.inverse(this); - }); - - this.handlebars.registerHelper('not', (value: unknown) => { - return !value; - }); - - // Index helpers - this.handlebars.registerHelper('is_last', function (index: number, array: unknown[], options: Handlebars.HelperOptions) { - return index === array.length - 1 ? options.fn(this) : options.inverse(this); - }); - - this.handlebars.registerHelper('is_not_last', function (index: number, array: unknown[], options: Handlebars.HelperOptions) { - return index !== array.length - 1 ? options.fn(this) : options.inverse(this); - }); - } - - /** - * Register a custom helper function - */ - registerHelper(name: string, fn: Handlebars.HelperDelegate): void { - this.handlebars.registerHelper(name, fn); - } - - /** - * Register a template string - */ - registerTemplate(name: string, template: string): void { - this.templates.set(name, this.handlebars.compile(template)); - } - - /** - * Register a partial template - */ - registerPartial(name: string, template: string): void { - this.handlebars.registerPartial(name, template); - } - - /** - * Render a registered template with context - */ - render(templateName: string, context: Record): string { - const template = this.templates.get(templateName); - if (!template) { - throw new Error(`Template not found: ${templateName}`); - } - return template(context); - } - - /** - * Render a template string directly - */ - renderString(template: string, context: Record): string { - const compiled = this.handlebars.compile(template); - return compiled(context); - } -} - -// ============================================================================ -// Context Builders -// ============================================================================ - -export interface ContextBuilderConfig { - mapType: (type: IRType) => string; - mapScalar: (name: string) => string; - escapeKeyword: (name: string) => string; - enumValueCase: (name: string) => string; - fieldNameCase: (name: string) => string; - getPropertyType: (type: IRType) => string; -} - -export function buildEnumContext( - irEnum: IREnum, - config: ContextBuilderConfig -): EnumContext { - return { - name: irEnum.name, - description: irEnum.description, - isErrorCode: irEnum.isErrorCode, - values: irEnum.values.map((value, index) => ({ - name: value.name, - caseName: config.escapeKeyword(config.enumValueCase(value.name)), - rawValue: value.rawValue, - description: value.description, - legacyAliases: value.legacyAliases, - isLast: index === irEnum.values.length - 1, - })), - }; -} - -export function buildFieldContext( - field: IRField, - config: ContextBuilderConfig, - isLast: boolean -): FieldContext { - return { - name: field.name, - propertyName: config.escapeKeyword(config.fieldNameCase(field.name)), - type: config.getPropertyType(field.type), - description: field.description, - nullable: field.type.nullable, - isOverride: field.isOverride, - defaultValue: field.defaultValue || '', - isLast, - }; -} - -export function buildFieldsContext( - fields: IRField[], - config: ContextBuilderConfig, - sort: boolean = false -): FieldContext[] { - const sortedFields = sort - ? [...fields].sort((a, b) => a.name.localeCompare(b.name)) - : fields; - return sortedFields.map((field, index) => - buildFieldContext(field, config, index === sortedFields.length - 1) - ); -} diff --git a/packages/gql/codegen/core/transformer.ts b/packages/gql/codegen/core/transformer.ts index bb69332b7..9fb368aad 100644 --- a/packages/gql/codegen/core/transformer.ts +++ b/packages/gql/codegen/core/transformer.ts @@ -20,14 +20,10 @@ import { type GraphQLObjectType, type GraphQLUnionType, type GraphQLType, - type GraphQLField, - type GraphQLInputField, - type GraphQLArgument, valueFromASTUntyped, } from 'graphql'; import type { IRSchema, - IRSchemaMetadata, IREnum, IREnumValue, IRInterface, @@ -40,27 +36,24 @@ import type { IRArg, IROperationField, IRResultUnionEntry, - IRPlatformDefault, SchemaMarkers, } from './types.js'; -import { - toKebabCase, - toConstantCase, - CUSTOM_INPUT_TYPES, - PLATFORM_TYPE_DEFAULTS, - ERROR_CODE_LEGACY_ALIASES, -} from './utils.js'; +import { toKebabCase, PLATFORM_TYPE_DEFAULTS, ERROR_CODE_LEGACY_ALIASES, SUPPORTED_GRAPHQL_SCALARS } from './utils.js'; import type { ParsedSchema } from './parser.js'; +import { assertValidSchemaMarkers } from '../../schema-markers.mjs'; +import { assertValidSchemaDeprecations } from '../../schema-deprecations.mjs'; +import { CUSTOM_INPUT_CONTRACTS, GENERATOR_INPUT_CONTRACTS, type CustomInputKind } from '../../custom-input-contracts.js'; // ============================================================================ // Transformer // ============================================================================ -export class SchemaTransformer { +class SchemaTransformer { private schema: GraphQLSchema; private markers: SchemaMarkers; private typeMap: ReturnType; private typeNames: string[]; + private typeDeprecationReasons: Map; // Computed metadata private enumNames = new Set(); @@ -70,23 +63,45 @@ export class SchemaTransformer { private unionNames = new Set(); private unionMembership = new Map>(); private singleFieldObjects = new Map(); - private inputsWithRequiredFields = new Set(); constructor(parsedSchema: ParsedSchema) { + assertValidSchemaMarkers(parsedSchema.markers); + assertValidSchemaDeprecations(parsedSchema.deprecations); this.schema = parsedSchema.schema; this.markers = parsedSchema.markers; + this.typeDeprecationReasons = parsedSchema.deprecations.typeReasons; this.typeMap = this.schema.getTypeMap(); this.typeNames = Object.keys(this.typeMap) .filter((name) => !name.startsWith('__')) .sort((a, b) => a.localeCompare(b)); } + private descriptionWithDeprecation( + description: string | null | undefined, + deprecationReason: string | null | undefined, + label: string, + ): string | undefined { + const normalizedDescription = description?.trim() || undefined; + if (deprecationReason == null) return normalizedDescription; + const normalizedReason = deprecationReason.replace(/\s+/g, ' ').trim(); + if (!normalizedReason) { + throw new Error(`${label} @deprecated reason must not be empty.`); + } + if (/(?:^|\n)\s*@deprecated\b/.test(normalizedDescription ?? '')) { + throw new Error(`${label} duplicates @deprecated in its description; keep the canonical reason only in the GraphQL directive.`); + } + return [normalizedDescription, `@deprecated ${normalizedReason}`].filter(Boolean).join('\n'); + } + /** * Transform the GraphQL schema to IR */ transform(): IRSchema { + this.assertValidUnionWrapperShapes(); + // First pass: categorize types and build name sets const categorized = this.categorizeTypes(); + this.assertPlatformTypeDefaultContracts(categorized.objects); // Build union membership map for (const unionType of categorized.unions) { @@ -106,26 +121,15 @@ export class SchemaTransformer { } } - // Identify inputs with required fields - for (const inputType of categorized.inputs) { - const fields = Object.values(inputType.getFields()); - const hasRequired = fields.some((field) => field.type instanceof GraphQLNonNull); - if (hasRequired) { - this.inputsWithRequiredFields.add(inputType.name); - } - } - // Transform each category const enums = categorized.enums.map((e) => this.transformEnum(e)); const interfaces = categorized.interfaces.map((i) => this.transformInterface(i)); const objects = categorized.objects.map((o) => this.transformObject(o)); const inputs = categorized.inputs.map((i) => this.transformInput(i)); + this.assertCustomInputContracts(inputs); const unions = categorized.unions.map((u) => this.transformUnion(u)); const operations = categorized.operations.map((o) => this.transformOperation(o)); - // Build metadata - const metadata = this.buildMetadata(); - return { enums: enums.sort((a, b) => a.name.localeCompare(b.name)), interfaces: interfaces.sort((a, b) => a.name.localeCompare(b.name)), @@ -133,10 +137,66 @@ export class SchemaTransformer { inputs: inputs.sort((a, b) => a.name.localeCompare(b.name)), unions: unions.sort((a, b) => a.name.localeCompare(b.name)), operations: operations.sort((a, b) => a.name.localeCompare(b.name)), - metadata, }; } + private assertValidUnionWrapperShapes(): void { + for (const typeName of this.markers.unionWrappers) { + if (['Query', 'Mutation', 'Subscription'].includes(typeName)) { + throw new Error(`${typeName} cannot use # => Union because operation root types cannot be union wrappers.`); + } + + const type = this.typeMap[typeName]; + if (!type || !isObjectType(type)) { + throw new Error(`${typeName} # => Union marker must resolve to exactly one object type.`); + } + + const fields = Object.values(type.getFields()); + if (fields.length === 0) { + throw new Error(`${typeName} # => Union wrapper must declare at least one nullable result field.`); + } + + const requiredFields = fields.filter((field) => field.type instanceof GraphQLNonNull).map((field) => field.name); + if (requiredFields.length > 0) { + throw new Error(`${typeName} # => Union wrapper fields must all be nullable; required: ${requiredFields.join(', ')}.`); + } + } + } + + private assertCustomInputContracts(inputs: IRInput[]): void { + const typeSignature = (type: IRType): string => + [ + type.kind, + type.name ?? '', + type.nullable ? 'nullable' : 'required', + type.elementType ? `[${typeSignature(type.elementType)}]` : '', + ].join(':'); + + for (const [inputName, expectedFields] of Object.entries(GENERATOR_INPUT_CONTRACTS)) { + const input = inputs.find((candidate) => candidate.name === inputName); + if (!input) continue; + + const actualNames = input.fields.map((field) => field.name); + const expectedNames = expectedFields.map((field) => field.name); + if (actualNames.length !== expectedNames.length || actualNames.some((name, index) => name !== expectedNames[index])) { + throw new Error( + `${inputName} custom input contract fields drifted; expected ${expectedNames.join(', ')}, found ${actualNames.join(', ')}.`, + ); + } + + for (const [index, expected] of expectedFields.entries()) { + const actual = input.fields[index]; + const expectedType = typeSignature(expected.type as IRType); + const actualType = typeSignature(actual.type); + if (actualType !== expectedType || !Object.is(actual.defaultValue, expected.defaultValue)) { + throw new Error( + `${inputName}.${expected.name} custom input contract drifted; expected ${expectedType} default ${String(expected.defaultValue)}, found ${actualType} default ${String(actual.defaultValue)}.`, + ); + } + } + } + } + // ============================================================================ // Type Categorization // ============================================================================ @@ -160,6 +220,7 @@ export class SchemaTransformer { const type = this.typeMap[name]; if (isScalarType(type)) { + this.assertSupportedScalar(type.name); continue; } if (isEnumType(type)) { @@ -195,6 +256,71 @@ export class SchemaTransformer { return { enums, interfaces, objects, inputs, unions, operations }; } + private assertSupportedScalar(typeName: string): void { + if (!SUPPORTED_GRAPHQL_SCALARS.has(typeName)) { + throw new Error(`Unsupported GraphQL scalar ${typeName}; add an explicit cross-language mapping before using it.`); + } + } + + private assertPlatformTypeDefaultContracts(objects: GraphQLObjectType[]): void { + const productCommon = this.typeMap.ProductCommon; + if (!productCommon) return; + if (!isInterfaceType(productCommon)) { + throw new Error('ProductCommon platform-default contract must remain a GraphQL interface.'); + } + + const implementors = objects + .filter((objectType) => objectType.getInterfaces().some((interfaceType) => interfaceType.name === 'ProductCommon')) + .map((objectType) => objectType.name) + .sort(); + const configured = Object.keys(PLATFORM_TYPE_DEFAULTS).sort(); + if (implementors.length !== configured.length || implementors.some((typeName, index) => typeName !== configured[index])) { + throw new Error( + `ProductCommon platform-default coverage drifted; implementors: ${implementors.join(', ') || ''}; configured: ${configured.join(', ') || ''}.`, + ); + } + + const fieldContracts = { + platform: { enumName: 'IapPlatform' }, + type: { enumName: 'ProductType' }, + } as const; + const rawValues = new Map>(); + for (const { enumName } of Object.values(fieldContracts)) { + const enumeration = this.typeMap[enumName]; + if (!enumeration || !isEnumType(enumeration)) { + throw new Error(`ProductCommon platform-default contract requires enum ${enumName}.`); + } + rawValues.set(enumName, new Set(enumeration.getValues().map((value) => toKebabCase(value.name)))); + } + + const assertFieldShape = (owner: GraphQLInterfaceType | GraphQLObjectType, fieldName: keyof typeof fieldContracts) => { + const field = owner.getFields()[fieldName]; + const { enumName } = fieldContracts[fieldName]; + const namedType = field?.type instanceof GraphQLNonNull ? field.type.ofType : null; + if (!namedType || !isEnumType(namedType) || namedType.name !== enumName) { + throw new Error(`${owner.name}.${fieldName} platform-default contract must remain non-null ${enumName}.`); + } + }; + + assertFieldShape(productCommon, 'platform'); + assertFieldShape(productCommon, 'type'); + for (const typeName of implementors) { + const objectType = this.typeMap[typeName]; + if (!objectType || !isObjectType(objectType)) { + throw new Error(`${typeName} platform-default contract must resolve to an object type.`); + } + const defaults = PLATFORM_TYPE_DEFAULTS[typeName]; + for (const fieldName of Object.keys(fieldContracts) as (keyof typeof fieldContracts)[]) { + assertFieldShape(objectType, fieldName); + const { enumName } = fieldContracts[fieldName]; + const rawValue = defaults[fieldName]; + if (!rawValues.get(enumName)?.has(rawValue)) { + throw new Error(`${typeName}.${fieldName} platform default "${rawValue}" is not a ${enumName} wire value.`); + } + } + } + } + // ============================================================================ // Type Transformation // ============================================================================ @@ -229,6 +355,7 @@ export class SchemaTransformer { kind = 'object'; } else { // Scalar + this.assertSupportedScalar(typeName); kind = 'scalar'; } @@ -263,14 +390,22 @@ export class SchemaTransformer { return { name: value.name, rawValue, - description: value.description ?? undefined, + description: this.descriptionWithDeprecation(value.description, value.deprecationReason, `${enumType.name}.${value.name}`), legacyAliases: [...new Set(legacyAliases)], }; }); + const rawValueOwners = new Map(); + for (const value of values) { + const previousOwner = rawValueOwners.get(value.rawValue); + if (previousOwner) { + throw new Error(`${enumType.name} enum values ${previousOwner} and ${value.name} both serialize as "${value.rawValue}".`); + } + rawValueOwners.set(value.rawValue, value.name); + } return { name: enumType.name, - description: enumType.description ?? undefined, + description: this.descriptionWithDeprecation(enumType.description, this.typeDeprecationReasons.get(enumType.name), enumType.name), values, isErrorCode: enumType.name === 'ErrorCode', }; @@ -286,17 +421,18 @@ export class SchemaTransformer { const fields: IRField[] = graphqlFields.map((field) => ({ name: field.name, - description: field.description ?? undefined, + description: this.descriptionWithDeprecation(field.description, field.deprecationReason, `${interfaceType.name}.${field.name}`), type: this.transformType(field.type), isOverride: false, - defaultValue: field.astNode?.defaultValue - ? valueFromASTUntyped(field.astNode.defaultValue) - : undefined, })); return { name: interfaceType.name, - description: interfaceType.description ?? undefined, + description: this.descriptionWithDeprecation( + interfaceType.description, + this.typeDeprecationReasons.get(interfaceType.name), + interfaceType.name, + ), fields, }; } @@ -307,15 +443,22 @@ export class SchemaTransformer { private transformObject(objectType: GraphQLObjectType): IRObject { const interfacesForObject = objectType.getInterfaces().map((i) => i.name); - const unionsForObject = this.unionMembership.get(objectType.name) - ? [...this.unionMembership.get(objectType.name)!] - : []; + const unionsForObject = this.unionMembership.get(objectType.name) ? [...this.unionMembership.get(objectType.name)!] : []; - // Collect interface fields for override detection - const interfaceFieldNames = new Set(); + // Collect interface fields once for override detection and canonical + // deprecation projection. The interface owns the reason, while GraphQL + // requires each concrete field to repeat that exact directive so + // introspection and concrete-type consumers retain the metadata. + const interfaceFieldsByName = new Map>(); for (const iface of objectType.getInterfaces()) { - for (const fieldName of Object.keys(iface.getFields())) { - interfaceFieldNames.add(fieldName); + for (const [fieldName, field] of Object.entries(iface.getFields())) { + interfaceFieldsByName.set(fieldName, [ + ...(interfaceFieldsByName.get(fieldName) ?? []), + { + interfaceName: iface.name, + deprecationReason: field.deprecationReason ?? undefined, + }, + ]); } } @@ -323,11 +466,32 @@ export class SchemaTransformer { const graphqlFields = Object.values(objectType.getFields()); const fields: IRField[] = graphqlFields.map((field) => { + const interfaceFields = interfaceFieldsByName.get(field.name) ?? []; + const inheritedReasons = [ + ...new Set(interfaceFields.map((candidate) => candidate.deprecationReason).filter((reason): reason is string => Boolean(reason))), + ]; + if (inheritedReasons.length > 1) { + throw new Error( + `${objectType.name}.${field.name} inherits conflicting deprecation reasons from ${interfaceFields.map((candidate) => candidate.interfaceName).join(', ')}.`, + ); + } + const inheritedReason = inheritedReasons[0]; + if (inheritedReason && field.deprecationReason !== inheritedReason) { + const relation = field.deprecationReason ? 'conflicts with' : 'must repeat'; + throw new Error( + `${objectType.name}.${field.name} ${relation} the exact interface-owned deprecation guidance for concrete GraphQL introspection.`, + ); + } + const irField: IRField = { name: field.name, - description: field.description ?? undefined, + description: this.descriptionWithDeprecation( + field.description, + field.deprecationReason ?? inheritedReason, + `${objectType.name}.${field.name}`, + ), type: this.transformType(field.type), - isOverride: interfaceFieldNames.has(field.name), + isOverride: interfaceFields.length > 0, }; // Add platform defaults for discriminated union types @@ -348,34 +512,25 @@ export class SchemaTransformer { let resultUnionEntries: IRResultUnionEntry[] | undefined; if (isResultUnion) { - const allOptional = graphqlFields.every( - (field) => !(field.type instanceof GraphQLNonNull) - ); - if (allOptional && graphqlFields.length > 0) { - resultUnionEntries = graphqlFields.map((field) => ({ - fieldName: field.name, - type: this.transformType(field.type), - })); - } + resultUnionEntries = fields.map((field) => ({ + fieldName: field.name, + description: field.description, + type: field.type, + })); } - // Check if single-field Args type - const isSingleFieldArgs = - graphqlFields.length === 1 && objectType.name.endsWith('Args'); - const singleFieldType = isSingleFieldArgs - ? this.transformType(graphqlFields[0].type) - : undefined; - return { name: objectType.name, - description: objectType.description ?? undefined, + description: this.descriptionWithDeprecation( + objectType.description, + this.typeDeprecationReasons.get(objectType.name), + objectType.name, + ), fields, interfaces: interfacesForObject, unions: unionsForObject, - isResultUnion: isResultUnion && !!resultUnionEntries, + isResultUnion, resultUnionEntries, - isSingleFieldArgs, - singleFieldType, }; } @@ -389,31 +544,20 @@ export class SchemaTransformer { const fields: IRField[] = graphqlFields.map((field) => ({ name: field.name, - description: field.description ?? undefined, + description: this.descriptionWithDeprecation(field.description, field.deprecationReason, `${inputType.name}.${field.name}`), type: this.transformType(field.type), isOverride: false, - defaultValue: field.astNode?.defaultValue - ? valueFromASTUntyped(field.astNode.defaultValue) - : undefined, + defaultValue: field.astNode?.defaultValue ? valueFromASTUntyped(field.astNode.defaultValue) : undefined, })); - const hasRequiredFields = graphqlFields.some( - (field) => field.type instanceof GraphQLNonNull - ); - - const isCustomType = CUSTOM_INPUT_TYPES.has(inputType.name); - let customTypeKind: IRInput['customTypeKind']; - if (inputType.name === 'RequestPurchaseProps') { - customTypeKind = 'RequestPurchaseProps'; - } else if (inputType.name === 'DiscountOfferInputIOS') { - customTypeKind = 'DiscountOfferInputIOS'; - } else if (inputType.name === 'PurchaseInput') { - customTypeKind = 'PurchaseInput'; - } + const hasRequiredFields = graphqlFields.some((field) => field.type instanceof GraphQLNonNull); + + const isCustomType = Object.hasOwn(CUSTOM_INPUT_CONTRACTS, inputType.name); + const customTypeKind = isCustomType ? (inputType.name as CustomInputKind) : undefined; return { name: inputType.name, - description: inputType.description ?? undefined, + description: this.descriptionWithDeprecation(inputType.description, this.typeDeprecationReasons.get(inputType.name), inputType.name), fields, hasRequiredFields, isCustomType, @@ -433,16 +577,12 @@ export class SchemaTransformer { if (memberTypes.length > 0) { const [firstMember, ...otherMembers] = memberTypes; if (typeof (firstMember as GraphQLObjectType).getInterfaces === 'function') { - const firstInterfaces = new Set( - (firstMember as GraphQLObjectType).getInterfaces().map((i) => i.name) - ); + const firstInterfaces = new Set((firstMember as GraphQLObjectType).getInterfaces().map((i) => i.name)); let allMembersHaveInterfaces = true; for (const member of otherMembers) { if (typeof (member as GraphQLObjectType).getInterfaces === 'function') { - const memberInterfaces = new Set( - (member as GraphQLObjectType).getInterfaces().map((i) => i.name) - ); + const memberInterfaces = new Set((member as GraphQLObjectType).getInterfaces().map((i) => i.name)); for (const ifaceName of [...firstInterfaces]) { if (!memberInterfaces.has(ifaceName)) { firstInterfaces.delete(ifaceName); @@ -468,7 +608,7 @@ export class SchemaTransformer { return { name: unionType.name, - description: unionType.description ?? undefined, + description: this.descriptionWithDeprecation(unionType.description, this.typeDeprecationReasons.get(unionType.name), unionType.name), members, // Preserve schema order sharedInterfaces: sharedInterfaceNames, }; @@ -487,24 +627,23 @@ export class SchemaTransformer { const fields: IROperationField[] = graphqlFields.map((field) => { const args: IRArg[] = field.args.map((arg) => ({ name: arg.name, - description: arg.description ?? undefined, + description: this.descriptionWithDeprecation( + arg.description, + arg.deprecationReason, + `${operationType.name}.${field.name}(${arg.name})`, + ), type: this.transformType(arg.type), })); const returnType = this.transformType(field.type); - const isFuture = this.markers.futureFields.has( - `${operationType.name}.${field.name}` - ); - // Resolve return type (VoidResult -> Void, single-field Args inlining) const resolvedReturnType = this.resolveOperationReturnType(field.type); return { name: field.name, - description: field.description ?? undefined, + description: this.descriptionWithDeprecation(field.description, field.deprecationReason, `${operationType.name}.${field.name}`), args, returnType, - isFuture, resolvedReturnType, }; }); @@ -512,7 +651,11 @@ export class SchemaTransformer { return { kind, name: operationType.name, - description: operationType.description ?? undefined, + description: this.descriptionWithDeprecation( + operationType.description, + this.typeDeprecationReasons.get(operationType.name), + operationType.name, + ), fields, }; } @@ -560,26 +703,6 @@ export class SchemaTransformer { } return current; } - - // ============================================================================ - // Metadata - // ============================================================================ - - private buildMetadata(): IRSchemaMetadata { - const platformDefaults = new Map(); - for (const [typeName, defaults] of Object.entries(PLATFORM_TYPE_DEFAULTS)) { - platformDefaults.set(typeName, defaults); - } - - return { - unionWrapperNames: this.markers.unionWrappers, - futureFieldNames: this.markers.futureFields, - platformDefaults, - singleFieldObjects: this.singleFieldObjects, - unionMembership: this.unionMembership, - inputsWithRequiredFields: this.inputsWithRequiredFields, - }; - } } // ============================================================================ diff --git a/packages/gql/codegen/core/types.ts b/packages/gql/codegen/core/types.ts index a6f9352ba..52d84369a 100644 --- a/packages/gql/codegen/core/types.ts +++ b/packages/gql/codegen/core/types.ts @@ -5,18 +5,13 @@ * of GraphQL schema types, which can be transformed into any target language. */ +import type { CustomInputKind } from '../../custom-input-contracts.js'; + // ============================================================================ // Type System // ============================================================================ -export type IRTypeKind = - | 'scalar' - | 'enum' - | 'object' - | 'input' - | 'interface' - | 'union' - | 'list'; +export type IRTypeKind = 'scalar' | 'enum' | 'object' | 'input' | 'interface' | 'union' | 'list'; export interface IRType { /** The kind of type */ @@ -104,15 +99,13 @@ export interface IRObject { isResultUnion: boolean; /** For result unions, the variant entries */ resultUnionEntries?: IRResultUnionEntry[]; - /** Whether this is a single-field Args type that can be inlined */ - isSingleFieldArgs: boolean; - /** For single-field Args, the inlined field type */ - singleFieldType?: IRType; } export interface IRResultUnionEntry { /** Field name */ fieldName: string; + /** Variant documentation, including canonical deprecation guidance */ + description?: string; /** Field type */ type: IRType; } @@ -133,7 +126,7 @@ export interface IRInput { /** Whether this is a special type that needs custom handling */ isCustomType: boolean; /** Custom type kind for special handling */ - customTypeKind?: 'RequestPurchaseProps' | 'DiscountOfferInputIOS' | 'PurchaseInput'; + customTypeKind?: CustomInputKind; } // ============================================================================ @@ -180,8 +173,6 @@ export interface IROperationField { args: IRArg[]; /** Return type */ returnType: IRType; - /** Whether this is a future field (wrap in Promise) */ - isFuture: boolean; /** Resolved return type (after VoidResult -> Void, Args inlining) */ resolvedReturnType: IRType; } @@ -214,28 +205,6 @@ export interface IRSchema { unions: IRUnion[]; /** Root operation types (Query, Mutation, Subscription) */ operations: IROperation[]; - /** Schema metadata */ - metadata: IRSchemaMetadata; -} - -export interface IRSchemaMetadata { - /** Types marked with # => Union comment */ - unionWrapperNames: Set; - /** Types marked with # Future comment (for Promise wrapping) */ - futureFieldNames: Set; - /** Platform-specific type defaults for discriminated unions */ - platformDefaults: Map; - /** Single-field Args types that can be inlined */ - singleFieldObjects: Map; - /** Union membership map (object name -> set of union names) */ - unionMembership: Map>; - /** Input types with required fields */ - inputsWithRequiredFields: Set; -} - -export interface IRPlatformDefault { - platform: string; - type: string; } // ============================================================================ @@ -247,4 +216,49 @@ export interface SchemaMarkers { unionWrappers: Set; /** Fields marked with # Future */ futureFields: Set; + /** Invalid or duplicate marker ownership detected by the shared parser */ + issues: SchemaMarkerIssue[]; +} + +interface SchemaMarkerIssue { + kind: 'future' | 'union'; + reason: 'duplicate-marker' | 'invalid-placement' | 'invalid-owner' | 'invalid-target' | 'no-effect'; + sourceId: string; + markerLine: number; + targetLine: number | null; + target?: string; + previous?: { + sourceId: string; + markerLine: number; + }; +} + +interface SchemaDeprecationEntry { + kind: string; + name: string; + parentKind?: string; + parentName?: string; + ownerPath: string; + reason: string; + sourceId: string; + line?: number; +} + +interface SchemaDeprecationIssue { + file: string; + line?: number; + message: string; + rule: string; +} + +export interface SchemaDeprecations { + entries: SchemaDeprecationEntry[]; + issues: SchemaDeprecationIssue[]; + operationArguments: Array<{ + rootName: string; + fieldName: string; + argumentName: string; + reason: string; + }>; + typeReasons: Map; } diff --git a/packages/gql/codegen/core/utils.ts b/packages/gql/codegen/core/utils.ts index 0d5997abc..d12029cbf 100644 --- a/packages/gql/codegen/core/utils.ts +++ b/packages/gql/codegen/core/utils.ts @@ -23,14 +23,6 @@ export function toPascalCase(value: string): string { return tokens.map((t) => t.charAt(0).toUpperCase() + t.slice(1)).join(''); } -/** - * Convert to camelCase (e.g., "my_value" -> "myValue") - */ -export function toCamelCase(value: string): string { - const pascal = toPascalCase(value); - return pascal.charAt(0).toLowerCase() + pascal.slice(1); -} - /** * Convert to lowerCamelCase (same as camelCase but preserves more context) */ @@ -92,13 +84,6 @@ export function capitalize(value: string): string { return value.length === 0 ? value : value.charAt(0).toUpperCase() + value.slice(1); } -/** - * Uncapitalize first letter - */ -export function uncapitalize(value: string): string { - return value.length === 0 ? value : value.charAt(0).toLowerCase() + value.slice(1); -} - /** * Convert to camelCase preserving IOS suffix (for Dart/GDScript) * e.g., "daysUntilExpirationIOS" stays "daysUntilExpirationIOS" @@ -120,9 +105,7 @@ export function toCamelCasePreserveIOS(value: string): string { return first; }; const firstToken = formatFirst(); - const restTokens = rest.map((token) => - token === 'IOS' ? 'IOS' : token.charAt(0).toUpperCase() + token.slice(1) - ); + const restTokens = rest.map((token) => (token === 'IOS' ? 'IOS' : token.charAt(0).toUpperCase() + token.slice(1))); return [firstToken, ...restTokens].join(''); } @@ -139,9 +122,7 @@ export function toPascalCasePreserveIOS(value: string): string { .map((token) => token.toLowerCase()); if (tokens.length === 0) return value; const normalized = tokens.map((token) => (token === 'ios' ? 'IOS' : token)); - return normalized.map((token) => - token === 'IOS' ? 'IOS' : token.charAt(0).toUpperCase() + token.slice(1) - ).join(''); + return normalized.map((token) => (token === 'IOS' ? 'IOS' : token.charAt(0).toUpperCase() + token.slice(1))).join(''); } // ============================================================================ @@ -344,153 +325,92 @@ export const GDSCRIPT_KEYWORDS = new Set([ 'NAN', ]); -export const TYPESCRIPT_RESERVED = new Set([ - 'break', - 'case', - 'catch', - 'class', - 'const', - 'continue', - 'debugger', - 'default', - 'delete', - 'do', - 'else', - 'enum', - 'export', - 'extends', - 'false', - 'finally', - 'for', - 'function', - 'if', - 'import', - 'in', - 'instanceof', - 'new', - 'null', - 'return', - 'super', - 'switch', - 'this', - 'throw', - 'true', - 'try', - 'typeof', - 'var', - 'void', - 'while', - 'with', - // Strict mode reserved - 'implements', - 'interface', - 'let', - 'package', - 'private', - 'protected', - 'public', - 'static', - 'yield', -]); - -// ============================================================================ -// Keyword Escaping -// ============================================================================ - -export function escapeSwiftKeyword(name: string): string { - return SWIFT_KEYWORDS.has(name) ? `\`${name}\`` : name; -} - -export function escapeKotlinKeyword(name: string): string { - return KOTLIN_KEYWORDS.has(name) ? `\`${name}\`` : name; -} - -export function escapeDartKeyword(name: string): string { - return DART_KEYWORDS.has(name) ? `${name}_` : name; -} - -export function escapeGDScriptKeyword(name: string): string { - return GDSCRIPT_KEYWORDS.has(name) ? `${name}_` : name; -} - -export function escapeTypeScriptKeyword(name: string): string { - // TypeScript generally doesn't need escaping for property names - return name; -} - // ============================================================================ // Scalar Mappings // ============================================================================ -export const GRAPHQL_TO_SWIFT: Record = { - ID: 'String', - String: 'String', - Boolean: 'Bool', - Int: 'Int', - Float: 'Double', -}; - -export const GRAPHQL_TO_KOTLIN: Record = { - ID: 'String', - String: 'String', - Boolean: 'Boolean', - Int: 'Int', - Float: 'Double', -}; - -export const GRAPHQL_TO_DART: Record = { - ID: 'String', - String: 'String', - Boolean: 'bool', - Int: 'int', - Float: 'double', -}; - -export const GRAPHQL_TO_GDSCRIPT: Record = { - ID: 'String', - String: 'String', - Boolean: 'bool', - Int: 'int', - Float: 'float', -}; - -export const GRAPHQL_TO_TYPESCRIPT: Record = { - ID: 'string', - String: 'string', - Boolean: 'boolean', - Int: 'number', - Float: 'number', +type GraphQLScalarContract = Readonly<{ + typescript: Readonly<{ input: string; output: string }>; + swift: string; + kotlin: string; + dart: string; + gdscript: string; + csharp: string; +}>; + +const GRAPHQL_SCALAR_CONTRACTS: Readonly> = Object.freeze({ + ID: Object.freeze({ + typescript: Object.freeze({ input: 'string', output: 'string' }), + swift: 'String', + kotlin: 'String', + dart: 'String', + gdscript: 'String', + csharp: 'string', + }), + String: Object.freeze({ + typescript: Object.freeze({ input: 'string', output: 'string' }), + swift: 'String', + kotlin: 'String', + dart: 'String', + gdscript: 'String', + csharp: 'string', + }), + Boolean: Object.freeze({ + typescript: Object.freeze({ input: 'boolean', output: 'boolean' }), + swift: 'Bool', + kotlin: 'Boolean', + dart: 'bool', + gdscript: 'bool', + csharp: 'bool', + }), + Int: Object.freeze({ + typescript: Object.freeze({ input: 'number', output: 'number' }), + swift: 'Int', + kotlin: 'Int', + dart: 'int', + gdscript: 'int', + csharp: 'int', + }), + Float: Object.freeze({ + typescript: Object.freeze({ input: 'number', output: 'number' }), + swift: 'Double', + kotlin: 'Double', + dart: 'double', + gdscript: 'float', + csharp: 'double', + }), +}); + +const scalarMapping = (key: Key): Record => + Object.fromEntries(Object.entries(GRAPHQL_SCALAR_CONTRACTS).map(([name, contract]) => [name, contract[key]])); + +export const SUPPORTED_GRAPHQL_SCALARS = new Set(Object.keys(GRAPHQL_SCALAR_CONTRACTS)); +export const GRAPHQL_TO_TYPESCRIPT = scalarMapping('typescript'); +export const GRAPHQL_TO_SWIFT = scalarMapping('swift'); +export const GRAPHQL_TO_KOTLIN = scalarMapping('kotlin'); +export const GRAPHQL_TO_DART = scalarMapping('dart'); +export const GRAPHQL_TO_GDSCRIPT = scalarMapping('gdscript'); +export const GRAPHQL_TO_CSHARP = scalarMapping('csharp'); + +export const requireGraphQLScalarMapping = (mapping: Readonly>, name: string, language: string): string => { + const mapped = mapping[name]; + if (!mapped) { + throw new Error(`Unsupported ${language} GraphQL scalar mapping: ${name}`); + } + return mapped; }; // ============================================================================ // Platform Defaults for Discriminated Unions // ============================================================================ -export const PLATFORM_TYPE_DEFAULTS: Record< - string, - { platform: string; type: string } -> = { +export const PLATFORM_TYPE_DEFAULTS: Record = { ProductIOS: { platform: 'ios', type: 'in-app' }, ProductAndroid: { platform: 'android', type: 'in-app' }, ProductSubscriptionIOS: { platform: 'ios', type: 'subs' }, ProductSubscriptionAndroid: { platform: 'android', type: 'subs' }, }; -// ============================================================================ -// Custom Types -// ============================================================================ - -export const CUSTOM_INPUT_TYPES = new Set([ - 'RequestPurchaseProps', - 'DiscountOfferInputIOS', - 'PurchaseInput', -]); - -export const TYPE_ALIASES: Record = { - PurchaseInput: 'Purchase', - VoidResult: 'Void', -}; - // ============================================================================ // Legacy Aliases for ErrorCode // ============================================================================ @@ -499,69 +419,3 @@ export const ERROR_CODE_LEGACY_ALIASES: Record = { 'receipt-failed': 'purchaseVerificationFailed', ReceiptFailed: 'purchaseVerificationFailed', }; - -// ============================================================================ -// File Header -// ============================================================================ - -export function generateFileHeader(language: string): string[] { - const header = [ - '// ============================================================================', - '// AUTO-GENERATED TYPES — DO NOT EDIT DIRECTLY', - '// Run `npm run generate` after updating any *.graphql schema file.', - '// ============================================================================', - '', - ]; - - switch (language) { - case 'swift': - header.push('import Foundation', ''); - break; - case 'kotlin': - header.push( - '// Suppress unchecked cast warnings for JSON Map parsing - unavoidable due to Kotlin type erasure', - '@file:Suppress("UNCHECKED_CAST")', - '' - ); - break; - case 'dart': - header.push("import 'dart:convert';", ''); - break; - } - - return header; -} - -// ============================================================================ -// Documentation Comments -// ============================================================================ - -export function formatDocComment( - description: string | undefined, - indent: string, - style: 'swift' | 'kotlin' | 'typescript' | 'dart' | 'gdscript' -): string[] { - if (!description) return []; - - const lines = description.split(/\r?\n/); - - switch (style) { - case 'swift': - return lines.map((line) => `${indent}/// ${line}`); - case 'kotlin': - case 'typescript': - case 'dart': - if (lines.length === 1) { - return [`${indent}/** ${lines[0]} */`]; - } - return [ - `${indent}/**`, - ...lines.map((line) => `${indent} * ${line}`), - `${indent} */`, - ]; - case 'gdscript': - return lines.map((line) => `${indent}## ${line}`); - default: - return lines.map((line) => `${indent}// ${line}`); - } -} diff --git a/packages/gql/codegen/index.ts b/packages/gql/codegen/index.ts index a76a7f6ff..9fa1de509 100644 --- a/packages/gql/codegen/index.ts +++ b/packages/gql/codegen/index.ts @@ -17,6 +17,7 @@ import { CSharpPlugin } from './plugins/csharp.js'; import type { CodegenPlugin } from './plugins/base-plugin.js'; import type { IRSchema } from './core/types.js'; import { lintSchema, formatLintResults } from './core/schema-linter.js'; +import { GQL_GENERATED_SOURCE_DIRECTORY, generatedSourceFileName, gqlPackageRelativePath } from '../generated-sync-manifest.mjs'; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); @@ -25,9 +26,44 @@ const __dirname = dirname(__filename); // Configuration // ============================================================================ +const LANGUAGE_PLUGIN_FACTORIES = { + swift: (outputPath: string) => new SwiftPlugin({ outputPath }), + kotlin: (outputPath: string) => new KotlinPlugin({ outputPath }), + dart: (outputPath: string) => new DartPlugin({ outputPath }), + gdscript: (outputPath: string) => new GDScriptPlugin({ outputPath }), + csharp: (outputPath: string) => new CSharpPlugin({ outputPath }), +} as const satisfies Record CodegenPlugin>; + +export type SupportedLanguage = keyof typeof LANGUAGE_PLUGIN_FACTORIES; +export const SUPPORTED_LANGUAGES = Object.freeze(Object.keys(LANGUAGE_PLUGIN_FACTORIES) as SupportedLanguage[]); +export const LANGUAGE_OUTPUT_PATHS = Object.freeze( + Object.fromEntries(SUPPORTED_LANGUAGES.map((language) => [language, generatedSourceFileName(language)])) as Record< + SupportedLanguage, + string + >, +); + +export function normalizeLanguages(languages: readonly string[] | undefined = undefined): SupportedLanguage[] { + const requested = languages ?? SUPPORTED_LANGUAGES; + if (requested.length === 0) { + throw new Error('At least one codegen language is required'); + } + + const normalized: SupportedLanguage[] = []; + for (const language of requested) { + if (!SUPPORTED_LANGUAGES.includes(language as SupportedLanguage)) { + throw new Error(`Unsupported codegen language: ${language}`); + } + if (!normalized.includes(language as SupportedLanguage)) { + normalized.push(language as SupportedLanguage); + } + } + return normalized; +} + export interface GenerateConfig { /** Languages to generate (default: all) */ - languages?: Array<'swift' | 'kotlin' | 'dart' | 'gdscript' | 'csharp'>; + languages?: SupportedLanguage[]; /** Output directory (default: packages/gql/src/generated) */ outputDir?: string; /** Whether to log progress */ @@ -39,13 +75,13 @@ export interface GenerateConfig { // ============================================================================ export class CodeGenerator { - private config: GenerateConfig; + private config: Required; private schema: IRSchema | null = null; constructor(config: GenerateConfig = {}) { this.config = { - languages: config.languages ?? ['swift', 'kotlin'], - outputDir: config.outputDir ?? resolve(__dirname, '../src/generated'), + languages: normalizeLanguages(config.languages), + outputDir: config.outputDir ?? resolve(__dirname, '..', gqlPackageRelativePath(GQL_GENERATED_SOURCE_DIRECTORY)), verbose: config.verbose ?? true, }; } @@ -73,7 +109,7 @@ export class CodeGenerator { this.log(`Found ${this.schema.enums.length} enums, ${this.schema.objects.length} objects, ${this.schema.unions.length} unions`); // Generate for each language - for (const language of this.config.languages!) { + for (const language of this.config.languages) { await this.generateForLanguage(language); } @@ -83,18 +119,14 @@ export class CodeGenerator { /** * Generate code for a specific language */ - private async generateForLanguage(language: string): Promise { + private async generateForLanguage(language: SupportedLanguage): Promise { const plugin = this.createPlugin(language); - if (!plugin) { - this.log(`Skipping ${language} - plugin not implemented`); - return; - } this.log(`Generating ${language}...`); const output = plugin.generate(this.schema!); const outputPath = plugin.getOutputPath(); - const fullPath = resolve(this.config.outputDir!, outputPath); + const fullPath = resolve(this.config.outputDir, outputPath); // Ensure directory exists mkdirSync(dirname(fullPath), { recursive: true }); @@ -107,31 +139,8 @@ export class CodeGenerator { /** * Create a plugin for the given language */ - private createPlugin(language: string): CodegenPlugin | null { - switch (language) { - case 'swift': - return new SwiftPlugin({ - outputPath: 'Types.swift', - }); - case 'kotlin': - return new KotlinPlugin({ - outputPath: 'Types.kt', - }); - case 'dart': - return new DartPlugin({ - outputPath: 'types.dart', - }); - case 'gdscript': - return new GDScriptPlugin({ - outputPath: 'types.gd', - }); - case 'csharp': - return new CSharpPlugin({ - outputPath: 'Types.cs', - }); - default: - return null; - } + private createPlugin(language: SupportedLanguage): CodegenPlugin { + return LANGUAGE_PLUGIN_FACTORIES[language](LANGUAGE_OUTPUT_PATHS[language]); } /** @@ -151,19 +160,14 @@ export class CodeGenerator { async function main() { const args = process.argv.slice(2); - const languages = args.length > 0 - ? args as Array<'swift' | 'kotlin' | 'dart' | 'gdscript' | 'csharp'> - : ['swift', 'kotlin', 'dart', 'gdscript', 'csharp']; - - const generator = new CodeGenerator({ languages }); + const generator = new CodeGenerator({ + languages: args.length > 0 ? normalizeLanguages(args) : undefined, + }); await generator.generate(); } // Run if executed directly (Bun-compatible check) -const isMain = - typeof Bun !== 'undefined' - ? Bun.main === import.meta.path - : import.meta.url === `file://${process.argv[1]}`; +const isMain = typeof Bun !== 'undefined' ? Bun.main === import.meta.path : import.meta.url === `file://${process.argv[1]}`; if (isMain) { main().catch((err) => { @@ -194,4 +198,4 @@ export { GDScriptPlugin } from './plugins/gdscript.js'; export { CSharpPlugin } from './plugins/csharp.js'; export type { IRSchema, IREnum, IRObject, IRUnion, IRType } from './core/types.js'; export { lintSchema, formatLintResults } from './core/schema-linter.js'; -export type { LintResult, LintOptions } from './core/schema-linter.js'; +export type { LintResult } from './core/schema-linter.js'; diff --git a/packages/gql/codegen/plugins/base-plugin.ts b/packages/gql/codegen/plugins/base-plugin.ts index 556baf39e..3fecfbc93 100644 --- a/packages/gql/codegen/plugins/base-plugin.ts +++ b/packages/gql/codegen/plugins/base-plugin.ts @@ -13,9 +13,11 @@ import type { IRInput, IRUnion, IROperation, + IROperationField, IRType, IRField, } from '../core/types.js'; +import { CUSTOM_INPUT_CONTRACTS } from '../../custom-input-contracts.js'; // ============================================================================ // Plugin Interface @@ -165,15 +167,6 @@ export abstract class CodegenPlugin { this.lines.push(line); } - /** - * Add multiple lines to the output - */ - protected emitLines(lines: string[]): void { - for (const line of lines) { - this.emit(line); - } - } - /** * Add a section comment */ @@ -196,10 +189,7 @@ export abstract class CodegenPlugin { /** * Generate documentation comment */ - protected generateDocComment( - description: string | undefined, - indent: string = '' - ): void { + protected generateDocComment(description: string | undefined, indent: string = ''): void { // Override in subclasses for language-specific doc comments if (!description) return; for (const line of description.split(/\r?\n/)) { @@ -208,44 +198,60 @@ export abstract class CodegenPlugin { } /** - * Check if a type is nullable - */ - protected isNullable(type: IRType): boolean { - return type.nullable; - } - - /** - * Get the element type for a list type - */ - protected getListElementType(type: IRType): IRType | undefined { - return type.kind === 'list' ? type.elementType : undefined; - } - - /** - * Check if type is a scalar + * Keep operation argument docs attached to the resolver declaration. Most + * target languages inline GraphQL arguments as method parameters instead of + * generating a separate Args type, so dropping these descriptions would + * also drop directive-owned deprecation guidance. */ - protected isScalar(type: IRType): boolean { - return type.kind === 'scalar'; + protected operationFieldDescription(field: IROperationField): string | undefined { + const argumentDescriptions = field.args + .filter((arg) => arg.description) + .map((arg) => `Parameter ${arg.name}: ${arg.description!.replace(/\s+/g, ' ').trim()}`); + return [field.description, ...argumentDescriptions].filter((value): value is string => Boolean(value)).join('\n') || undefined; } /** - * Check if type is an enum + * Resolve a schema field used by a custom generator path. Custom shapes must + * fail closed instead of silently dropping metadata when the schema drifts. */ - protected isEnum(type: IRType): boolean { - return type.kind === 'enum'; + protected requireField(container: { name: string; fields: IRField[] }, fieldName: string): IRField { + const field = container.fields.find((candidate) => candidate.name === fieldName); + if (!field) { + throw new Error(`${container.name}.${fieldName} is required by the custom generator.`); + } + return field; } /** - * Check if type is a list + * Resolve an entire custom shape and reject additive schema drift. A custom + * generator that silently omits a new field creates a phantom cross-language + * contract, so every custom shape must opt into its exact supported fields. */ - protected isList(type: IRType): boolean { - return type.kind === 'list'; + protected requireExactFields(container: { name: string; fields: IRField[] }, fieldNames: readonly string[]): IRField[] { + const fields = fieldNames.map((fieldName) => this.requireField(container, fieldName)); + const expected = new Set(fieldNames); + const unexpected = container.fields.map((field) => field.name).filter((fieldName) => !expected.has(fieldName)); + if (unexpected.length > 0 || container.fields.length !== fields.length) { + throw new Error( + `${container.name} custom generator fields drifted; expected ${fieldNames.join(', ')}, found ${container.fields.map((field) => field.name).join(', ')}.`, + ); + } + return fields; } /** - * Get the base type name for a named type + * Resolve a custom input in canonical contract order. Language plugins own + * rendering only; the field set and order live in CUSTOM_INPUT_CONTRACTS. */ - protected getTypeName(type: IRType): string | undefined { - return type.name; + protected requireCustomInputFields(irInput: IRInput): IRField[] { + const customTypeKind = irInput.customTypeKind; + if (!customTypeKind || irInput.name !== customTypeKind) { + throw new Error(`${irInput.name} custom generator requires a matching customTypeKind discriminator.`); + } + const contract = CUSTOM_INPUT_CONTRACTS[customTypeKind]; + return this.requireExactFields( + irInput, + contract.map((field) => field.name), + ); } } diff --git a/packages/gql/codegen/plugins/csharp.ts b/packages/gql/codegen/plugins/csharp.ts index 699c5d9a4..36f6ab977 100644 --- a/packages/gql/codegen/plugins/csharp.ts +++ b/packages/gql/codegen/plugins/csharp.ts @@ -20,6 +20,7 @@ */ import { CodegenPlugin, type CodegenPluginConfig } from './base-plugin.js'; +import { generatedFileHeader } from '../core/generated-header.js'; import type { IRSchema, IREnum, @@ -37,38 +38,94 @@ import { toCamelCasePreserveIOS, toConstantCase, capitalize, - PLATFORM_TYPE_DEFAULTS, + GRAPHQL_TO_CSHARP, + requireGraphQLScalarMapping, } from '../core/utils.js'; const CSHARP_KEYWORDS = new Set([ - 'abstract', 'as', 'base', 'bool', 'break', 'byte', 'case', 'catch', 'char', - 'checked', 'class', 'const', 'continue', 'decimal', 'default', 'delegate', - 'do', 'double', 'else', 'enum', 'event', 'explicit', 'extern', 'false', - 'finally', 'fixed', 'float', 'for', 'foreach', 'goto', 'if', 'implicit', - 'in', 'int', 'interface', 'internal', 'is', 'lock', 'long', 'namespace', - 'new', 'null', 'object', 'operator', 'out', 'override', 'params', 'private', - 'protected', 'public', 'readonly', 'ref', 'return', 'sbyte', 'sealed', - 'short', 'sizeof', 'stackalloc', 'static', 'string', 'struct', 'switch', - 'this', 'throw', 'true', 'try', 'typeof', 'uint', 'ulong', 'unchecked', - 'unsafe', 'ushort', 'using', 'virtual', 'void', 'volatile', 'while', + 'abstract', + 'as', + 'base', + 'bool', + 'break', + 'byte', + 'case', + 'catch', + 'char', + 'checked', + 'class', + 'const', + 'continue', + 'decimal', + 'default', + 'delegate', + 'do', + 'double', + 'else', + 'enum', + 'event', + 'explicit', + 'extern', + 'false', + 'finally', + 'fixed', + 'float', + 'for', + 'foreach', + 'goto', + 'if', + 'implicit', + 'in', + 'int', + 'interface', + 'internal', + 'is', + 'lock', + 'long', + 'namespace', + 'new', + 'null', + 'object', + 'operator', + 'out', + 'override', + 'params', + 'private', + 'protected', + 'public', + 'readonly', + 'ref', + 'return', + 'sbyte', + 'sealed', + 'short', + 'sizeof', + 'stackalloc', + 'static', + 'string', + 'struct', + 'switch', + 'this', + 'throw', + 'true', + 'try', + 'typeof', + 'uint', + 'ulong', + 'unchecked', + 'unsafe', + 'ushort', + 'using', + 'virtual', + 'void', + 'volatile', + 'while', ]); -const GRAPHQL_TO_CSHARP: Record = { - ID: 'string', - String: 'string', - Boolean: 'bool', - Int: 'int', - Float: 'double', -}; - const NAMESPACE = 'OpenIap'; // Preserve the published MAUI 1.x CLR signatures until a coordinated 2.0. -const MAUI_1_X_STRING_RESULT_OPERATIONS = new Set([ - 'deepLinkToSubscriptions', - 'finishTransaction', - 'restorePurchases', -]); +const MAUI_1_X_STRING_RESULT_OPERATIONS = new Set(['deepLinkToSubscriptions', 'finishTransaction', 'restorePurchases']); export class CSharpPlugin extends CodegenPlugin { readonly name = 'csharp'; @@ -76,7 +133,6 @@ export class CSharpPlugin extends CodegenPlugin { readonly keywords = CSHARP_KEYWORDS; private schema!: IRSchema; - private enumNames = new Set(); // For each nested-union name, the OUTER union it appears under. Used so the // nested union can inherit from its parent — that way C# pattern matching // works through the chain ProductOrSubscription → Product → ProductIOS. @@ -91,7 +147,7 @@ export class CSharpPlugin extends CodegenPlugin { // ============================================================================ mapScalar(name: string): string { - return GRAPHQL_TO_CSHARP[name] ?? 'string'; + return requireGraphQLScalarMapping(GRAPHQL_TO_CSHARP, name, 'C#'); } mapType(type: IRType): string { @@ -132,8 +188,6 @@ export class CSharpPlugin extends CodegenPlugin { this.schema = schema; this.lines = []; - for (const e of schema.enums) this.enumNames.add(e.name); - // Build a nested-union → outer-union map so nested members can declare // their inheritance and JsonPolymorphism nests correctly. this.nestedUnionParents.clear(); @@ -181,10 +235,7 @@ export class CSharpPlugin extends CodegenPlugin { } generateHeader(): void { - this.emit('// ============================================================================'); - this.emit('// AUTO-GENERATED TYPES — DO NOT EDIT DIRECTLY'); - this.emit('// Run `bun run generate` after updating any *.graphql schema file.'); - this.emit('// ============================================================================'); + for (const line of generatedFileHeader()) this.emit(line); this.emit(''); this.emit('#nullable enable'); this.emit(''); @@ -243,11 +294,7 @@ export class CSharpPlugin extends CodegenPlugin { this.emit(' {'); for (const value of irEnum.values) { const caseName = this.enumValueCase(value.name); - const aliases = new Set([ - value.rawValue, - toConstantCase(value.name), - value.name, - ]); + const aliases = new Set([value.rawValue, toConstantCase(value.name), value.name]); for (const alias of aliases) { this.emit(` ["${alias}"] = ${irEnum.name}.${caseName},`); } @@ -276,7 +323,9 @@ export class CSharpPlugin extends CodegenPlugin { this.emit(''); this.emit(` internal static string ToRawString(${irEnum.name} value) => _toString[value];`); this.emit(` internal static ${irEnum.name} FromRawString(string value) =>`); - this.emit(` _fromString.TryGetValue(value, out var v) ? v : throw new ArgumentException($"Unknown ${irEnum.name} value: {value}");`); + this.emit( + ` _fromString.TryGetValue(value, out var v) ? v : throw new ArgumentException($"Unknown ${irEnum.name} value: {value}");`, + ); this.emit('}'); this.emit(''); @@ -359,6 +408,7 @@ export class CSharpPlugin extends CodegenPlugin { generateObject(irObject: IRObject): void { if (irObject.name === 'VoidResult') { + this.emitDoc(irObject.description); this.emit('public readonly record struct VoidResult;'); this.emit(''); return; @@ -383,7 +433,7 @@ export class CSharpPlugin extends CodegenPlugin { const inheritance = baseTypes.length > 0 ? ` : ${baseTypes.join(', ')}` : ''; this.emit(`public sealed record ${irObject.name}${inheritance}`); this.emit('{'); - this.emitProperties(sortedFields, irObject.name); + this.emitProperties(sortedFields); this.emit('}'); this.emit(''); } @@ -403,8 +453,7 @@ export class CSharpPlugin extends CodegenPlugin { return baseTypes; } - private emitProperties(fields: IRField[], typeName: string): void { - const defaults = PLATFORM_TYPE_DEFAULTS[typeName]; + private emitProperties(fields: IRField[]): void { fields.forEach((field) => { this.emitDoc(field.description, ' '); const propType = this.propertyType(field.type); @@ -426,12 +475,6 @@ export class CSharpPlugin extends CodegenPlugin { } else { this.emit(` public required ${propType} ${propName} { get; init; }`); } - } else if (defaults && field.name === 'platform') { - const defaultValue = `IapPlatform.${toPascalCasePreserveIOS(defaults.platform)}`; - this.emit(` public ${propType} ${propName} { get; init; } = ${defaultValue};`); - } else if (defaults && field.name === 'type') { - const defaultValue = `ProductType.${toPascalCasePreserveIOS(defaults.type)}`; - this.emit(` public ${propType} ${propName} { get; init; } = ${defaultValue};`); } else { this.emit(` public required ${propType} ${propName} { get; init; }`); } @@ -465,19 +508,12 @@ export class CSharpPlugin extends CodegenPlugin { } private csharpStringLiteral(value: string): string { - return `"${value - .replace(/\\/g, '\\\\') - .replace(/"/g, '\\"') - .replace(/\r/g, '\\r') - .replace(/\n/g, '\\n') - .replace(/\t/g, '\\t')}"`; + return `"${value.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\r/g, '\\r').replace(/\n/g, '\\n').replace(/\t/g, '\\t')}"`; } private generateResultUnionObject(irObject: IRObject): void { this.emitDoc(irObject.description); - const entries = [...irObject.resultUnionEntries!].sort((a, b) => - a.fieldName.localeCompare(b.fieldName) - ); + const entries = [...irObject.resultUnionEntries!].sort((a, b) => a.fieldName.localeCompare(b.fieldName)); // Sealed wrapper hierarchy mirroring Kotlin. The actual GraphQL JSON for // these result unions has no `__typename` / `__variant` discriminator — @@ -488,6 +524,7 @@ export class CSharpPlugin extends CodegenPlugin { this.emit(''); for (const entry of entries) { + this.emitDoc(entry.description); const className = `${irObject.name}${capitalize(entry.fieldName)}`; const propType = this.propertyType(entry.type); this.emit(`public sealed record ${className}(${propType} Value) : ${irObject.name};`); @@ -511,7 +548,7 @@ export class CSharpPlugin extends CodegenPlugin { this.emitDoc(irInput.description); this.emit(`public sealed record ${irInput.name}`); this.emit('{'); - this.emitProperties(irInput.fields, irInput.name); + this.emitProperties(irInput.fields); this.emit('}'); this.emit(''); } @@ -531,25 +568,31 @@ export class CSharpPlugin extends CodegenPlugin { this.generateRequestPurchaseProps(irInput); break; case 'DiscountOfferInputIOS': - default: this.generateStandardInput(irInput); break; + default: + throw new Error(`${irInput.name} is marked as a custom input without a C# generator strategy.`); } } private generateRequestPurchaseProps(irInput: IRInput): void { + const [requestPurchase, requestSubscription, type, useAlternativeBilling] = this.requireCustomInputFields(irInput); this.emitDoc(irInput.description); this.emit('public sealed record RequestPurchaseProps : IJsonOnDeserialized'); this.emit('{'); + this.emitDoc(requestPurchase.description, ' '); this.emit(' [JsonPropertyName("requestPurchase")]'); this.emit(' public RequestPurchasePropsByPlatforms? RequestPurchase { get; init; }'); this.emit(''); + this.emitDoc(requestSubscription.description, ' '); this.emit(' [JsonPropertyName("requestSubscription")]'); this.emit(' public RequestSubscriptionPropsByPlatforms? RequestSubscription { get; init; }'); this.emit(''); + this.emitDoc(type.description, ' '); this.emit(' [JsonPropertyName("type")]'); this.emit(' public required ProductQueryType Type { get; init; }'); this.emit(''); + this.emitDoc(useAlternativeBilling.description, ' '); this.emit(' [JsonPropertyName("useAlternativeBilling")]'); this.emit(' public bool? UseAlternativeBilling { get; init; }'); this.emit(''); @@ -558,7 +601,9 @@ export class CSharpPlugin extends CodegenPlugin { this.emit(' var hasPurchase = RequestPurchase is not null;'); this.emit(' var hasSubscription = RequestSubscription is not null;'); this.emit(' if (hasPurchase == hasSubscription)'); - this.emit(' throw new InvalidOperationException("RequestPurchaseProps requires exactly one of requestPurchase or requestSubscription");'); + this.emit( + ' throw new InvalidOperationException("RequestPurchaseProps requires exactly one of requestPurchase or requestSubscription");', + ); this.emit(' if (hasPurchase && Type != ProductQueryType.InApp)'); this.emit(' throw new InvalidOperationException("type must be IN_APP when requestPurchase is provided");'); this.emit(' if (hasSubscription && Type != ProductQueryType.Subs)'); @@ -580,12 +625,10 @@ export class CSharpPlugin extends CodegenPlugin { this.emit(`public interface ${interfaceName}`); this.emit('{'); - const sortedFields = irOperation.fields - .filter((f) => f.name !== '_placeholder') - .sort((a, b) => a.name.localeCompare(b.name)); + const sortedFields = irOperation.fields.filter((f) => f.name !== '_placeholder').sort((a, b) => a.name.localeCompare(b.name)); sortedFields.forEach((field, index) => { - this.emitDoc(field.description, ' '); + this.emitDoc(this.operationFieldDescription(field), ' '); const returnType = this.getOperationReturnType(field); const args = field.args.map((arg) => { const argType = this.propertyType(arg.type); @@ -638,10 +681,5 @@ export class CSharpPlugin extends CodegenPlugin { // XML emission site (attribute or content) without auditing the call shape. function escapeXml(text: string): string { - return text - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"') - .replace(/'/g, '''); + return text.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"').replace(/'/g, '''); } diff --git a/packages/gql/codegen/plugins/dart.ts b/packages/gql/codegen/plugins/dart.ts index 883fed1af..d23080199 100644 --- a/packages/gql/codegen/plugins/dart.ts +++ b/packages/gql/codegen/plugins/dart.ts @@ -6,6 +6,7 @@ */ import { CodegenPlugin, type CodegenPluginConfig } from './base-plugin.js'; +import { generatedFileHeader } from '../core/generated-header.js'; import type { IRSchema, IREnum, @@ -18,13 +19,7 @@ import type { IRField, IROperationField, } from '../core/types.js'; -import { - DART_KEYWORDS, - GRAPHQL_TO_DART, - toPascalCasePreserveIOS, - toKebabCase, - PLATFORM_TYPE_DEFAULTS, -} from '../core/utils.js'; +import { DART_KEYWORDS, GRAPHQL_TO_DART, requireGraphQLScalarMapping, toPascalCasePreserveIOS } from '../core/utils.js'; export class DartPlugin extends CodegenPlugin { readonly name = 'dart'; @@ -32,11 +27,6 @@ export class DartPlugin extends CodegenPlugin { readonly keywords = DART_KEYWORDS; private schema!: IRSchema; - private enumNames = new Set(); - private objectNames = new Set(); - private inputNames = new Set(); - private unionNames = new Set(); - private interfaceNames = new Set(); constructor(config: CodegenPluginConfig) { super(config); @@ -47,7 +37,7 @@ export class DartPlugin extends CodegenPlugin { // ============================================================================ mapScalar(name: string): string { - return GRAPHQL_TO_DART[name] ?? 'dynamic'; + return requireGraphQLScalarMapping(GRAPHQL_TO_DART, name, 'Dart'); } mapType(type: IRType): string { @@ -81,13 +71,6 @@ export class DartPlugin extends CodegenPlugin { generate(schema: IRSchema): string { this.schema = schema; - // Build type name sets for reference - for (const e of schema.enums) this.enumNames.add(e.name); - for (const o of schema.objects) this.objectNames.add(o.name); - for (const i of schema.inputs) this.inputNames.add(i.name); - for (const u of schema.unions) this.unionNames.add(u.name); - for (const i of schema.interfaces) this.interfaceNames.add(i.name); - this.lines = []; this.generateHeader(); @@ -155,10 +138,7 @@ export class DartPlugin extends CodegenPlugin { } generateHeader(): void { - this.emit('// ============================================================================'); - this.emit('// AUTO-GENERATED TYPES — DO NOT EDIT DIRECTLY'); - this.emit('// Run `bun run generate` after updating any *.graphql schema file.'); - this.emit('// ============================================================================'); + for (const line of generatedFileHeader()) this.emit(line); this.emit(''); this.emit('// ignore_for_file: unused_element, unused_field'); this.emit(''); @@ -240,6 +220,7 @@ export class DartPlugin extends CodegenPlugin { generateObject(irObject: IRObject): void { // Handle VoidResult if (irObject.name === 'VoidResult') { + this.generateDocComment(irObject.description); this.emit('typedef VoidResult = void;'); this.emit(''); return; @@ -260,9 +241,7 @@ export class DartPlugin extends CodegenPlugin { const implementsTargets = [...irObject.interfaces, ...otherUnions]; const extendsClause = baseUnion ? ` extends ${baseUnion}` : ''; - const implementsClause = implementsTargets.length > 0 - ? ` implements ${implementsTargets.join(', ')}` - : ''; + const implementsClause = implementsTargets.length > 0 ? ` implements ${implementsTargets.join(', ')}` : ''; this.emit(`class ${irObject.name}${extendsClause}${implementsClause} {`); this.emit(` const ${irObject.name}({`); @@ -272,21 +251,9 @@ export class DartPlugin extends CodegenPlugin { // Constructor parameters for (const field of sortedFields) { - const defaults = PLATFORM_TYPE_DEFAULTS[irObject.name]; - let defaultValue = ''; - - if (defaults) { - if (field.name === 'platform') { - const platformEnum = defaults.platform === 'ios' ? 'IapPlatform.IOS' : 'IapPlatform.Android'; - defaultValue = ` = ${platformEnum}`; - } else if (field.name === 'type') { - const typeEnum = defaults.type === 'in-app' ? 'ProductType.InApp' : 'ProductType.Subs'; - defaultValue = ` = ${typeEnum}`; - } - } - - if (defaultValue) { - this.emit(` this.${this.escapeKeyword(field.name)}${defaultValue},`); + const schemaDefault = this.buildDefaultValueExpression(field); + if (schemaDefault) { + this.emit(` this.${this.escapeKeyword(field.name)} = ${schemaDefault},`); } else if (field.type.nullable) { this.emit(` this.${this.escapeKeyword(field.name)},`); } else { @@ -295,8 +262,9 @@ export class DartPlugin extends CodegenPlugin { } // Special handling for PurchaseAndroid and PurchaseIOS - const needsAlternativeBilling = (irObject.name === 'PurchaseAndroid' || irObject.name === 'PurchaseIOS') - && !sortedFields.some(f => f.name === 'isAlternativeBilling'); + const needsAlternativeBilling = + (irObject.name === 'PurchaseAndroid' || irObject.name === 'PurchaseIOS') && + !sortedFields.some((f) => f.name === 'isAlternativeBilling'); if (needsAlternativeBilling) { this.emit(' this.isAlternativeBilling,'); } @@ -358,10 +326,9 @@ export class DartPlugin extends CodegenPlugin { this.emit(''); // Sort entries alphabetically - const sortedEntries = [...irObject.resultUnionEntries!].sort((a, b) => - a.fieldName.localeCompare(b.fieldName) - ); + const sortedEntries = [...irObject.resultUnionEntries!].sort((a, b) => a.fieldName.localeCompare(b.fieldName)); for (const entry of sortedEntries) { + this.generateDocComment(entry.description); const className = `${irObject.name}${toPascalCasePreserveIOS(entry.fieldName)}`; const valueType = this.getPropertyType(entry.type); this.emit(`class ${className} extends ${irObject.name} {`); @@ -377,17 +344,20 @@ export class DartPlugin extends CodegenPlugin { // ============================================================================ generateInput(irInput: IRInput): void { - // Handle PurchaseInput alias - if (irInput.name === 'PurchaseInput') { - this.emit('typedef PurchaseInput = Purchase;'); - this.emit(''); - return; - } - - // Handle RequestPurchaseProps special case - if (irInput.name === 'RequestPurchaseProps') { - this.generateRequestPurchaseProps(irInput); - return; + if (irInput.isCustomType) { + switch (irInput.customTypeKind) { + case 'PurchaseInput': + this.emit('typedef PurchaseInput = Purchase;'); + this.emit(''); + return; + case 'RequestPurchaseProps': + this.generateRequestPurchaseProps(irInput); + return; + case 'DiscountOfferInputIOS': + break; + default: + throw new Error(`${irInput.name} is marked as a custom input without a Dart generator strategy.`); + } } this.generateDocComment(irInput.description); @@ -423,11 +393,7 @@ export class DartPlugin extends CodegenPlugin { this.emit(` factory ${irInput.name}.fromJson(Map json) {`); this.emit(` return ${irInput.name}(`); for (const field of sortedFields) { - const jsonExpr = this.buildFromJsonExpression( - field.type, - `json['${field.name}']`, - this.buildDefaultValueExpression(field) - ); + const jsonExpr = this.buildFromJsonExpression(field.type, `json['${field.name}']`, this.buildDefaultValueExpression(field)); this.emit(` ${this.escapeKeyword(field.name)}: ${jsonExpr},`); } this.emit(' );'); @@ -449,47 +415,43 @@ export class DartPlugin extends CodegenPlugin { } private generateRequestPurchaseProps(irInput: IRInput): void { + const [requestPurchase, requestSubscription, , useAlternativeBilling] = this.requireCustomInputFields(irInput); this.generateDocComment(irInput.description); // Find the platform-specific types from schema - const purchaseByPlatforms = this.schema.inputs.find(i => i.name === 'RequestPurchasePropsByPlatforms'); - const subsByPlatforms = this.schema.inputs.find(i => i.name === 'RequestSubscriptionPropsByPlatforms'); + const purchaseByPlatforms = this.schema.inputs.find((i) => i.name === 'RequestPurchasePropsByPlatforms'); + const subsByPlatforms = this.schema.inputs.find((i) => i.name === 'RequestSubscriptionPropsByPlatforms'); - // Log warnings if fallback types are used (schema drift detection) if (!purchaseByPlatforms) { - console.warn('[dart] RequestPurchasePropsByPlatforms not found in schema, using fallback types'); + throw new Error('RequestPurchasePropsByPlatforms is required by the Dart custom generator.'); } if (!subsByPlatforms) { - console.warn('[dart] RequestSubscriptionPropsByPlatforms not found in schema, using fallback types'); + throw new Error('RequestSubscriptionPropsByPlatforms is required by the Dart custom generator.'); } const appleName = 'apple'; const googleName = 'google'; - const appleType = purchaseByPlatforms?.fields.find(f => f.name === 'apple') - ? this.mapType(purchaseByPlatforms.fields.find(f => f.name === 'apple')!.type) - : 'RequestPurchaseIosProps'; - const googleType = purchaseByPlatforms?.fields.find(f => f.name === 'google') - ? this.mapType(purchaseByPlatforms.fields.find(f => f.name === 'google')!.type) - : 'RequestPurchaseAndroidProps'; - const appleSubsType = subsByPlatforms?.fields.find(f => f.name === 'apple') - ? this.mapType(subsByPlatforms.fields.find(f => f.name === 'apple')!.type) - : 'RequestSubscriptionIosProps'; - const googleSubsType = subsByPlatforms?.fields.find(f => f.name === 'google') - ? this.mapType(subsByPlatforms.fields.find(f => f.name === 'google')!.type) - : 'RequestSubscriptionAndroidProps'; + const appleType = this.mapType(this.requireField(purchaseByPlatforms, appleName).type); + const googleType = this.mapType(this.requireField(purchaseByPlatforms, googleName).type); + const appleSubsType = this.mapType(this.requireField(subsByPlatforms, appleName).type); + const googleSubsType = this.mapType(this.requireField(subsByPlatforms, googleName).type); this.emit('sealed class RequestPurchaseProps {'); this.emit(' const RequestPurchaseProps._();'); this.emit(''); + this.generateDocComment(requestPurchase.description, ' '); this.emit(' const factory RequestPurchaseProps.inApp(({'); this.emit(` ${appleType}? ${appleName},`); this.emit(` ${googleType}? ${googleName},`); + this.generateDocComment(useAlternativeBilling.description, ' '); this.emit(' bool? useAlternativeBilling,'); this.emit(' }) props) = _InAppPurchase;'); this.emit(''); + this.generateDocComment(requestSubscription.description, ' '); this.emit(' const factory RequestPurchaseProps.subs(({'); this.emit(` ${appleSubsType}? ${appleName},`); this.emit(` ${googleSubsType}? ${googleName},`); + this.generateDocComment(useAlternativeBilling.description, ' '); this.emit(' bool? useAlternativeBilling,'); this.emit(' }) props) = _SubsPurchase;'); this.emit(''); @@ -553,9 +515,7 @@ export class DartPlugin extends CodegenPlugin { // Find shared interfaces const sharedInterfaces = irUnion.sharedInterfaces || []; - const implementsClause = sharedInterfaces.length > 0 - ? ` implements ${sharedInterfaces.join(', ')}` - : ''; + const implementsClause = sharedInterfaces.length > 0 ? ` implements ${sharedInterfaces.join(', ')}` : ''; this.emit(`sealed class ${irUnion.name}${implementsClause} {`); this.emit(` const ${irUnion.name}();`); @@ -571,7 +531,7 @@ export class DartPlugin extends CodegenPlugin { const nestedUnionWrappers = new Map(); for (const member of irUnion.members) { - const nestedUnion = this.schema.unions.find(u => u.name === member.name); + const nestedUnion = this.schema.unions.find((u) => u.name === member.name); if (nestedUnion) { // This member is a union - add its concrete members for (const nestedMember of nestedUnion.members) { @@ -603,7 +563,7 @@ export class DartPlugin extends CodegenPlugin { if (sharedInterfaces.length > 0) { this.emit(''); for (const interfaceName of sharedInterfaces) { - const iface = this.schema.interfaces.find(i => i.name === interfaceName); + const iface = this.schema.interfaces.find((i) => i.name === interfaceName); if (iface) { // Sort fields alphabetically const sortedFields = [...iface.fields].sort((a, b) => a.name.localeCompare(b.name)); @@ -647,12 +607,10 @@ export class DartPlugin extends CodegenPlugin { this.emit(`abstract class ${interfaceName} {`); // Sort fields alphabetically and filter _placeholder - const sortedFields = irOperation.fields - .filter((f) => f.name !== '_placeholder') - .sort((a, b) => a.name.localeCompare(b.name)); + const sortedFields = irOperation.fields.filter((f) => f.name !== '_placeholder').sort((a, b) => a.name.localeCompare(b.name)); for (const field of sortedFields) { - this.generateDocComment(field.description, ' '); + this.generateDocComment(this.operationFieldDescription(field), ' '); const returnType = this.getOperationReturnType(field); if (field.args.length === 0) { @@ -662,12 +620,12 @@ export class DartPlugin extends CodegenPlugin { // Check if we should expand params const expandableParams = ['params', 'options', 'config', 'props']; - const expandableArg = field.args.find(arg => expandableParams.includes(arg.name)); + const expandableArg = field.args.find((arg) => expandableParams.includes(arg.name)); if (expandableArg && expandableArg.type.name) { - const inputType = this.schema.inputs.find(i => i.name === expandableArg.type.name); + const inputType = this.schema.inputs.find((i) => i.name === expandableArg.type.name); if (inputType && inputType.name !== 'RequestPurchaseProps') { - const otherArgs = field.args.filter(arg => arg !== expandableArg); + const otherArgs = field.args.filter((arg) => arg !== expandableArg); this.emit(` Future<${returnType}> ${this.escapeKeyword(field.name)}({`); // Sort expanded fields alphabetically @@ -721,9 +679,7 @@ export class DartPlugin extends CodegenPlugin { this.emit(''); // Sort fields alphabetically and filter _placeholder - const sortedFields = irOperation.fields - .filter((f) => f.name !== '_placeholder') - .sort((a, b) => a.name.localeCompare(b.name)); + const sortedFields = irOperation.fields.filter((f) => f.name !== '_placeholder').sort((a, b) => a.name.localeCompare(b.name)); for (const field of sortedFields) { const pascalField = toPascalCasePreserveIOS(field.name); @@ -737,12 +693,12 @@ export class DartPlugin extends CodegenPlugin { // Check if we should expand params const expandableParams = ['params', 'options', 'config', 'props']; - const expandableArg = field.args.find(arg => expandableParams.includes(arg.name)); + const expandableArg = field.args.find((arg) => expandableParams.includes(arg.name)); if (expandableArg && expandableArg.type.name) { - const inputType = this.schema.inputs.find(i => i.name === expandableArg.type.name); + const inputType = this.schema.inputs.find((i) => i.name === expandableArg.type.name); if (inputType && inputType.name !== 'RequestPurchaseProps') { - const otherArgs = field.args.filter(arg => arg !== expandableArg); + const otherArgs = field.args.filter((arg) => arg !== expandableArg); this.emit(`typedef ${aliasName} = Future<${returnType}> Function({`); // Sort expanded fields alphabetically @@ -818,21 +774,10 @@ export class DartPlugin extends CodegenPlugin { } private getOperationReturnType(field: IROperationField): string { - // Handle VoidResult - if (field.returnType.name === 'VoidResult') { + if (field.resolvedReturnType.name === 'Void') { return 'void'; // void cannot be nullable in Dart } - - // Handle single-field wrapper types (e.g., ProductsArgs -> List) - if (field.returnType.name && field.returnType.name.endsWith('Args')) { - const wrapperObj = this.schema.objects.find(o => o.name === field.returnType.name); - if (wrapperObj && wrapperObj.fields.length === 1) { - const innerType = this.getPropertyType(wrapperObj.fields[0].type); - return field.returnType.nullable ? `${innerType}?` : innerType; - } - } - - return this.getPropertyType(field.returnType); + return this.getPropertyType(field.resolvedReturnType); } private buildFromJsonExpression(type: IRType, sourceExpr: string, defaultExpression?: string | null): string { @@ -855,9 +800,7 @@ export class DartPlugin extends CodegenPlugin { if (defaultExpression) { return `${sourceExpr} == null ? ${defaultExpression} : (${sourceExpr} as num).toDouble()`; } - return type.nullable - ? `(${sourceExpr} as num?)?.toDouble()` - : `(${sourceExpr} as num).toDouble()`; + return type.nullable ? `(${sourceExpr} as num?)?.toDouble()` : `(${sourceExpr} as num).toDouble()`; case 'Int': if (defaultExpression) { return `${sourceExpr} == null ? ${defaultExpression} : ${sourceExpr} as int`; diff --git a/packages/gql/codegen/plugins/gdscript.ts b/packages/gql/codegen/plugins/gdscript.ts index b8d47caff..2b99c31bf 100644 --- a/packages/gql/codegen/plugins/gdscript.ts +++ b/packages/gql/codegen/plugins/gdscript.ts @@ -6,25 +6,9 @@ */ import { CodegenPlugin, type CodegenPluginConfig } from './base-plugin.js'; -import type { - IRSchema, - IREnum, - IRInterface, - IRObject, - IRInput, - IRUnion, - IROperation, - IRType, - IRField, - IROperationField, -} from '../core/types.js'; -import { - GDSCRIPT_KEYWORDS, - GRAPHQL_TO_GDSCRIPT, - toSnakeCase, - toConstantCase, - toKebabCase, -} from '../core/utils.js'; +import { generatedFileHeader } from '../core/generated-header.js'; +import type { IRSchema, IREnum, IRInterface, IRObject, IRInput, IRUnion, IROperation, IRType, IRField } from '../core/types.js'; +import { GDSCRIPT_KEYWORDS, GRAPHQL_TO_GDSCRIPT, requireGraphQLScalarMapping, toSnakeCase, toConstantCase } from '../core/utils.js'; export class GDScriptPlugin extends CodegenPlugin { readonly name = 'gdscript'; @@ -52,7 +36,7 @@ export class GDScriptPlugin extends CodegenPlugin { // ============================================================================ mapScalar(name: string): string { - return GRAPHQL_TO_GDSCRIPT[name] ?? 'Variant'; + return requireGraphQLScalarMapping(GRAPHQL_TO_GDSCRIPT, name, 'GDScript'); } mapType(type: IRType): string { @@ -131,18 +115,13 @@ export class GDScriptPlugin extends CodegenPlugin { return this.buildSchemaDefaultForType(field.type, field.defaultValue); } - private buildSchemaDefaultForType( - type: IRType, - defaultValue: unknown, - ): string | null { + private buildSchemaDefaultForType(type: IRType, defaultValue: unknown): string | null { // Lists recurse per element (e.g. `[TRANSACTIONAL]` in the schema must // become `[InAppMessageCategoryAndroid.TRANSACTIONAL]`, not `[]`) — // mirrors buildDefaultValueForType in the Kotlin/Swift/Dart plugins. if (type.kind === 'list') { if (!Array.isArray(defaultValue)) return null; - const items = defaultValue.map((value) => - this.buildSchemaDefaultForType(type.elementType!, value), - ); + const items = defaultValue.map((value) => this.buildSchemaDefaultForType(type.elementType!, value)); if (items.some((item) => item === null)) return null; return `[${items.join(', ')}]`; } @@ -155,10 +134,7 @@ export class GDScriptPlugin extends CodegenPlugin { if (typeof defaultValue === 'string') { return JSON.stringify(defaultValue); } - if ( - typeof defaultValue === 'number' || - typeof defaultValue === 'boolean' - ) { + if (typeof defaultValue === 'number' || typeof defaultValue === 'boolean') { return String(defaultValue); } } @@ -250,7 +226,7 @@ export class GDScriptPlugin extends CodegenPlugin { this.emit('# Query Types'); this.emit('# ============================================================================'); this.emit(''); - const queryOp = schema.operations.find(op => op.name === 'Query'); + const queryOp = schema.operations.find((op) => op.name === 'Query'); if (queryOp) { this.generateOperation(queryOp); } @@ -259,7 +235,7 @@ export class GDScriptPlugin extends CodegenPlugin { this.emit('# Mutation Types'); this.emit('# ============================================================================'); this.emit(''); - const mutationOp = schema.operations.find(op => op.name === 'Mutation'); + const mutationOp = schema.operations.find((op) => op.name === 'Mutation'); if (mutationOp) { this.generateOperation(mutationOp); } @@ -287,11 +263,8 @@ export class GDScriptPlugin extends CodegenPlugin { } generateHeader(): void { - this.emit('# ============================================================================'); - this.emit('# AUTO-GENERATED TYPES — DO NOT EDIT DIRECTLY'); + for (const line of generatedFileHeader('#')) this.emit(line); this.emit('# Generated from OpenIAP GraphQL schema (https://openiap.dev)'); - this.emit('# Run `bun run generate` to regenerate this file.'); - this.emit('# ============================================================================'); this.emit('# Usage: const Types = preload("types.gd")'); this.emit('# var store: Types.IapStore = Types.IapStore.APPLE'); this.emit('# ============================================================================'); @@ -345,10 +318,8 @@ export class GDScriptPlugin extends CodegenPlugin { } private getEnumUnknownFallback(typeName: string): string | null { - const irEnum = this.schema.enums.find(e => e.name === typeName); - const unknown = irEnum?.values.find( - (value) => value.name.toLowerCase() === 'unknown' || value.rawValue.toLowerCase() === 'unknown' - ); + const irEnum = this.schema.enums.find((e) => e.name === typeName); + const unknown = irEnum?.values.find((value) => value.name.toLowerCase() === 'unknown' || value.rawValue.toLowerCase() === 'unknown'); return unknown ? `${typeName}.${this.enumValueCase(unknown.name)}` : null; } @@ -371,26 +342,6 @@ export class GDScriptPlugin extends CodegenPlugin { this.emit(`${indent}\t${target} = enum_str`); } - private emitEnumListFromDictAssignment(indent: string, target: string, typeName: string, sourceExpression: string): void { - const enumReverseLookup = toConstantCase(typeName) + '_FROM_STRING'; - const fallback = this.getEnumUnknownFallback(typeName); - - this.emit(`${indent}var arr: Array[${typeName}] = []`); - this.emit(`${indent}for item in ${sourceExpression}:`); - if (fallback) { - this.emit(`${indent}\tif item is String:`); - this.emit(`${indent}\t\tarr.append(${enumReverseLookup}.get(item, ${fallback}))`); - this.emit(`${indent}\telse:`); - this.emit(`${indent}\t\tarr.append(item)`); - } else { - this.emit(`${indent}\tif item is String and ${enumReverseLookup}.has(item):`); - this.emit(`${indent}\t\tarr.append(${enumReverseLookup}[item])`); - this.emit(`${indent}\telse:`); - this.emit(`${indent}\t\tarr.append(item)`); - } - this.emit(`${indent}${target} = arr`); - } - private emitEnumListToDictAssignment(indent: string, graphqlName: string, fieldName: string, typeName: string): void { const enumConstName = toConstantCase(typeName) + '_VALUES'; @@ -404,16 +355,14 @@ export class GDScriptPlugin extends CodegenPlugin { } private isEnumList(type: IRType): boolean { - return type.kind === 'list' && - !!type.elementType && - (type.elementType.kind === 'enum' || this.enumNames.has(type.elementType.name!)); + return type.kind === 'list' && !!type.elementType && (type.elementType.kind === 'enum' || this.enumNames.has(type.elementType.name!)); } // ============================================================================ // Interfaces (not used in GDScript, but required by base class) // ============================================================================ - generateInterface(irInterface: IRInterface): void { + generateInterface(_irInterface: IRInterface): void { // GDScript doesn't have interfaces, skip } @@ -431,13 +380,14 @@ export class GDScriptPlugin extends CodegenPlugin { } else { // Field declarations for (const field of fields) { - if (field.description) { - this.emit(`\t## ${field.description.split('\n')[0]}`); - } + this.generateDocComment(field.description, '\t'); const gdType = this.mapType(field.type); const fieldName = this.getGdscriptFieldName(field.name, irObject.name); + const schemaDefaultValue = this.getSchemaDefaultValue(field); const defaultValue = this.getDefaultValue(field.type); - if (field.type.nullable && this.usesNullableVariant(field.type)) { + if (schemaDefaultValue !== null) { + this.emit(`\tvar ${fieldName}: ${gdType} = ${schemaDefaultValue}`); + } else if (field.type.nullable && this.usesNullableVariant(field.type)) { // Nullable value types are emitted as untyped Variant so they can // actually hold `null`. Typed GDScript properties cannot hold // null, so declaring e.g. `var foo: String = ""` collapses the @@ -498,12 +448,7 @@ export class GDScriptPlugin extends CodegenPlugin { } } - private generateListFromDictAssignment( - type: IRType, - graphqlName: string, - fieldName: string, - indent = '\t\t\t' - ): void { + private generateListFromDictAssignment(type: IRType, graphqlName: string, fieldName: string, indent = '\t\t\t'): void { const elementType = type.elementType!; const elementTypeName = elementType.name!; const gdElementType = this.mapType(elementType); @@ -642,9 +587,17 @@ export class GDScriptPlugin extends CodegenPlugin { // ============================================================================ generateInput(irInput: IRInput): void { - if (irInput.name === 'RequestPurchaseProps') { - this.generateRequestPurchasePropsInput(irInput); - return; + if (irInput.isCustomType) { + switch (irInput.customTypeKind) { + case 'RequestPurchaseProps': + this.generateRequestPurchasePropsInput(irInput); + return; + case 'PurchaseInput': + case 'DiscountOfferInputIOS': + break; + default: + throw new Error(`${irInput.name} is marked as a custom input without a GDScript generator strategy.`); + } } this.generateDocComment(irInput.description); @@ -656,9 +609,7 @@ export class GDScriptPlugin extends CodegenPlugin { } else { // Field declarations for (const field of fields) { - if (field.description) { - this.emit(`\t## ${field.description.split('\n')[0]}`); - } + this.generateDocComment(field.description, '\t'); const gdType = this.mapType(field.type); const fieldName = this.getGdscriptFieldName(field.name, irInput.name); const schemaDefaultValue = this.getSchemaDefaultValue(field); @@ -704,25 +655,30 @@ export class GDScriptPlugin extends CodegenPlugin { * reject ambiguous/mismatched dictionaries before they cross a native bridge. */ private generateRequestPurchasePropsInput(irInput: IRInput): void { + const [requestPurchase, requestSubscription, type, useAlternativeBilling] = this.requireCustomInputFields(irInput); this.generateDocComment(irInput.description); this.emit('class RequestPurchaseProps:'); - this.emit('\t## Per-platform purchase request props'); + this.generateDocComment(requestPurchase.description, '\t'); this.emit('\tvar request: RequestPurchasePropsByPlatforms'); - this.emit('\t## Per-platform subscription request props'); + this.generateDocComment(requestSubscription.description, '\t'); this.emit('\tvar request_subscription: RequestSubscriptionPropsByPlatforms'); - this.emit('\t## Explicit purchase type hint (defaults to in-app)'); + this.generateDocComment(type.description, '\t'); this.emit('\tvar type: ProductQueryType = ProductQueryType.IN_APP'); - this.emit('\t## @deprecated Use enableBillingProgramAndroid in InitConnectionConfig instead.'); + this.generateDocComment(useAlternativeBilling.description, '\t'); this.emit('\tvar use_alternative_billing: Variant = null'); this.emit(''); - this.emit('\tstatic func in_app(platforms: RequestPurchasePropsByPlatforms, use_alternative_billing_value: Variant = null) -> RequestPurchaseProps:'); + this.emit( + '\tstatic func in_app(platforms: RequestPurchasePropsByPlatforms, use_alternative_billing_value: Variant = null) -> RequestPurchaseProps:', + ); this.emit('\t\tvar obj = RequestPurchaseProps.new()'); this.emit('\t\tobj.request = platforms'); this.emit('\t\tobj.type = ProductQueryType.IN_APP'); this.emit('\t\tobj.use_alternative_billing = use_alternative_billing_value'); this.emit('\t\treturn obj'); this.emit(''); - this.emit('\tstatic func subs(platforms: RequestSubscriptionPropsByPlatforms, use_alternative_billing_value: Variant = null) -> RequestPurchaseProps:'); + this.emit( + '\tstatic func subs(platforms: RequestSubscriptionPropsByPlatforms, use_alternative_billing_value: Variant = null) -> RequestPurchaseProps:', + ); this.emit('\t\tvar obj = RequestPurchaseProps.new()'); this.emit('\t\tobj.request_subscription = platforms'); this.emit('\t\tobj.type = ProductQueryType.SUBS'); @@ -738,10 +694,14 @@ export class GDScriptPlugin extends CodegenPlugin { this.emit('\t\tvar obj = RequestPurchaseProps.new()'); this.emit('\t\tif has_purchase:'); this.emit('\t\t\tvar purchase_value = data["requestPurchase"]'); - this.emit('\t\t\tobj.request = RequestPurchasePropsByPlatforms.from_dict(purchase_value) if purchase_value is Dictionary else purchase_value'); + this.emit( + '\t\t\tobj.request = RequestPurchasePropsByPlatforms.from_dict(purchase_value) if purchase_value is Dictionary else purchase_value', + ); this.emit('\t\telse:'); this.emit('\t\t\tvar subscription_value = data["requestSubscription"]'); - this.emit('\t\t\tobj.request_subscription = RequestSubscriptionPropsByPlatforms.from_dict(subscription_value) if subscription_value is Dictionary else subscription_value'); + this.emit( + '\t\t\tobj.request_subscription = RequestSubscriptionPropsByPlatforms.from_dict(subscription_value) if subscription_value is Dictionary else subscription_value', + ); this.emit('\t\tvar expected_type = ProductQueryType.IN_APP if has_purchase else ProductQueryType.SUBS'); this.emit('\t\tobj.type = expected_type'); this.emit('\t\tif data.has("type") and data["type"] != null:'); @@ -768,7 +728,9 @@ export class GDScriptPlugin extends CodegenPlugin { this.emit('\t\tif has_purchase:'); this.emit('\t\t\tdict["requestPurchase"] = request.to_dict() if request.has_method("to_dict") else request'); this.emit('\t\telse:'); - this.emit('\t\t\tdict["requestSubscription"] = request_subscription.to_dict() if request_subscription.has_method("to_dict") else request_subscription'); + this.emit( + '\t\t\tdict["requestSubscription"] = request_subscription.to_dict() if request_subscription.has_method("to_dict") else request_subscription', + ); this.emit('\t\tdict["type"] = PRODUCT_QUERY_TYPE_VALUES.get(type, type)'); this.emit('\t\tif use_alternative_billing != null:'); this.emit('\t\t\tdict["useAlternativeBilling"] = use_alternative_billing'); @@ -833,7 +795,7 @@ export class GDScriptPlugin extends CodegenPlugin { // Unions (not used directly in GDScript) // ============================================================================ - generateUnion(irUnion: IRUnion): void { + generateUnion(_irUnion: IRUnion): void { // GDScript doesn't have unions, use Variant } @@ -842,6 +804,7 @@ export class GDScriptPlugin extends CodegenPlugin { // ============================================================================ generateOperation(irOperation: IROperation): void { + this.generateDocComment(irOperation.description); this.emit(`class ${irOperation.name}:`); // Use schema field order, don't filter _placeholder const fields = irOperation.fields; @@ -850,9 +813,7 @@ export class GDScriptPlugin extends CodegenPlugin { this.emit('\tpass'); } else { for (const field of fields) { - if (field.description) { - this.emit(`\t## ${field.description.split('\n')[0]}`); - } + this.generateDocComment(field.description, '\t'); this.emit(`\tclass ${field.name}Field:`); this.emit(`\t\tconst name = "${field.name}"`); @@ -864,9 +825,7 @@ export class GDScriptPlugin extends CodegenPlugin { for (const arg of field.args) { const argType = this.mapType(arg.type); const argSnakeName = this.escapeKeyword(toSnakeCase(arg.name)); - if (arg.description) { - this.emit(`\t\t\t## ${arg.description.split('\n')[0]}`); - } + this.generateDocComment(arg.description, '\t\t\t'); if (arg.type.nullable) { this.emit(`\t\t\tvar ${argSnakeName}: Variant = null`); } else { @@ -882,12 +841,7 @@ export class GDScriptPlugin extends CodegenPlugin { if (arg.type.kind === 'list') { this.generateListFromDictAssignment(arg.type, arg.name, argSnakeName, '\t\t\t\t\t'); } else if (arg.type.kind === 'enum') { - this.emitEnumFromDictAssignment( - '\t\t\t\t\t', - `obj.${argSnakeName}`, - arg.type.name!, - `data["${arg.name}"]` - ); + this.emitEnumFromDictAssignment('\t\t\t\t\t', `obj.${argSnakeName}`, arg.type.name!, `data["${arg.name}"]`); } else { this.emit(`\t\t\t\t\tobj.${argSnakeName} = data["${arg.name}"]`); } @@ -922,9 +876,8 @@ export class GDScriptPlugin extends CodegenPlugin { } // Return type info - const returnTypeName = field.returnType.kind === 'list' - ? field.returnType.elementType?.name || 'Variant' - : field.returnType.name || 'Variant'; + const returnTypeName = + field.returnType.kind === 'list' ? field.returnType.elementType?.name || 'Variant' : field.returnType.name || 'Variant'; const isArray = field.returnType.kind === 'list'; this.emit(`\t\tconst return_type = "${returnTypeName}"`); this.emit(`\t\tconst is_array = ${isArray}`); @@ -935,14 +888,12 @@ export class GDScriptPlugin extends CodegenPlugin { } private generateApiHelpers(irOperation: IROperation): void { - const fields = irOperation.fields.filter(f => f.name !== '_placeholder'); + const fields = irOperation.fields.filter((f) => f.name !== '_placeholder'); for (const field of fields) { const snakeName = toSnakeCase(field.name); - if (field.description) { - this.emit(`## ${field.description.split('\n')[0]}`); - } + this.generateDocComment(field.description); // Build parameters const params: string[] = []; diff --git a/packages/gql/codegen/plugins/kotlin.ts b/packages/gql/codegen/plugins/kotlin.ts index edbd060c6..02294a7f6 100644 --- a/packages/gql/codegen/plugins/kotlin.ts +++ b/packages/gql/codegen/plugins/kotlin.ts @@ -5,6 +5,7 @@ */ import { CodegenPlugin, type CodegenPluginConfig } from './base-plugin.js'; +import { generatedFileHeader } from '../core/generated-header.js'; import type { IRSchema, IREnum, @@ -20,11 +21,10 @@ import type { import { KOTLIN_KEYWORDS, GRAPHQL_TO_KOTLIN, + requireGraphQLScalarMapping, toPascalCase, - toKebabCase, toConstantCase, capitalize, - PLATFORM_TYPE_DEFAULTS, } from '../core/utils.js'; interface CompatibleDataClassShape { @@ -34,16 +34,7 @@ interface CompatibleDataClassShape { const COMPATIBLE_DATA_CLASS_SHAPES: Record = { PurchaseError: { - primaryFields: [ - 'code', - 'debugMessage', - 'isEmptyProductList', - 'message', - 'productId', - 'productIds', - 'productType', - 'responseCode', - ], + primaryFields: ['code', 'debugMessage', 'isEmptyProductList', 'message', 'productId', 'productIds', 'productType', 'responseCode'], extraFields: ['subResponseCodeAndroid'], }, UserChoiceBillingDetails: { @@ -79,7 +70,7 @@ export class KotlinPlugin extends CodegenPlugin { // ============================================================================ mapScalar(name: string): string { - return GRAPHQL_TO_KOTLIN[name] ?? 'String'; + return requireGraphQLScalarMapping(GRAPHQL_TO_KOTLIN, name, 'Kotlin'); } mapType(type: IRType): string { @@ -183,10 +174,7 @@ export class KotlinPlugin extends CodegenPlugin { } generateHeader(): void { - this.emit('// ============================================================================'); - this.emit('// AUTO-GENERATED TYPES — DO NOT EDIT DIRECTLY'); - this.emit('// Run `bun run generate` after updating any *.graphql schema file.'); - this.emit('// ============================================================================'); + for (const line of generatedFileHeader()) this.emit(line); this.emit(''); this.emit('// Suppress unchecked cast warnings for JSON Map parsing - unavoidable due to Kotlin type erasure'); this.emit('@file:Suppress("UNCHECKED_CAST")'); @@ -264,6 +252,7 @@ export class KotlinPlugin extends CodegenPlugin { generateObject(irObject: IRObject): void { // Handle VoidResult if (irObject.name === 'VoidResult') { + this.generateDocComment(irObject.description); this.emit('public typealias VoidResult = Unit'); this.emit(''); return; @@ -302,7 +291,7 @@ export class KotlinPlugin extends CodegenPlugin { const suffix = index === sortedFields.length - 1 ? '' : ','; const overrideKeyword = field.isOverride ? 'override ' : ''; - const defaultValue = this.getObjectFieldDefault(irObject.name, field); + const defaultValue = this.getObjectFieldDefault(field); this.emit(` ${overrideKeyword}val ${propertyName}: ${propertyType}${defaultValue}${suffix}`); }); @@ -346,10 +335,7 @@ export class KotlinPlugin extends CodegenPlugin { } /** Preserve published data-class JVM descriptors for additive fields. */ - private generateCompatibleDataClass( - irObject: IRObject, - shape: CompatibleDataClassShape - ): void { + private generateCompatibleDataClass(irObject: IRObject, shape: CompatibleDataClassShape): void { const field = (name: string): IRField => { const value = irObject.fields.find((candidate) => candidate.name === name); if (!value) throw new Error(`${irObject.name} is missing ${name}`); @@ -369,7 +355,7 @@ export class KotlinPlugin extends CodegenPlugin { primaryFields.forEach((value, index) => { this.generateDocComment(value.description, ' '); const suffix = index === primaryFields.length - 1 ? '' : ','; - const defaultValue = this.getObjectFieldDefault(irObject.name, value); + const defaultValue = this.getObjectFieldDefault(value); this.emit(` val ${value.name}: ${this.getPropertyType(value.type)}${defaultValue}${suffix}`); }); this.emit(') {'); @@ -384,7 +370,7 @@ export class KotlinPlugin extends CodegenPlugin { this.emit(' constructor('); for (const value of primaryFields) { - const defaultValue = this.getObjectFieldDefault(irObject.name, value); + const defaultValue = this.getObjectFieldDefault(value); this.emit(` ${value.name}: ${this.getPropertyType(value.type)}${defaultValue},`); } extraFields.forEach((value, index) => { @@ -424,14 +410,9 @@ export class KotlinPlugin extends CodegenPlugin { this.emit(''); } - private getObjectFieldDefault(objectName: string, field: IRField): string { - const defaults = PLATFORM_TYPE_DEFAULTS[objectName]; - if (defaults && field.name === 'platform') { - return ` = IapPlatform.${toPascalCase(defaults.platform)}`; - } - if (defaults && field.name === 'type') { - return ` = ProductType.${toPascalCase(defaults.type)}`; - } + private getObjectFieldDefault(field: IRField): string { + const schemaDefault = this.buildDefaultValueExpression(field); + if (schemaDefault) return ` = ${schemaDefault}`; return field.type.nullable ? ' = null' : ''; } @@ -441,10 +422,9 @@ export class KotlinPlugin extends CodegenPlugin { this.emit(''); // Sort entries alphabetically - const sortedEntries = [...irObject.resultUnionEntries!].sort((a, b) => - a.fieldName.localeCompare(b.fieldName) - ); + const sortedEntries = [...irObject.resultUnionEntries!].sort((a, b) => a.fieldName.localeCompare(b.fieldName)); for (const entry of sortedEntries) { + this.generateDocComment(entry.description); const className = `${irObject.name}${capitalize(entry.fieldName)}`; const propertyType = this.getPropertyType(entry.type); this.emit(`public data class ${className}(val value: ${propertyType}) : ${irObject.name}`); @@ -501,19 +481,15 @@ export class KotlinPlugin extends CodegenPlugin { `json["${field.name}"]`, false, true, - this.buildDefaultValueExpression(field) + this.buildDefaultValueExpression(field), ); this.emit(` val ${propertyName} = ${expression}`); } // Null check for required fields (excluding enums which have fallbacks) - const requiredFields = sortedFields.filter( - (f) => !f.type.nullable && !this.hasSchemaDefault(f) && f.type.kind !== 'enum' - ); + const requiredFields = sortedFields.filter((f) => !f.type.nullable && !this.hasSchemaDefault(f) && f.type.kind !== 'enum'); if (requiredFields.length > 0) { - const nullChecks = requiredFields - .map((f) => `${this.escapeKeyword(this.fieldNameCase(f.name))} == null`) - .join(' || '); + const nullChecks = requiredFields.map((f) => `${this.escapeKeyword(this.fieldNameCase(f.name))} == null`).join(' || '); this.emit(` if (${nullChecks}) return null`); } @@ -535,7 +511,7 @@ export class KotlinPlugin extends CodegenPlugin { `json["${field.name}"]`, false, false, - this.buildDefaultValueExpression(field) + this.buildDefaultValueExpression(field), ); this.emit(` ${propertyName} = ${expression},`); } @@ -559,10 +535,7 @@ export class KotlinPlugin extends CodegenPlugin { } /** Preserve published input data-class JVM descriptors for additive fields. */ - private generateCompatibleInputDataClass( - irInput: IRInput, - shape: CompatibleDataClassShape - ): void { + private generateCompatibleInputDataClass(irInput: IRInput, shape: CompatibleDataClassShape): void { const field = (name: string): IRField => { const value = irInput.fields.find((candidate) => candidate.name === name); if (!value) throw new Error(`${irInput.name} is missing ${name}`); @@ -588,9 +561,7 @@ export class KotlinPlugin extends CodegenPlugin { primaryFields.forEach((value, index) => { this.generateDocComment(value.description, ' '); const suffix = index === primaryFields.length - 1 ? '' : ','; - this.emit( - ` val ${value.name}: ${this.getPropertyType(value.type)}${defaultValue(value)}${suffix}` - ); + this.emit(` val ${value.name}: ${this.getPropertyType(value.type)}${defaultValue(value)}${suffix}`); }); this.emit(') {'); this.emit(''); @@ -604,15 +575,11 @@ export class KotlinPlugin extends CodegenPlugin { this.emit(' constructor('); for (const value of primaryFields) { - this.emit( - ` ${value.name}: ${this.getPropertyType(value.type)}${defaultValue(value)},` - ); + this.emit(` ${value.name}: ${this.getPropertyType(value.type)}${defaultValue(value)},`); } extraFields.forEach((value, index) => { const extraDefault = index === 0 ? '' : ' = null'; - this.emit( - ` ${value.name}: ${this.getPropertyType(value.type)}${extraDefault},` - ); + this.emit(` ${value.name}: ${this.getPropertyType(value.type)}${extraDefault},`); }); this.emit(' ) : this('); for (const value of primaryFields) { @@ -634,7 +601,7 @@ export class KotlinPlugin extends CodegenPlugin { `json["${value.name}"]`, false, false, - this.buildDefaultValueExpression(value) + this.buildDefaultValueExpression(value), ); this.emit(` ${value.name} = ${expression},`); } @@ -667,7 +634,7 @@ export class KotlinPlugin extends CodegenPlugin { this.generateStandardInput(irInput); break; default: - this.generateStandardInput(irInput); + throw new Error(`${irInput.name} is marked as a custom input without a Kotlin generator strategy.`); } } @@ -701,19 +668,15 @@ export class KotlinPlugin extends CodegenPlugin { `json["${field.name}"]`, false, true, - this.buildDefaultValueExpression(field) + this.buildDefaultValueExpression(field), ); this.emit(` val ${propertyName} = ${expression}`); } // Null check for required fields (excluding enums which have fallbacks) - const requiredFields = irInput.fields.filter( - (f) => !f.type.nullable && !this.hasSchemaDefault(f) && f.type.kind !== 'enum' - ); + const requiredFields = irInput.fields.filter((f) => !f.type.nullable && !this.hasSchemaDefault(f) && f.type.kind !== 'enum'); if (requiredFields.length > 0) { - const nullChecks = requiredFields - .map((f) => `${this.escapeKeyword(this.fieldNameCase(f.name))} == null`) - .join(' || '); + const nullChecks = requiredFields.map((f) => `${this.escapeKeyword(this.fieldNameCase(f.name))} == null`).join(' || '); this.emit(` if (${nullChecks}) return null`); } @@ -735,7 +698,7 @@ export class KotlinPlugin extends CodegenPlugin { `json["${field.name}"]`, false, false, - this.buildDefaultValueExpression(field) + this.buildDefaultValueExpression(field), ); this.emit(` ${propertyName} = ${expression},`); } @@ -759,16 +722,23 @@ export class KotlinPlugin extends CodegenPlugin { } private generateRequestPurchaseProps(irInput: IRInput): void { + const [requestPurchase, requestSubscription, type, useAlternativeBilling] = this.requireCustomInputFields(irInput); this.generateDocComment(irInput.description); this.emit('public data class RequestPurchaseProps('); this.emit(' val request: Request,'); + this.generateDocComment(type.description, ' '); this.emit(' val type: ProductQueryType,'); + this.generateDocComment(useAlternativeBilling.description, ' '); this.emit(' val useAlternativeBilling: Boolean? = null'); this.emit(') {'); this.emit(' init {'); this.emit(' when (request) {'); - this.emit(' is Request.Purchase -> require(type == ProductQueryType.InApp) { "type must be IN_APP when request is purchase" }'); - this.emit(' is Request.Subscription -> require(type == ProductQueryType.Subs) { "type must be SUBS when request is subscription" }'); + this.emit( + ' is Request.Purchase -> require(type == ProductQueryType.InApp) { "type must be IN_APP when request is purchase" }', + ); + this.emit( + ' is Request.Subscription -> require(type == ProductQueryType.Subs) { "type must be SUBS when request is subscription" }', + ); this.emit(' }'); this.emit(' }'); this.emit(''); @@ -785,13 +755,17 @@ export class KotlinPlugin extends CodegenPlugin { this.emit(' val request = Request.Purchase(RequestPurchasePropsByPlatforms.fromJson(purchaseJson))'); this.emit(' val finalType = rawType ?: ProductQueryType.InApp'); this.emit(' require(finalType == ProductQueryType.InApp) { "type must be IN_APP when requestPurchase is provided" }'); - this.emit(' return RequestPurchaseProps(request = request, type = finalType, useAlternativeBilling = useAlternativeBilling)'); + this.emit( + ' return RequestPurchaseProps(request = request, type = finalType, useAlternativeBilling = useAlternativeBilling)', + ); this.emit(' }'); this.emit(' if (subscriptionJson != null) {'); this.emit(' val request = Request.Subscription(RequestSubscriptionPropsByPlatforms.fromJson(subscriptionJson))'); this.emit(' val finalType = rawType ?: ProductQueryType.Subs'); this.emit(' require(finalType == ProductQueryType.Subs) { "type must be SUBS when requestSubscription is provided" }'); - this.emit(' return RequestPurchaseProps(request = request, type = finalType, useAlternativeBilling = useAlternativeBilling)'); + this.emit( + ' return RequestPurchaseProps(request = request, type = finalType, useAlternativeBilling = useAlternativeBilling)', + ); this.emit(' }'); this.emit(' error("RequestPurchaseProps branch validation failed")'); this.emit(' }'); @@ -811,7 +785,9 @@ export class KotlinPlugin extends CodegenPlugin { this.emit(' }'); this.emit(''); this.emit(' sealed class Request {'); + this.generateDocComment(requestPurchase.description, ' '); this.emit(' data class Purchase(val value: RequestPurchasePropsByPlatforms) : Request()'); + this.generateDocComment(requestSubscription.description, ' '); this.emit(' data class Subscription(val value: RequestSubscriptionPropsByPlatforms) : Request()'); this.emit(' }'); this.emit('}'); @@ -825,9 +801,7 @@ export class KotlinPlugin extends CodegenPlugin { generateUnion(irUnion: IRUnion): void { this.generateDocComment(irUnion.description); - const implementations = irUnion.sharedInterfaces.length > 0 - ? ` : ${irUnion.sharedInterfaces.join(', ')}` - : ''; + const implementations = irUnion.sharedInterfaces.length > 0 ? ` : ${irUnion.sharedInterfaces.join(', ')}` : ''; this.emit(`public sealed interface ${irUnion.name}${implementations} {`); this.emit(' fun toJson(): Map'); this.emit(''); @@ -837,7 +811,11 @@ export class KotlinPlugin extends CodegenPlugin { // Collect all concrete members and their delegate targets const nestedUnions = new Set(); - const concreteMembers: Array<{ name: string; delegateTo: string; isNested: boolean }> = []; + const concreteMembers: Array<{ + name: string; + delegateTo: string; + isNested: boolean; + }> = []; for (const member of irUnion.members) { if (member.isNestedUnion) { @@ -907,12 +885,10 @@ export class KotlinPlugin extends CodegenPlugin { this.emit(`public interface ${interfaceName} {`); // Sort fields alphabetically and filter _placeholder - const sortedFields = irOperation.fields - .filter((f) => f.name !== '_placeholder') - .sort((a, b) => a.name.localeCompare(b.name)); + const sortedFields = irOperation.fields.filter((f) => f.name !== '_placeholder').sort((a, b) => a.name.localeCompare(b.name)); for (const field of sortedFields) { - this.generateDocComment(field.description, ' '); + this.generateDocComment(this.operationFieldDescription(field), ' '); const returnType = this.getOperationReturnType(field); const args = field.args.map((arg) => { @@ -932,9 +908,7 @@ export class KotlinPlugin extends CodegenPlugin { private generateOperationHelpers(irOperation: IROperation): void { // Sort fields alphabetically and filter _placeholder - const sortedFields = irOperation.fields - .filter((f) => f.name !== '_placeholder') - .sort((a, b) => a.name.localeCompare(b.name)); + const sortedFields = irOperation.fields.filter((f) => f.name !== '_placeholder').sort((a, b) => a.name.localeCompare(b.name)); if (sortedFields.length === 0) return; @@ -984,7 +958,7 @@ export class KotlinPlugin extends CodegenPlugin { sourceExpr: string, isListElement: boolean = false, forNullableFromJson: boolean = false, - defaultExpression?: string | null + defaultExpression?: string | null, ): string { if (type.kind === 'list') { const element = this.buildFromJsonExpression(type.elementType!, 'it', true, forNullableFromJson); @@ -1005,32 +979,24 @@ export class KotlinPlugin extends CodegenPlugin { if (defaultExpression) { return `(${sourceExpr} as? Number)?.toDouble() ?: ${defaultExpression}`; } - return useNullable - ? `(${sourceExpr} as? Number)?.toDouble()` - : `(${sourceExpr} as? Number)?.toDouble() ?: 0.0`; + return useNullable ? `(${sourceExpr} as? Number)?.toDouble()` : `(${sourceExpr} as? Number)?.toDouble() ?: 0.0`; case 'Int': if (defaultExpression) { return `(${sourceExpr} as? Number)?.toInt() ?: ${defaultExpression}`; } - return useNullable - ? `(${sourceExpr} as? Number)?.toInt()` - : `(${sourceExpr} as? Number)?.toInt() ?: 0`; + return useNullable ? `(${sourceExpr} as? Number)?.toInt()` : `(${sourceExpr} as? Number)?.toInt() ?: 0`; case 'Boolean': if (defaultExpression) { return `${sourceExpr} as? Boolean ?: ${defaultExpression}`; } - return useNullable - ? `${sourceExpr} as? Boolean` - : `${sourceExpr} as? Boolean ?: false`; + return useNullable ? `${sourceExpr} as? Boolean` : `${sourceExpr} as? Boolean ?: false`; case 'ID': case 'String': default: if (defaultExpression) { return `${sourceExpr} as? String ?: ${defaultExpression}`; } - return useNullable - ? `${sourceExpr} as? String` - : `${sourceExpr} as? String ?: ""`; + return useNullable ? `${sourceExpr} as? String` : `${sourceExpr} as? String ?: ""`; } } @@ -1068,7 +1034,7 @@ export class KotlinPlugin extends CodegenPlugin { return `(${sourceExpr} as? Map)?.let { ${callTarget}.fromJson(it) }`; } // Check if input has required fields (nullable fromJson) - const isInputWithRequired = this.schema.metadata.inputsWithRequiredFields.has(callTarget); + const isInputWithRequired = this.schema.inputs.find(({ name }) => name === callTarget)?.hasRequiredFields ?? false; if (isInputWithRequired) { return `(${sourceExpr} as? Map)?.let { ${callTarget}.fromJson(it) } ?: throw IllegalArgumentException("Missing or invalid required object for ${callTarget}")`; } @@ -1084,9 +1050,7 @@ export class KotlinPlugin extends CodegenPlugin { if (inner === 'it') { return accessorExpr; } - return type.nullable - ? `${accessorExpr}?.map { ${inner} }` - : `${accessorExpr}.map { ${inner} }`; + return type.nullable ? `${accessorExpr}?.map { ${inner} }` : `${accessorExpr}.map { ${inner} }`; } if (type.kind === 'enum') { @@ -1137,9 +1101,7 @@ export class KotlinPlugin extends CodegenPlugin { if (type.kind !== 'enum' || !type.name) return null; const irEnum = this.schema.enums.find((e) => e.name === type.name); const unknownValue = irEnum?.values.find((value) => value.name.toLowerCase().startsWith('unknown')); - return unknownValue - ? `${type.name}.${this.escapeKeyword(this.enumValueCase(unknownValue.name))}` - : null; + return unknownValue ? `${type.name}.${this.escapeKeyword(this.enumValueCase(unknownValue.name))}` : null; } // ============================================================================ diff --git a/packages/gql/codegen/plugins/swift.ts b/packages/gql/codegen/plugins/swift.ts index ffacdb1e5..7f66313b3 100644 --- a/packages/gql/codegen/plugins/swift.ts +++ b/packages/gql/codegen/plugins/swift.ts @@ -5,6 +5,7 @@ */ import { CodegenPlugin, type CodegenPluginConfig } from './base-plugin.js'; +import { generatedFileHeader } from '../core/generated-header.js'; import type { IRSchema, IREnum, @@ -17,15 +18,7 @@ import type { IRField, IROperationField, } from '../core/types.js'; -import { - SWIFT_KEYWORDS, - GRAPHQL_TO_SWIFT, - toLowerCamelCase, - toKebabCase, - capitalize, - PLATFORM_TYPE_DEFAULTS, - ERROR_CODE_LEGACY_ALIASES, -} from '../core/utils.js'; +import { SWIFT_KEYWORDS, GRAPHQL_TO_SWIFT, requireGraphQLScalarMapping, toLowerCamelCase, capitalize } from '../core/utils.js'; export class SwiftPlugin extends CodegenPlugin { readonly name = 'swift'; @@ -43,7 +36,7 @@ export class SwiftPlugin extends CodegenPlugin { // ============================================================================ mapScalar(name: string): string { - return GRAPHQL_TO_SWIFT[name] ?? 'String'; + return requireGraphQLScalarMapping(GRAPHQL_TO_SWIFT, name, 'Swift'); } mapType(type: IRType): string { @@ -75,10 +68,7 @@ export class SwiftPlugin extends CodegenPlugin { // ============================================================================ generateHeader(): void { - this.emit('// ============================================================================'); - this.emit('// AUTO-GENERATED TYPES — DO NOT EDIT DIRECTLY'); - this.emit('// Run `bun run generate` after updating any *.graphql schema file.'); - this.emit('// ============================================================================'); + for (const line of generatedFileHeader()) this.emit(line); this.emit(''); this.emit('import Foundation'); this.emit(''); @@ -100,11 +90,14 @@ export class SwiftPlugin extends CodegenPlugin { // Add custom initializer for ErrorCode to handle legacy aliases if (irEnum.isErrorCode) { - // Legacy aliases: old error codes that map to new ones - const legacyAliases: Record = { - 'receipt-failed': 'purchaseVerificationFailed', - 'ReceiptFailed': 'purchaseVerificationFailed', - }; + // The transformer owns legacy alias mapping in the IR. Build the reverse + // lookup here so this emitter never duplicates compatibility literals. + const legacyAliases = new Map( + irEnum.values.flatMap((value) => { + const targetCase = this.escapeKeyword(this.enumValueCase(value.name)); + return value.legacyAliases.map((alias) => [alias, targetCase] as const); + }), + ); this.emit(''); this.emit(' /// Custom initializer to handle both kebab-case and camelCase error codes'); @@ -119,7 +112,7 @@ export class SwiftPlugin extends CodegenPlugin { const camelCaseName = value.name.charAt(0).toUpperCase() + value.name.slice(1); // Check if this case is a legacy alias that should map to another case - const aliasTarget = legacyAliases[rawValue] || legacyAliases[camelCaseName]; + const aliasTarget = legacyAliases.get(rawValue) ?? legacyAliases.get(camelCaseName); if (aliasTarget && aliasTarget === caseName) { // This case IS the target - just use normal handling @@ -174,6 +167,7 @@ export class SwiftPlugin extends CodegenPlugin { generateObject(irObject: IRObject): void { // Handle VoidResult if (irObject.name === 'VoidResult') { + this.generateDocComment(irObject.description); this.emit('public typealias VoidResult = Void'); this.emit(''); return; @@ -198,13 +192,10 @@ export class SwiftPlugin extends CodegenPlugin { const propertyType = this.getPropertyType(field.type); const propertyName = this.escapeKeyword(this.fieldNameCase(field.name)); - // Handle platform defaults - const defaults = PLATFORM_TYPE_DEFAULTS[irObject.name]; + const schemaDefault = this.buildDefaultValueExpression(field); let defaultValue = ''; - if (defaults && field.name === 'platform') { - defaultValue = ` = .${defaults.platform}`; - } else if (defaults && field.name === 'type') { - defaultValue = ` = .${defaults.type === 'in-app' ? 'inApp' : 'subs'}`; + if (schemaDefault) { + defaultValue = ` = ${schemaDefault}`; } else if (field.type.nullable) { // Default nullable properties to nil so the synthesized memberwise // initializer can omit them — existing call sites that construct @@ -230,10 +221,9 @@ export class SwiftPlugin extends CodegenPlugin { this.emit(`public enum ${irObject.name} {`); // Sort entries alphabetically - const sortedEntries = [...irObject.resultUnionEntries!].sort((a, b) => - a.fieldName.localeCompare(b.fieldName) - ); + const sortedEntries = [...irObject.resultUnionEntries!].sort((a, b) => a.fieldName.localeCompare(b.fieldName)); for (const entry of sortedEntries) { + this.generateDocComment(entry.description, ' '); const caseName = this.escapeKeyword(this.enumValueCase(entry.fieldName)); const payloadType = this.getPropertyType(entry.type); this.emit(` case ${caseName}(${payloadType})`); @@ -308,6 +298,8 @@ export class SwiftPlugin extends CodegenPlugin { case 'RequestPurchaseProps': this.generateRequestPurchaseProps(irInput); break; + default: + throw new Error(`${irInput.name} is marked as a custom input without a Swift generator strategy.`); } } @@ -337,13 +329,13 @@ export class SwiftPlugin extends CodegenPlugin { } private generateDiscountOfferInputIOS(irInput: IRInput): void { + const fields = this.requireCustomInputFields(irInput); this.generateDocComment(irInput.description); this.emit('public struct DiscountOfferInputIOS: Codable {'); - this.emit(' public var identifier: String'); - this.emit(' public var keyIdentifier: String'); - this.emit(' public var nonce: String'); - this.emit(' public var signature: String'); - this.emit(' public var timestamp: Double'); + for (const field of fields) { + this.generateDocComment(field.description, ' '); + this.emit(` public var ${field.name}: ${this.getPropertyType(field.type)}`); + } this.emit(''); this.emit(' public init(identifier: String, keyIdentifier: String, nonce: String, signature: String, timestamp: Double) {'); this.emit(' self.identifier = identifier'); @@ -392,10 +384,13 @@ export class SwiftPlugin extends CodegenPlugin { } private generateRequestPurchaseProps(irInput: IRInput): void { + const [requestPurchase, requestSubscription, type, useAlternativeBilling] = this.requireCustomInputFields(irInput); this.generateDocComment(irInput.description); this.emit('public struct RequestPurchaseProps: Codable {'); this.emit(' public var request: Request'); + this.generateDocComment(type.description, ' '); this.emit(' public var type: ProductQueryType'); + this.generateDocComment(useAlternativeBilling.description, ' '); this.emit(' public var useAlternativeBilling: Bool?'); this.emit(''); this.emit(' public init(request: Request, type: ProductQueryType? = nil, useAlternativeBilling: Bool? = nil) {'); @@ -425,14 +420,20 @@ export class SwiftPlugin extends CodegenPlugin { this.emit(' let decodedType = try container.decodeIfPresent(ProductQueryType.self, forKey: .type)'); this.emit(' self.useAlternativeBilling = try container.decodeIfPresent(Bool.self, forKey: .useAlternativeBilling)'); this.emit(' let purchase = try container.decodeIfPresent(RequestPurchasePropsByPlatforms.self, forKey: .requestPurchase)'); - this.emit(' let subscription = try container.decodeIfPresent(RequestSubscriptionPropsByPlatforms.self, forKey: .requestSubscription)'); + this.emit( + ' let subscription = try container.decodeIfPresent(RequestSubscriptionPropsByPlatforms.self, forKey: .requestSubscription)', + ); this.emit(' guard (purchase == nil) != (subscription == nil) else {'); - this.emit(' throw DecodingError.dataCorruptedError(forKey: .requestPurchase, in: container, debugDescription: "RequestPurchaseProps requires exactly one of requestPurchase or requestSubscription.")'); + this.emit( + ' throw DecodingError.dataCorruptedError(forKey: .requestPurchase, in: container, debugDescription: "RequestPurchaseProps requires exactly one of requestPurchase or requestSubscription.")', + ); this.emit(' }'); this.emit(' if let purchase {'); this.emit(' let finalType = decodedType ?? .inApp'); this.emit(' guard finalType == .inApp else {'); - this.emit(' throw DecodingError.dataCorruptedError(forKey: .type, in: container, debugDescription: "type must be IN_APP when requestPurchase is provided")'); + this.emit( + ' throw DecodingError.dataCorruptedError(forKey: .type, in: container, debugDescription: "type must be IN_APP when requestPurchase is provided")', + ); this.emit(' }'); this.emit(' self.request = .purchase(purchase)'); this.emit(' self.type = finalType'); @@ -441,13 +442,17 @@ export class SwiftPlugin extends CodegenPlugin { this.emit(' if let subscription {'); this.emit(' let finalType = decodedType ?? .subs'); this.emit(' guard finalType == .subs else {'); - this.emit(' throw DecodingError.dataCorruptedError(forKey: .type, in: container, debugDescription: "type must be SUBS when requestSubscription is provided")'); + this.emit( + ' throw DecodingError.dataCorruptedError(forKey: .type, in: container, debugDescription: "type must be SUBS when requestSubscription is provided")', + ); this.emit(' }'); this.emit(' self.request = .subscription(subscription)'); this.emit(' self.type = finalType'); this.emit(' return'); this.emit(' }'); - this.emit(' throw DecodingError.dataCorruptedError(forKey: .requestPurchase, in: container, debugDescription: "RequestPurchaseProps branch validation failed.")'); + this.emit( + ' throw DecodingError.dataCorruptedError(forKey: .requestPurchase, in: container, debugDescription: "RequestPurchaseProps branch validation failed.")', + ); this.emit(' }'); this.emit(''); this.emit(' public func encode(to encoder: Encoder) throws {'); @@ -463,7 +468,9 @@ export class SwiftPlugin extends CodegenPlugin { this.emit(' }'); this.emit(''); this.emit(' public enum Request {'); + this.generateDocComment(requestPurchase.description, ' '); this.emit(' case purchase(RequestPurchasePropsByPlatforms)'); + this.generateDocComment(requestSubscription.description, ' '); this.emit(' case subscription(RequestSubscriptionPropsByPlatforms)'); this.emit(' }'); this.emit('}'); @@ -628,12 +635,10 @@ export class SwiftPlugin extends CodegenPlugin { this.emit(`public protocol ${protocolName} {`); // Sort fields alphabetically and filter _placeholder - const sortedFields = irOperation.fields - .filter((f) => f.name !== '_placeholder') - .sort((a, b) => a.name.localeCompare(b.name)); + const sortedFields = irOperation.fields.filter((f) => f.name !== '_placeholder').sort((a, b) => a.name.localeCompare(b.name)); for (const field of sortedFields) { - this.generateDocComment(field.description, ' '); + this.generateDocComment(this.operationFieldDescription(field), ' '); const returnType = this.getOperationReturnType(field); if (field.args.length === 0) { @@ -661,9 +666,7 @@ export class SwiftPlugin extends CodegenPlugin { private generateOperationHelpers(irOperation: IROperation): void { // Sort fields alphabetically and filter _placeholder - const sortedFields = irOperation.fields - .filter((f) => f.name !== '_placeholder') - .sort((a, b) => a.name.localeCompare(b.name)); + const sortedFields = irOperation.fields.filter((f) => f.name !== '_placeholder').sort((a, b) => a.name.localeCompare(b.name)); if (sortedFields.length === 0) return; diff --git a/packages/gql/codegen/templates/dart/enum.hbs b/packages/gql/codegen/templates/dart/enum.hbs deleted file mode 100644 index 7cdd9bbab..000000000 --- a/packages/gql/codegen/templates/dart/enum.hbs +++ /dev/null @@ -1,28 +0,0 @@ -{{#if description}} -/// {{{description}}} -{{/if}} -enum {{name}} { -{{#each values}} -{{#if description}} - /// {{{description}}} -{{/if}} - {{caseName}}('{{rawValue}}'){{#unless isLast}},{{/unless}}{{#if isLast}};{{/if}} -{{/each}} - - const {{name}}(this.rawValue); - final String rawValue; - - static {{name}} fromJson(String value) { - return switch (value) { -{{#each values}} - '{{rawValue}}' => {{caseName}}, -{{#each legacyValues}} - '{{this}}' => {{../caseName}}, -{{/each}} -{{/each}} - _ => throw ArgumentError('Unknown {{name}} value: $value'), - }; - } - - String toJson() => rawValue; -} diff --git a/packages/gql/codegen/templates/dart/header.hbs b/packages/gql/codegen/templates/dart/header.hbs deleted file mode 100644 index ef845d70f..000000000 --- a/packages/gql/codegen/templates/dart/header.hbs +++ /dev/null @@ -1,4 +0,0 @@ -// ============================================================================ -// AUTO-GENERATED TYPES — DO NOT EDIT DIRECTLY -// Run `bun run generate` after updating any *.graphql schema file. -// ============================================================================ diff --git a/packages/gql/codegen/templates/dart/input.hbs b/packages/gql/codegen/templates/dart/input.hbs deleted file mode 100644 index 0a718a7d9..000000000 --- a/packages/gql/codegen/templates/dart/input.hbs +++ /dev/null @@ -1,27 +0,0 @@ -{{#if description}} -/// {{{description}}} -{{/if}} -class {{name}} { -{{#each fields}} -{{#if description}} - /// {{{description}}} -{{/if}} - {{declarationType}} {{propertyName}}; -{{/each}} - - {{name}}({{constructorParams}}); - - factory {{name}}.fromJson(Map json) { - return {{name}}( -{{#each fields}} - {{propertyName}}: {{fromJsonExpr}}, -{{/each}} - ); - } - - Map toJson() => { -{{#each fields}} - '{{graphqlName}}': {{toJsonExpr}}, -{{/each}} - }; -} diff --git a/packages/gql/codegen/templates/dart/interface.hbs b/packages/gql/codegen/templates/dart/interface.hbs deleted file mode 100644 index 286fd0a84..000000000 --- a/packages/gql/codegen/templates/dart/interface.hbs +++ /dev/null @@ -1,13 +0,0 @@ -{{#if description}} -/// {{{description}}} -{{/if}} -abstract class {{name}} { -{{#each fields}} -{{#if description}} - /// {{{description}}} -{{/if}} - {{type}} get {{propertyName}}; -{{/each}} - - Map toJson(); -} diff --git a/packages/gql/codegen/templates/dart/object.hbs b/packages/gql/codegen/templates/dart/object.hbs deleted file mode 100644 index 28daec95a..000000000 --- a/packages/gql/codegen/templates/dart/object.hbs +++ /dev/null @@ -1,34 +0,0 @@ -{{#if description}} -/// {{{description}}} -{{/if}} -class {{name}}{{implements}} { -{{#each fields}} -{{#if annotation}} - {{annotation}} -{{/if}} -{{#if description}} - /// {{{description}}} -{{/if}} - {{declarationType}} {{propertyName}}; -{{/each}} - - {{name}}({{constructorParams}}); - - factory {{name}}.fromJson(Map json) { - return {{name}}( -{{#each fields}} - {{propertyName}}: {{fromJsonExpr}}, -{{/each}} - ); - } - -{{#if hasUnionOverride}} - @override -{{/if}} - Map toJson() => { - '__typename': '{{name}}', -{{#each fields}} - '{{graphqlName}}': {{toJsonExpr}}, -{{/each}} - }; -} diff --git a/packages/gql/codegen/templates/dart/operation-helpers.hbs b/packages/gql/codegen/templates/dart/operation-helpers.hbs deleted file mode 100644 index 5d0941d77..000000000 --- a/packages/gql/codegen/templates/dart/operation-helpers.hbs +++ /dev/null @@ -1,13 +0,0 @@ -// MARK: - {{name}} Helpers - -{{#each fields}} -typedef {{aliasName}} = Future<{{returnType}}> Function({{paramsSignature}}); -{{/each}} - -class {{handlersName}} { -{{#each fields}} - {{aliasName}}? {{escapedName}}; -{{/each}} - - {{handlersName}}({{constructorParams}}); -} diff --git a/packages/gql/codegen/templates/dart/operation-interface.hbs b/packages/gql/codegen/templates/dart/operation-interface.hbs deleted file mode 100644 index c336cc816..000000000 --- a/packages/gql/codegen/templates/dart/operation-interface.hbs +++ /dev/null @@ -1,11 +0,0 @@ -{{#if description}} -/// {{{description}}} -{{/if}} -abstract class {{interfaceName}} { -{{#each fields}} -{{#if description}} - /// {{{description}}} -{{/if}} - Future<{{returnType}}> {{escapedName}}({{argsSignature}}); -{{/each}} -} diff --git a/packages/gql/codegen/templates/dart/result-union.hbs b/packages/gql/codegen/templates/dart/result-union.hbs deleted file mode 100644 index 816f53697..000000000 --- a/packages/gql/codegen/templates/dart/result-union.hbs +++ /dev/null @@ -1,12 +0,0 @@ -{{#if description}} -/// {{{description}}} -{{/if}} -sealed class {{name}} {} - -{{#each entries}} -class {{className}} extends {{../name}} { - final {{type}} value; - {{className}}(this.value); -} - -{{/each}} diff --git a/packages/gql/codegen/templates/dart/union.hbs b/packages/gql/codegen/templates/dart/union.hbs deleted file mode 100644 index 6eda55996..000000000 --- a/packages/gql/codegen/templates/dart/union.hbs +++ /dev/null @@ -1,34 +0,0 @@ -{{#if description}} -/// {{{description}}} -{{/if}} -sealed class {{name}}{{implements}} { - Map toJson(); - - static {{name}} fromJson(Map json) { - return switch (json['__typename']) { -{{#each concreteMembers}} -{{#if isNested}} - '{{typeName}}' => {{wrapperName}}({{delegateTo}}.fromJson(json)), -{{else}} - '{{typeName}}' => {{delegateTo}}.fromJson(json), -{{/if}} -{{/each}} - _ => throw ArgumentError('Unknown __typename for {{name}}: ${json["__typename"]}'), - }; - } -} - -{{#each nestedUnionWrappers}} -class {{wrapperName}} extends {{parentUnionName}} { -{{#each interfaceFields}} - @override - {{type}} get {{propertyName}} => value.{{propertyName}}; -{{/each}} - final {{unionName}} value; - {{wrapperName}}(this.value); - - @override - Map toJson() => value.toJson(); -} - -{{/each}} diff --git a/packages/gql/codegen/templates/gdscript/enum.hbs b/packages/gql/codegen/templates/gdscript/enum.hbs deleted file mode 100644 index 85ad97e03..000000000 --- a/packages/gql/codegen/templates/gdscript/enum.hbs +++ /dev/null @@ -1,10 +0,0 @@ -{{#if description}} -{{{gd_doc description}}} -{{/if}} -class {{name}}: -{{#each values}} -{{#if description}} - {{{gd_doc description}}} -{{/if}} - const {{caseName}} = "{{rawValue}}" -{{/each}} diff --git a/packages/gql/codegen/templates/gdscript/header.hbs b/packages/gql/codegen/templates/gdscript/header.hbs deleted file mode 100644 index 98d341948..000000000 --- a/packages/gql/codegen/templates/gdscript/header.hbs +++ /dev/null @@ -1,7 +0,0 @@ -# ============================================================================ -# AUTO-GENERATED TYPES — DO NOT EDIT DIRECTLY -# Run `bun run generate` after updating any *.graphql schema file. -# ============================================================================ - -class_name Types -extends RefCounted diff --git a/packages/gql/codegen/templates/gdscript/input.hbs b/packages/gql/codegen/templates/gdscript/input.hbs deleted file mode 100644 index 69a067bd9..000000000 --- a/packages/gql/codegen/templates/gdscript/input.hbs +++ /dev/null @@ -1,29 +0,0 @@ -{{#if description}} -## {{{description}}} -{{/if}} -class {{name}}: -{{#each fields}} -{{#if description}} - ## {{{description}}} -{{/if}} - var {{propertyName}}: {{type}} -{{/each}} - - func _init({{initParams}}) -> void: -{{#each fields}} - self.{{propertyName}} = {{paramName}} -{{/each}} - - static func from_json(json: Dictionary) -> {{name}}: - return {{name}}.new( -{{#each fields}} - {{fromJsonExpr}}{{#unless isLast}},{{/unless}} -{{/each}} - ) - - func to_json() -> Dictionary: - return { -{{#each fields}} - "{{graphqlName}}": {{toJsonExpr}}{{#unless isLast}},{{/unless}} -{{/each}} - } diff --git a/packages/gql/codegen/templates/gdscript/interface.hbs b/packages/gql/codegen/templates/gdscript/interface.hbs deleted file mode 100644 index 69a067bd9..000000000 --- a/packages/gql/codegen/templates/gdscript/interface.hbs +++ /dev/null @@ -1,29 +0,0 @@ -{{#if description}} -## {{{description}}} -{{/if}} -class {{name}}: -{{#each fields}} -{{#if description}} - ## {{{description}}} -{{/if}} - var {{propertyName}}: {{type}} -{{/each}} - - func _init({{initParams}}) -> void: -{{#each fields}} - self.{{propertyName}} = {{paramName}} -{{/each}} - - static func from_json(json: Dictionary) -> {{name}}: - return {{name}}.new( -{{#each fields}} - {{fromJsonExpr}}{{#unless isLast}},{{/unless}} -{{/each}} - ) - - func to_json() -> Dictionary: - return { -{{#each fields}} - "{{graphqlName}}": {{toJsonExpr}}{{#unless isLast}},{{/unless}} -{{/each}} - } diff --git a/packages/gql/codegen/templates/gdscript/object.hbs b/packages/gql/codegen/templates/gdscript/object.hbs deleted file mode 100644 index a3bc3ec6b..000000000 --- a/packages/gql/codegen/templates/gdscript/object.hbs +++ /dev/null @@ -1,30 +0,0 @@ -{{#if description}} -## {{{description}}} -{{/if}} -class {{name}}{{extends}}: -{{#each fields}} -{{#if description}} - ## {{{description}}} -{{/if}} - var {{propertyName}}: {{type}}{{defaultValue}} -{{/each}} - - func _init({{initParams}}) -> void: -{{#each fields}} - self.{{propertyName}} = {{paramName}} -{{/each}} - - static func from_json(json: Dictionary) -> {{name}}: - return {{name}}.new( -{{#each fields}} - {{fromJsonExpr}}{{#unless isLast}},{{/unless}} -{{/each}} - ) - - func to_json() -> Dictionary: - return { - "__typename": "{{name}}"{{#if hasFields}},{{/if}} -{{#each fields}} - "{{graphqlName}}": {{toJsonExpr}}{{#unless isLast}},{{/unless}} -{{/each}} - } diff --git a/packages/gql/codegen/templates/gdscript/operation.hbs b/packages/gql/codegen/templates/gdscript/operation.hbs deleted file mode 100644 index 2af00a89d..000000000 --- a/packages/gql/codegen/templates/gdscript/operation.hbs +++ /dev/null @@ -1,17 +0,0 @@ -# MARK: - {{kind}} - -{{#if description}} -## {{{description}}} -{{/if}} -class {{resolverName}}: -{{#each fields}} -{{#if description}} - ## {{description}} -{{/if}} - var {{propertyName}}: Callable - -{{/each}} - func _init({{initParams}}) -> void: -{{#each fields}} - self.{{propertyName}} = {{paramName}} -{{/each}} diff --git a/packages/gql/codegen/templates/gdscript/result-union.hbs b/packages/gql/codegen/templates/gdscript/result-union.hbs deleted file mode 100644 index 97185b753..000000000 --- a/packages/gql/codegen/templates/gdscript/result-union.hbs +++ /dev/null @@ -1,12 +0,0 @@ -{{#if description}} -## {{{description}}} -{{/if}} -class {{name}}: -{{#each entries}} - var {{fieldName}}: {{type}} -{{/each}} - - func _init({{initParams}}) -> void: -{{#each entries}} - self.{{fieldName}} = p_{{fieldName}} -{{/each}} diff --git a/packages/gql/codegen/templates/gdscript/union.hbs b/packages/gql/codegen/templates/gdscript/union.hbs deleted file mode 100644 index a3f5c39bb..000000000 --- a/packages/gql/codegen/templates/gdscript/union.hbs +++ /dev/null @@ -1,23 +0,0 @@ -{{#if description}} -## {{{description}}} -{{/if}} -class {{name}}: - var value: Variant - - func _init(p_value: Variant) -> void: - self.value = p_value - - static func from_json(json: Dictionary) -> {{name}}: - var typename = json.get("__typename", "") - match typename: -{{#each concreteMembers}} - "{{typeName}}": - return {{../name}}.new({{delegateTo}}.from_json(json)) -{{/each}} - push_error("Unknown __typename for {{name}}: " + typename) - return null - - func to_json() -> Dictionary: - if value != null and value.has_method("to_json"): - return value.to_json() - return {} diff --git a/packages/gql/codegen/templates/kotlin/enum.hbs b/packages/gql/codegen/templates/kotlin/enum.hbs deleted file mode 100644 index 134f84a2f..000000000 --- a/packages/gql/codegen/templates/kotlin/enum.hbs +++ /dev/null @@ -1,31 +0,0 @@ -{{#if description}} -/** - * {{{description}}} - */ -{{/if}} -public enum class {{name}}(val rawValue: String) { -{{#each values}} -{{#if description}} - /** - * {{{description}}} - */ -{{/if}} - {{caseName}}("{{rawValue}}"){{#unless isLast}},{{/unless}} -{{/each}} - - companion object { - fun fromJson(value: String): {{name}} = when (value) { -{{#each values}} - "{{rawValue}}" -> {{../name}}.{{caseName}} -{{#each legacyValues}} -{{#unless (eq this ../rawValue)}} - "{{this}}" -> {{../../name}}.{{../caseName}} -{{/unless}} -{{/each}} -{{/each}} - else -> throw IllegalArgumentException("Unknown {{name}} value: $value") - } - } - - fun toJson(): String = rawValue -} diff --git a/packages/gql/codegen/templates/kotlin/header.hbs b/packages/gql/codegen/templates/kotlin/header.hbs deleted file mode 100644 index 9da6dd4dd..000000000 --- a/packages/gql/codegen/templates/kotlin/header.hbs +++ /dev/null @@ -1,7 +0,0 @@ -// ============================================================================ -// AUTO-GENERATED TYPES — DO NOT EDIT DIRECTLY -// Run `bun run generate` after updating any *.graphql schema file. -// ============================================================================ - -// Suppress unchecked cast warnings for JSON Map parsing - unavoidable due to Kotlin type erasure -@file:Suppress("UNCHECKED_CAST") diff --git a/packages/gql/codegen/templates/kotlin/input.hbs b/packages/gql/codegen/templates/kotlin/input.hbs deleted file mode 100644 index 0fadcc1b2..000000000 --- a/packages/gql/codegen/templates/kotlin/input.hbs +++ /dev/null @@ -1,47 +0,0 @@ -{{#if description}} -/** - * {{{description}}} - */ -{{/if}} -public data class {{name}}( -{{#each fields}} -{{#if description}} - /** - * {{{description}}} - */ -{{/if}} - val {{propertyName}}: {{type}}{{defaultValue}}{{#unless isLast}},{{/unless}} -{{/each}} -) { - companion object { -{{#if hasRequiredFields}} - fun fromJson(json: Map): {{name}}? { -{{#each fields}} - val {{propertyName}} = {{fromJsonExpr}} -{{/each}} -{{#if requiredFieldsNullCheck}} - if ({{requiredFieldsNullCheck}}) return null -{{/if}} - return {{name}}( -{{#each fields}} - {{propertyName}} = {{propertyName}}, -{{/each}} - ) - } -{{else}} - fun fromJson(json: Map): {{name}} { - return {{name}}( -{{#each fields}} - {{propertyName}} = {{fromJsonExpr}}, -{{/each}} - ) - } -{{/if}} - } - - fun toJson(): Map = mapOf( -{{#each fields}} - "{{graphqlName}}" to {{toJsonExpr}}, -{{/each}} - ) -} diff --git a/packages/gql/codegen/templates/kotlin/interface.hbs b/packages/gql/codegen/templates/kotlin/interface.hbs deleted file mode 100644 index a8e69cab6..000000000 --- a/packages/gql/codegen/templates/kotlin/interface.hbs +++ /dev/null @@ -1,15 +0,0 @@ -{{#if description}} -/** - * {{{description}}} - */ -{{/if}} -public interface {{name}} { -{{#each fields}} -{{#if description}} - /** - * {{{description}}} - */ -{{/if}} - val {{propertyName}}: {{type}} -{{/each}} -} diff --git a/packages/gql/codegen/templates/kotlin/object.hbs b/packages/gql/codegen/templates/kotlin/object.hbs deleted file mode 100644 index 3ddf2afb8..000000000 --- a/packages/gql/codegen/templates/kotlin/object.hbs +++ /dev/null @@ -1,33 +0,0 @@ -{{#if description}} -/** - * {{{description}}} - */ -{{/if}} -public data class {{name}}( -{{#each fields}} -{{#if description}} - /** - * {{{description}}} - */ -{{/if}} - {{#if isOverride}}override {{/if}}val {{propertyName}}: {{type}}{{defaultValue}}{{#unless isLast}},{{/unless}} -{{/each}} -){{#if implements}} : {{implements}}{{/if}} { - - companion object { - fun fromJson(json: Map): {{name}} { - return {{name}}( -{{#each fields}} - {{propertyName}} = {{fromJsonExpr}}, -{{/each}} - ) - } - } - - {{#if hasUnionOverride}}override {{/if}}fun toJson(): Map = mapOf( - "__typename" to "{{name}}", -{{#each fields}} - "{{graphqlName}}" to {{toJsonExpr}}, -{{/each}} - ) -} diff --git a/packages/gql/codegen/templates/kotlin/operation-helpers.hbs b/packages/gql/codegen/templates/kotlin/operation-helpers.hbs deleted file mode 100644 index 22bfa8f11..000000000 --- a/packages/gql/codegen/templates/kotlin/operation-helpers.hbs +++ /dev/null @@ -1,15 +0,0 @@ -// MARK: - {{name}} Helpers - -{{#each fields}} -{{#if hasArgs}} -public typealias {{aliasName}} = suspend ({{paramsSignature}}) -> {{returnType}} -{{else}} -public typealias {{aliasName}} = suspend () -> {{returnType}} -{{/if}} -{{/each}} - -public data class {{handlersName}}( -{{#each fields}} - val {{escapedName}}: {{aliasName}}? = null{{#unless isLast}},{{/unless}} -{{/each}} -) diff --git a/packages/gql/codegen/templates/kotlin/operation-interface.hbs b/packages/gql/codegen/templates/kotlin/operation-interface.hbs deleted file mode 100644 index 9be1a6b21..000000000 --- a/packages/gql/codegen/templates/kotlin/operation-interface.hbs +++ /dev/null @@ -1,15 +0,0 @@ -{{#if description}} -/** - * {{{description}}} - */ -{{/if}} -public interface {{interfaceName}} { -{{#each fields}} -{{#if description}} - /** - * {{{description}}} - */ -{{/if}} - suspend fun {{escapedName}}({{argsSignature}}): {{returnType}} -{{/each}} -} diff --git a/packages/gql/codegen/templates/kotlin/result-union.hbs b/packages/gql/codegen/templates/kotlin/result-union.hbs deleted file mode 100644 index 5bc77918a..000000000 --- a/packages/gql/codegen/templates/kotlin/result-union.hbs +++ /dev/null @@ -1,11 +0,0 @@ -{{#if description}} -/** - * {{{description}}} - */ -{{/if}} -public sealed interface {{name}} - -{{#each entries}} -public data class {{className}}(val value: {{type}}) : {{../name}} - -{{/each}} diff --git a/packages/gql/codegen/templates/kotlin/union.hbs b/packages/gql/codegen/templates/kotlin/union.hbs deleted file mode 100644 index 7fd34c06b..000000000 --- a/packages/gql/codegen/templates/kotlin/union.hbs +++ /dev/null @@ -1,29 +0,0 @@ -{{#if description}} -/** - * {{{description}}} - */ -{{/if}} -public sealed interface {{name}}{{implementations}} { - fun toJson(): Map - - companion object { - fun fromJson(json: Map): {{name}} { - return when (json["__typename"] as String?) { -{{#each concreteMembers}} -{{#if isNested}} - "{{typeName}}" -> {{wrapperName}}({{delegateTo}}.fromJson(json)) -{{else}} - "{{typeName}}" -> {{delegateTo}}.fromJson(json) -{{/if}} -{{/each}} - else -> throw IllegalArgumentException("Unknown __typename for {{name}}: ${json["__typename"]}") - } - } - } -{{#each nestedUnionWrappers}} - - data class {{wrapperName}}(val value: {{unionName}}) : {{parentUnionName}} { - override fun toJson() = value.toJson() - } -{{/each}} -} diff --git a/packages/gql/codegen/templates/swift/enum.hbs b/packages/gql/codegen/templates/swift/enum.hbs deleted file mode 100644 index 6dcad1dcd..000000000 --- a/packages/gql/codegen/templates/swift/enum.hbs +++ /dev/null @@ -1,27 +0,0 @@ -{{#if description}} -/// {{{description}}} -{{/if}} -public enum {{name}}: String, Codable, CaseIterable { -{{#each values}} -{{#if description}} - /// {{{description}}} -{{/if}} - case {{caseName}} = "{{rawValue}}" -{{/each}} -{{#if isErrorCode}} - - /// Custom initializer to handle both kebab-case and camelCase error codes - /// This ensures compatibility with react-native-iap and other libraries that may send camelCase - public init?(rawValue: String) { - // Try direct match first (kebab-case) - switch rawValue { -{{#each values}} - case "{{rawValue}}", "{{camelCaseName}}": - self = .{{caseName}} -{{/each}} - default: - return nil - } - } -{{/if}} -} diff --git a/packages/gql/codegen/templates/swift/header.hbs b/packages/gql/codegen/templates/swift/header.hbs deleted file mode 100644 index 34f16e559..000000000 --- a/packages/gql/codegen/templates/swift/header.hbs +++ /dev/null @@ -1,6 +0,0 @@ -// ============================================================================ -// AUTO-GENERATED TYPES — DO NOT EDIT DIRECTLY -// Run `bun run generate` after updating any *.graphql schema file. -// ============================================================================ - -import Foundation diff --git a/packages/gql/codegen/templates/swift/input.hbs b/packages/gql/codegen/templates/swift/input.hbs deleted file mode 100644 index b60862786..000000000 --- a/packages/gql/codegen/templates/swift/input.hbs +++ /dev/null @@ -1,25 +0,0 @@ -{{#if description}} -/// {{{description}}} -{{/if}} -public struct {{name}}: Codable { -{{#each fields}} -{{#if description}} - /// {{{description}}} -{{/if}} - public var {{propertyName}}: {{type}} -{{/each}} -{{#if hasFields}} - - public init( -{{#each fields}} - {{propertyName}}: {{type}}{{defaultValue}}{{#unless isLast}},{{/unless}} -{{/each}} - ) { -{{#each fields}} - self.{{propertyName}} = {{propertyName}} -{{/each}} - } -{{else}} - public init() {} -{{/if}} -} diff --git a/packages/gql/codegen/templates/swift/interface.hbs b/packages/gql/codegen/templates/swift/interface.hbs deleted file mode 100644 index 5b2cf1139..000000000 --- a/packages/gql/codegen/templates/swift/interface.hbs +++ /dev/null @@ -1,11 +0,0 @@ -{{#if description}} -/// {{{description}}} -{{/if}} -public protocol {{name}}: Codable { -{{#each fields}} -{{#if description}} - /// {{{description}}} -{{/if}} - var {{propertyName}}: {{type}} { get } -{{/each}} -} diff --git a/packages/gql/codegen/templates/swift/object.hbs b/packages/gql/codegen/templates/swift/object.hbs deleted file mode 100644 index 13d37e5e0..000000000 --- a/packages/gql/codegen/templates/swift/object.hbs +++ /dev/null @@ -1,14 +0,0 @@ -{{#if description}} -/// {{{description}}} -{{/if}} -public struct {{name}}: {{conformances}} { -{{#each fields}} -{{#if description}} - /// {{{description}}} -{{/if}} - public var {{propertyName}}: {{type}}{{defaultValue}} -{{/each}} -{{#unless hasFields}} - public init() {} -{{/unless}} -} diff --git a/packages/gql/codegen/templates/swift/operation-helpers.hbs b/packages/gql/codegen/templates/swift/operation-helpers.hbs deleted file mode 100644 index c68261d19..000000000 --- a/packages/gql/codegen/templates/swift/operation-helpers.hbs +++ /dev/null @@ -1,25 +0,0 @@ -// MARK: - {{name}} Helpers - -{{#each fields}} -{{#if hasArgs}} -public typealias {{aliasName}} = ({{paramsSignature}}) async throws -> {{returnType}} -{{else}} -public typealias {{aliasName}} = () async throws -> {{returnType}} -{{/if}} -{{/each}} - -public struct {{handlersName}} { -{{#each fields}} - public var {{escapedName}}: {{aliasName}}? -{{/each}} - - public init( -{{#each fields}} - {{escapedName}}: {{aliasName}}? = nil{{#unless isLast}},{{/unless}} -{{/each}} - ) { -{{#each fields}} - self.{{escapedName}} = {{escapedName}} -{{/each}} - } -} diff --git a/packages/gql/codegen/templates/swift/operation-protocol.hbs b/packages/gql/codegen/templates/swift/operation-protocol.hbs deleted file mode 100644 index a959d6e5b..000000000 --- a/packages/gql/codegen/templates/swift/operation-protocol.hbs +++ /dev/null @@ -1,19 +0,0 @@ -{{#if description}} -/// {{{description}}} -{{/if}} -public protocol {{protocolName}} { -{{#each fields}} -{{#if description}} - /// {{{description}}} -{{/if}} -{{#if hasArgs}} -{{#if hasSingleArg}} - func {{escapedName}}(_ {{argsSignature}}) async throws -> {{returnType}} -{{else}} - func {{escapedName}}({{argsSignature}}) async throws -> {{returnType}} -{{/if}} -{{else}} - func {{escapedName}}() async throws -> {{returnType}} -{{/if}} -{{/each}} -} diff --git a/packages/gql/codegen/templates/swift/result-union.hbs b/packages/gql/codegen/templates/swift/result-union.hbs deleted file mode 100644 index 5566e3310..000000000 --- a/packages/gql/codegen/templates/swift/result-union.hbs +++ /dev/null @@ -1,8 +0,0 @@ -{{#if description}} -/// {{{description}}} -{{/if}} -public enum {{name}} { -{{#each entries}} - case {{caseName}}({{type}}) -{{/each}} -} diff --git a/packages/gql/codegen/templates/swift/union.hbs b/packages/gql/codegen/templates/swift/union.hbs deleted file mode 100644 index d46c96d9f..000000000 --- a/packages/gql/codegen/templates/swift/union.hbs +++ /dev/null @@ -1,24 +0,0 @@ -{{#if description}} -/// {{{description}}} -{{/if}} -public enum {{name}}{{conformances}} { -{{#each members}} - case {{caseName}}({{name}}) -{{/each}} -{{#if hasInterfaceFields}} -{{#each interfaceFields}} - -{{#if description}} - /// {{{description}}} -{{/if}} - public var {{propertyName}}: {{type}} { - switch self { -{{#each ../members}} - case let .{{caseName}}(value): - return value.{{../propertyName}} -{{/each}} - } - } -{{/each}} -{{/if}} -} diff --git a/packages/gql/custom-input-contracts.ts b/packages/gql/custom-input-contracts.ts new file mode 100644 index 000000000..314accf04 --- /dev/null +++ b/packages/gql/custom-input-contracts.ts @@ -0,0 +1,111 @@ +type InputContractType = Readonly<{ + kind: 'scalar' | 'enum' | 'input' | 'list'; + name?: string; + nullable: boolean; + elementType?: InputContractType; +}>; + +type InputContractField = Readonly<{ + name: string; + type: InputContractType; + defaultValue?: unknown; +}>; + +const field = ( + name: string, + kind: InputContractType['kind'], + typeName: string | undefined, + nullable: boolean, + options: { + elementType?: InputContractType; + defaultValue?: unknown; + } = {}, +): InputContractField => + Object.freeze({ + name, + type: Object.freeze({ + kind, + name: typeName, + nullable, + ...(options.elementType ? { elementType: Object.freeze(options.elementType) } : {}), + }), + defaultValue: options.defaultValue, + }); + +/** + * Inputs whose generated public shape is intentionally customized by one or + * more language plugins. Keep this as the only custom-type discriminator SSOT. + */ +export const CUSTOM_INPUT_CONTRACTS = Object.freeze({ + DiscountOfferInputIOS: Object.freeze([ + field('identifier', 'scalar', 'String', false), + field('keyIdentifier', 'scalar', 'String', false), + field('nonce', 'scalar', 'String', false), + field('signature', 'scalar', 'String', false), + field('timestamp', 'scalar', 'Float', false), + ]), + PurchaseInput: Object.freeze([ + field('id', 'scalar', 'ID', false), + field('productId', 'scalar', 'String', false), + field('ids', 'list', undefined, true, { + elementType: { + kind: 'scalar', + name: 'String', + nullable: false, + }, + }), + field('transactionDate', 'scalar', 'Float', false), + field('purchaseToken', 'scalar', 'String', true), + field('store', 'enum', 'IapStore', true), + field('platform', 'enum', 'IapPlatform', true), + field('quantity', 'scalar', 'Int', false), + field('purchaseState', 'enum', 'PurchaseState', false), + field('isAutoRenewing', 'scalar', 'Boolean', false), + ]), + RequestPurchaseProps: Object.freeze([ + field('requestPurchase', 'input', 'RequestPurchasePropsByPlatforms', true), + field('requestSubscription', 'input', 'RequestSubscriptionPropsByPlatforms', true), + field('type', 'enum', 'ProductQueryType', true, { + defaultValue: 'InApp', + }), + field('useAlternativeBilling', 'scalar', 'Boolean', true), + ]), +} as const); + +/** + * Nested inputs that custom RequestPurchaseProps generators project directly. + * They remain standard generated inputs, but their exact schema shape is just + * as compatibility-sensitive as the outer custom type. + */ +const REQUEST_PLATFORM_INPUT_CONTRACTS = Object.freeze({ + RequestPurchasePropsByPlatforms: Object.freeze([ + field('apple', 'input', 'RequestPurchaseIosProps', true), + field('google', 'input', 'RequestPurchaseAndroidProps', true), + field('ios', 'input', 'RequestPurchaseIosProps', true), + field('android', 'input', 'RequestPurchaseAndroidProps', true), + ]), + RequestSubscriptionPropsByPlatforms: Object.freeze([ + field('apple', 'input', 'RequestSubscriptionIosProps', true), + field('google', 'input', 'RequestSubscriptionAndroidProps', true), + field('ios', 'input', 'RequestSubscriptionIosProps', true), + field('android', 'input', 'RequestSubscriptionAndroidProps', true), + ]), +} as const); + +export const GENERATOR_INPUT_CONTRACTS = Object.freeze({ + ...CUSTOM_INPUT_CONTRACTS, + ...REQUEST_PLATFORM_INPUT_CONTRACTS, +}); + +/** + * Generated declarations that intentionally project a custom input's fields + * rather than retaining the input as a nested property. + */ +export const TYPESCRIPT_CUSTOM_INPUT_PROJECTIONS = Object.freeze({ + RequestPurchaseProps: Object.freeze({ + operationArgsOwner: 'MutationRequestPurchaseArgs', + sourceProperty: 'params', + }), +}); + +export type CustomInputKind = keyof typeof CUSTOM_INPUT_CONTRACTS; diff --git a/packages/gql/generated-sync-manifest.mjs b/packages/gql/generated-sync-manifest.mjs new file mode 100644 index 000000000..970f54e7c --- /dev/null +++ b/packages/gql/generated-sync-manifest.mjs @@ -0,0 +1,144 @@ +/** + * Repository-root-relative source/target graph for canonical GQL sync. + * Each target owns its copy/post-process mode here so adding a manifest edge + * automatically makes it part of synchronization and drift verification. + */ +const target = (path, label, mode = 'copy') => Object.freeze({ path, label, mode }); +const group = ({ source, generated, exportKey, targets }) => + Object.freeze({ + source, + generated, + exportKey, + targets: Object.freeze(targets), + }); + +export const GQL_PACKAGE_ROOT = 'packages/gql'; +export const GQL_GENERATED_SOURCE_DIRECTORY = `${GQL_PACKAGE_ROOT}/src/generated`; + +export const GENERATED_SYNC_MANIFEST = Object.freeze({ + kotlin: group({ + source: 'packages/gql/src/generated/Types.kt', + generated: true, + exportKey: './kotlin', + targets: { + google: target('packages/google/openiap/src/main/java/dev/hyo/openiap/Types.kt', 'Kotlin → Google (Android)', 'google-kotlin'), + kmp: target( + 'libraries/kmp-iap/library/src/commonMain/kotlin/io/github/hyochan/kmpiap/openiap/Types.kt', + 'Kotlin → kmp-iap', + 'kmp-kotlin', + ), + }, + }), + swift: group({ + source: 'packages/gql/src/generated/Types.swift', + generated: true, + exportKey: './swift', + targets: { + apple: target('packages/apple/Sources/Models/Types.swift', 'Swift → Apple (iOS)'), + }, + }), + dart: group({ + source: 'packages/gql/src/generated/types.dart', + generated: true, + exportKey: './dart', + targets: { + flutter: target('libraries/flutter_inapp_purchase/lib/types.dart', 'Dart → flutter_inapp_purchase'), + }, + }), + gdscript: group({ + source: 'packages/gql/src/generated/types.gd', + generated: true, + exportKey: './gdscript', + targets: { + godot: target('libraries/godot-iap/addons/godot-iap/types.gd', 'GDScript → godot-iap'), + }, + }), + typescript: group({ + source: 'packages/gql/src/generated/types.ts', + generated: true, + exportKey: '.', + targets: { + reactNative: target('libraries/react-native-iap/src/types.ts', 'TypeScript → react-native-iap'), + expo: target('libraries/expo-iap/src/types.ts', 'TypeScript → expo-iap'), + }, + }), + csharp: group({ + source: 'packages/gql/src/generated/Types.cs', + generated: true, + exportKey: './csharp', + targets: { + maui: target('libraries/maui-iap/src/OpenIap.Maui/Types.cs', 'C# → maui-iap'), + }, + }), + webhookClient: group({ + source: 'packages/gql/src/webhook-client.ts', + generated: false, + exportKey: './webhook-client', + targets: { + reactNative: target('libraries/react-native-iap/src/webhook-client.ts', 'webhook-client → react-native-iap'), + expo: target('libraries/expo-iap/src/webhook-client.ts', 'webhook-client → expo-iap'), + }, + }), + kitApi: group({ + source: 'packages/gql/src/kit-api.ts', + generated: false, + exportKey: './kit-api', + targets: { + reactNative: target('libraries/react-native-iap/src/kit-api.ts', 'kit-api → react-native-iap'), + expo: target('libraries/expo-iap/src/kit-api.ts', 'kit-api → expo-iap'), + }, + }), +}); + +export const gqlPackageRelativePath = (path) => { + const prefix = `${GQL_PACKAGE_ROOT}/`; + if (!path.startsWith(prefix) || path.length === prefix.length) { + throw new Error(`Expected a path below ${GQL_PACKAGE_ROOT}: ${path}`); + } + return path.slice(prefix.length); +}; + +export const generatedSourceFileName = (groupName) => { + const definition = GENERATED_SYNC_MANIFEST[groupName]; + if (!definition?.generated) { + throw new Error(`Expected a generated manifest group: ${groupName}`); + } + + const prefix = `${GQL_GENERATED_SOURCE_DIRECTORY}/`; + if (!definition.source.startsWith(prefix)) { + throw new Error(`Generated source must be a direct child of ${GQL_GENERATED_SOURCE_DIRECTORY}: ${definition.source}`); + } + const fileName = definition.source.slice(prefix.length); + if (!fileName || fileName.includes('/')) { + throw new Error(`Generated source must be a direct child of ${GQL_GENERATED_SOURCE_DIRECTORY}: ${definition.source}`); + } + return fileName; +}; + +export const GENERATED_SYNC_EDGES = Object.freeze( + Object.entries(GENERATED_SYNC_MANIFEST).flatMap(([groupName, definition]) => + Object.entries(definition.targets).map(([targetName, definitionTarget]) => + Object.freeze({ + groupName, + targetName, + source: definition.source, + ...definitionTarget, + }), + ), + ), +); + +export const GENERATED_DRIFT_PATHS = Object.freeze([ + ...new Set([ + ...Object.values(GENERATED_SYNC_MANIFEST) + .filter((definition) => definition.generated) + .map((definition) => definition.source), + ...GENERATED_SYNC_EDGES.map((edge) => edge.path), + ]), +]); + +const generatedDriftPathSet = new Set(GENERATED_DRIFT_PATHS); +const GQL_GENERATION_EXTERNAL_INPUTS = Object.freeze(['package.json', 'bun.lock']); +export const GQL_GENERATION_INPUT_PATHS = Object.freeze([GQL_PACKAGE_ROOT, ...GQL_GENERATION_EXTERNAL_INPUTS]); +export const isGqlGenerationInputPath = (path) => + (path.startsWith(`${GQL_PACKAGE_ROOT}/`) && !generatedDriftPathSet.has(path)) || GQL_GENERATION_EXTERNAL_INPUTS.includes(path); diff --git a/packages/gql/generators/dart/README.md b/packages/gql/generators/dart/README.md deleted file mode 100644 index 53a054821..000000000 --- a/packages/gql/generators/dart/README.md +++ /dev/null @@ -1,17 +0,0 @@ -# Dart Codegen Scaffold - -This package wraps `graphql_codegen` so you can produce Dart models from the -shared OpenIAP schema. - -## Usage - -```bash -cd generators/dart -dart pub get -dart run build_runner build -``` - -Place your query/mutation/subscription documents inside `lib/` (or create a -`graphql/` directory and point to it via `build.yaml`). Generated files will be -written to `lib/generated/` by default. Adjust `pubspec.yaml` and `build.yaml` -if you need custom scalar mappings or a different output structure. diff --git a/packages/gql/generators/dart/build.yaml b/packages/gql/generators/dart/build.yaml deleted file mode 100644 index 9ded13616..000000000 --- a/packages/gql/generators/dart/build.yaml +++ /dev/null @@ -1,17 +0,0 @@ -targets: - $default: - sources: - - lib/** - - graphql/** - - ../../src/** - builders: - graphql_codegen: - options: - schema: - - ../../src/type.graphql - - ../../src/type-ios.graphql - - ../../src/type-android.graphql - - ../../src/api.graphql - - ../../src/api-ios.graphql - - ../../src/api-android.graphql - output: lib/generated/ diff --git a/packages/gql/generators/dart/pubspec.yaml b/packages/gql/generators/dart/pubspec.yaml deleted file mode 100644 index 4d282785a..000000000 --- a/packages/gql/generators/dart/pubspec.yaml +++ /dev/null @@ -1,23 +0,0 @@ -name: openiap_gql_codegen -publish_to: none - -environment: - sdk: '>=3.0.0 <4.0.0' - -dependencies: - gql: ^0.13.1 - -dev_dependencies: - build_runner: ^2.4.6 - graphql_codegen: ^0.14.1 - -# Point graphql_codegen to the shared schema files -graphql_codegen: - schema: - - ../../src/type.graphql - - ../../src/type-ios.graphql - - ../../src/type-android.graphql - - ../../src/api.graphql - - ../../src/api-ios.graphql - - ../../src/api-android.graphql - output: lib/generated/ diff --git a/packages/gql/generators/kotlin/README.md b/packages/gql/generators/kotlin/README.md deleted file mode 100644 index 93033818f..000000000 --- a/packages/gql/generators/kotlin/README.md +++ /dev/null @@ -1,36 +0,0 @@ -# Kotlin Codegen Scaffold - -Use this directory as a reference when wiring Apollo Kotlin into your Android -project. The main repository does not include a standalone Gradle project; -instead, copy the snippet from the root `README.md` into a module inside your -app and point the `schemaFiles` to the shared SDL under `../../src/`. - -Typical usage inside `build.gradle.kts`: - -```kotlin -plugins { - id("com.apollographql.apollo3") version "4.0.0" -} - -dependencies { - implementation("com.apollographql.apollo3:apollo-runtime:4.0.0") -} - -apollo { - service("openIap") { - packageName.set("dev.openiap.graphql") - schemaFiles.from( - file("../../src/type.graphql"), - file("../../src/type-ios.graphql"), - file("../../src/type-android.graphql"), - file("../../src/api.graphql"), - file("../../src/api-ios.graphql"), - file("../../src/api-android.graphql"), - ) - srcDir("src/main/graphql") - } -} -``` - -Run `./gradlew ::generateApolloSources` after adding or updating your -queries. diff --git a/packages/gql/generators/swift/README.md b/packages/gql/generators/swift/README.md deleted file mode 100644 index 8fcc0e719..000000000 --- a/packages/gql/generators/swift/README.md +++ /dev/null @@ -1,18 +0,0 @@ -# Swift Codegen Scaffold - -Use the provided `generate-swift.sh` script to run the Apollo iOS CLI against -the shared schema files. - -## Prerequisites - -- Install the CLI once: `brew install apollo-ios-cli` - -## Generate - -```bash -./generate-swift.sh -``` - -The script collects every `.graphql` file from `../../src/` and writes the -result to `Generated/`. Update the command flags inside the script to point to -operation documents or to change the module name to match your project. diff --git a/packages/gql/generators/swift/generate-swift.sh b/packages/gql/generators/swift/generate-swift.sh deleted file mode 100755 index 3db1dc299..000000000 --- a/packages/gql/generators/swift/generate-swift.sh +++ /dev/null @@ -1,26 +0,0 @@ -#!/usr/bin/env bash -# Swift code generation helper using Apollo iOS CLI -set -euo pipefail - -SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" -SCHEMA_DIR="${SCRIPT_DIR}/../../src" -OUTPUT_DIR="${SCRIPT_DIR}/Generated" -MODULE_NAME="OpenIAPGraphQL" - -mkdir -p "$OUTPUT_DIR" - -if ! command -v apollo-ios-cli >/dev/null 2>&1; then - echo "apollo-ios-cli is not installed. Install it via 'brew install apollo-ios-cli' or 'mint install apollographql/apollo-ios-cli'." >&2 - exit 1 -fi - -SCHEMA_ARGS=() -while IFS= read -r -d '' file; do - SCHEMA_ARGS+=("--schema-paths" "$file") -done < <(find "$SCHEMA_DIR" -maxdepth 1 -name '*.graphql' -print0) - -apollo-ios-cli generate \ - "${SCHEMA_ARGS[@]}" \ - --module-type embeddedInTarget \ - --target-name "$MODULE_NAME" \ - --output-dir "$OUTPUT_DIR" diff --git a/packages/gql/package.json b/packages/gql/package.json index 919e12c0d..b2698c988 100644 --- a/packages/gql/package.json +++ b/packages/gql/package.json @@ -14,14 +14,15 @@ "./csharp": "./src/generated/Types.cs" }, "scripts": { - "generate": "bun run generate:ts && bun run generate:swift && bun run generate:kotlin && bun run generate:dart && bun run generate:gdscript && bun run generate:csharp && bun run sync", + "generate": "bun run generate:ts && bun codegen/index.ts && bun run sync", "generate:ts": "graphql-codegen --config codegen.ts && bun scripts/fix-generated-types.mjs", "generate:swift": "bun codegen/index.ts swift", "generate:kotlin": "bun codegen/index.ts kotlin", "generate:dart": "bun codegen/index.ts dart", "generate:gdscript": "bun codegen/index.ts gdscript", "generate:csharp": "bun codegen/index.ts csharp", - "sync": "bun scripts/sync-to-platforms.mjs", + "sync": "node scripts/sync-to-platforms.mjs", + "verify:generated-staged": "node scripts/assert-generated-staged.mjs", "test": "vitest run src" }, "keywords": [ @@ -38,8 +39,6 @@ "@graphql-codegen/cli": "^6.0.0", "@graphql-codegen/typescript": "^5.0.0", "graphql": "^16.11.0", - "handlebars": "^4.7.8", - "ts-node": "^10.9.2", "typescript": "^5.9.2", "vitest": "^4.1.5" }, diff --git a/packages/gql/schema-deprecations.mjs b/packages/gql/schema-deprecations.mjs new file mode 100644 index 000000000..df676ef4d --- /dev/null +++ b/packages/gql/schema-deprecations.mjs @@ -0,0 +1,217 @@ +import { Kind, parse } from 'graphql'; +import { collectGraphQLComments, normalizeSchemaSources } from './schema-source-utils.mjs'; + +const TYPE_DEPRECATION_DIRECTIVE = 'openiapDeprecated'; + +const TYPE_DEFINITION_KINDS = new Set([ + Kind.ENUM_TYPE_DEFINITION, + Kind.ENUM_TYPE_EXTENSION, + Kind.INPUT_OBJECT_TYPE_DEFINITION, + Kind.INPUT_OBJECT_TYPE_EXTENSION, + Kind.INTERFACE_TYPE_DEFINITION, + Kind.INTERFACE_TYPE_EXTENSION, + Kind.OBJECT_TYPE_DEFINITION, + Kind.OBJECT_TYPE_EXTENSION, + Kind.UNION_TYPE_DEFINITION, + Kind.UNION_TYPE_EXTENSION, +]); + +const canonicalReason = ({ directive, issues, label, line, sourceId }) => { + const argumentsList = directive.arguments ?? []; + const reasonArguments = argumentsList.filter((argument) => argument.name.value === 'reason'); + const reason = reasonArguments[0]?.value; + if ( + argumentsList.length !== 1 || + reasonArguments.length !== 1 || + !reason || + reason.kind !== Kind.STRING || + reason.value.trim().length === 0 || + reason.value.includes('*/') + ) { + issues.push({ + file: sourceId, + line: directive.loc?.startToken.line ?? line, + message: `${label} must declare exactly one non-empty string @${directive.name.value} reason and no other arguments`, + rule: 'deprecated-reason-invalid', + }); + return null; + } + return reason.value.replace(/\s+/g, ' ').trim(); +}; + +/** + * Extract and validate the canonical deprecation metadata shared by linting, + * IR generation, TypeScript post-processing, and generated-output tests. + */ +export const extractSchemaDeprecations = (sources) => { + const entries = []; + const issues = []; + const typeReasons = new Map(); + const operationArguments = []; + const entryOwners = new Map(); + + for (const { sourceId, sdl } of normalizeSchemaSources(sources)) { + const document = parse(sdl); + for (const comment of collectGraphQLComments(sdl)) { + if (/^#\s*@deprecated\b/i.test(comment.text.trim())) { + issues.push({ + file: sourceId, + line: comment.line, + message: 'Legacy "# @deprecated" comments are not canonical; use a GraphQL deprecation directive', + rule: 'deprecated-comment-legacy', + }); + } + } + + const processNode = ({ node, parentKind, parentName, ownerPath, typeLevel = false }) => { + const directives = node.directives ?? []; + const canonicalName = typeLevel ? TYPE_DEPRECATION_DIRECTIVE : 'deprecated'; + const wrongName = typeLevel ? 'deprecated' : TYPE_DEPRECATION_DIRECTIVE; + const canonical = directives.filter((directive) => directive.name.value === canonicalName); + const wrong = directives.filter((directive) => directive.name.value === wrongName); + const label = `${node.kind} "${ownerPath}"`; + const line = node.loc?.startToken.line; + const descriptionTag = node.description && /(?:^|\n)\s*@deprecated\b/.test(node.description.value); + + for (const directive of wrong) { + issues.push({ + file: sourceId, + line: directive.loc?.startToken.line ?? line, + message: typeLevel + ? `${label} must use @${TYPE_DEPRECATION_DIRECTIVE}; standard @deprecated does not support type definitions` + : `${label} must use standard @deprecated; @${TYPE_DEPRECATION_DIRECTIVE} is reserved for type definitions`, + rule: 'deprecated-directive-location', + }); + } + + if (canonical.length > 1) { + issues.push({ + file: sourceId, + line, + message: `${label} declares @${canonicalName} more than once`, + rule: 'deprecated-directive-duplicate', + }); + } + + if (descriptionTag) { + issues.push({ + file: sourceId, + line, + message: + canonical.length > 0 + ? `${label} duplicates directive-owned @deprecated guidance in its description` + : `${label} declares @deprecated guidance only in its description; move the canonical reason to a directive`, + rule: canonical.length > 0 ? 'deprecated-description-duplicate' : 'deprecated-directive-missing', + }); + } + + if (canonical.length !== 1) return null; + const reason = canonicalReason({ + directive: canonical[0], + issues, + label, + line, + sourceId, + }); + if (!reason) return null; + + const name = node.name?.value ?? node.kind; + const previous = entryOwners.get(ownerPath); + if (previous) { + issues.push({ + file: sourceId, + line, + message: `${label} duplicates @${canonicalName} ownership from ${previous.sourceId}${previous.line ? `:${previous.line}` : ''}`, + rule: 'deprecated-directive-duplicate', + }); + return null; + } + + const entry = { + kind: node.kind, + name, + parentKind, + parentName, + ownerPath, + reason, + sourceId, + line, + }; + entryOwners.set(ownerPath, { line, sourceId }); + entries.push(entry); + + if (typeLevel) { + typeReasons.set(name, { reason, sourceId, line }); + } + return entry; + }; + + for (const definition of document.definitions) { + if (!TYPE_DEFINITION_KINDS.has(definition.kind)) continue; + const typeName = definition.name.value; + processNode({ + node: definition, + ownerPath: typeName, + typeLevel: true, + }); + + if (definition.kind === Kind.ENUM_TYPE_DEFINITION || definition.kind === Kind.ENUM_TYPE_EXTENSION) { + for (const value of definition.values ?? []) { + processNode({ + node: value, + ownerPath: `${typeName}.${value.name.value}`, + parentKind: definition.kind, + parentName: typeName, + }); + } + continue; + } + + if (!('fields' in definition)) continue; + for (const field of definition.fields ?? []) { + const fieldPath = `${typeName}.${field.name.value}`; + processNode({ + node: field, + ownerPath: fieldPath, + parentKind: definition.kind, + parentName: typeName, + }); + if (!('arguments' in field)) continue; + for (const argument of field.arguments ?? []) { + const argumentPath = `${fieldPath}.${argument.name.value}`; + const entry = processNode({ + node: argument, + ownerPath: argumentPath, + parentKind: field.kind, + parentName: fieldPath, + }); + const directive = (argument.directives ?? []).find((candidate) => candidate.name.value === 'deprecated'); + if (entry && directive && (typeName === 'Query' || typeName === 'Mutation' || typeName === 'Subscription')) { + operationArguments.push({ + rootName: typeName, + fieldName: field.name.value, + argumentName: argument.name.value, + reason: entry.reason, + }); + } + } + } + } + } + + return { + entries, + issues, + operationArguments, + typeReasons: new Map([...typeReasons].map(([name, metadata]) => [name, metadata.reason])), + }; +}; + +export const assertValidSchemaDeprecations = (deprecations) => { + if (deprecations.issues.length === 0) return; + throw new Error( + `Invalid GraphQL deprecation metadata:\n${deprecations.issues + .map((issue) => `- ${issue.file}${issue.line ? `:${issue.line}` : ''}: ${issue.message}`) + .join('\n')}`, + ); +}; diff --git a/packages/gql/schema-files.mjs b/packages/gql/schema-files.mjs new file mode 100644 index 000000000..a86af8087 --- /dev/null +++ b/packages/gql/schema-files.mjs @@ -0,0 +1,19 @@ +/** + * Ordered GraphQL schema inputs shared by both generation pipelines. + * + * This is the canonical production inventory. Every repository-owned + * generation path imports it directly; schema-files.test.mjs prevents missing + * or duplicate SDL inputs. + */ +export const SCHEMA_FILE_NAMES = Object.freeze([ + 'schema.graphql', + 'type.graphql', + 'type-ios.graphql', + 'type-android.graphql', + 'api.graphql', + 'api-ios.graphql', + 'api-android.graphql', + 'error.graphql', + 'event.graphql', + 'webhook.graphql', +]); diff --git a/packages/gql/schema-markers.mjs b/packages/gql/schema-markers.mjs new file mode 100644 index 000000000..eab87526b --- /dev/null +++ b/packages/gql/schema-markers.mjs @@ -0,0 +1,235 @@ +import { Kind, parse } from 'graphql'; +import { collectGraphQLComments, normalizeSchemaSources } from './schema-source-utils.mjs'; + +const UNION_MARKER_PATTERN = /^#\s*=>\s*Union\s*$/i; +const FUTURE_MARKER_PATTERN = /^#\s*Future\s*$/i; +const ASYNC_ROOT_NAMES = new Set(['Query', 'Mutation']); +const OPERATION_ROOT_NAMES = new Set(['Query', 'Mutation', 'Subscription']); +const FIELD_CONTAINER_KINDS = new Set([ + Kind.INPUT_OBJECT_TYPE_DEFINITION, + Kind.INPUT_OBJECT_TYPE_EXTENSION, + Kind.INTERFACE_TYPE_DEFINITION, + Kind.INTERFACE_TYPE_EXTENSION, + Kind.OBJECT_TYPE_DEFINITION, + Kind.OBJECT_TYPE_EXTENSION, +]); + +const lineStartOffsets = (sdl) => { + const offsets = [0]; + for (const match of sdl.matchAll(/\r?\n/g)) { + offsets.push(match.index + match[0].length); + } + return offsets; +}; + +const nextSignificantTarget = (lines, starts, markerIndex) => { + for (let index = markerIndex + 1; index < lines.length; index += 1) { + const line = lines[index]; + const trimmed = line.trim(); + if (trimmed.length === 0 || trimmed.startsWith('#')) continue; + return { + line: index + 1, + offset: starts[index] + line.search(/\S/), + }; + } + return null; +}; + +const collectTargets = (sdl) => { + const typeTargets = new Map(); + const fieldTargets = new Map(); + const document = parse(sdl); + + for (const definition of document.definitions) { + if (!FIELD_CONTAINER_KINDS.has(definition.kind)) continue; + + if (definition.kind === Kind.OBJECT_TYPE_DEFINITION || definition.kind === Kind.OBJECT_TYPE_EXTENSION) { + if (definition.loc) { + typeTargets.set(definition.loc.start, definition.name.value); + for (let token = definition.loc.startToken; token && token.start < definition.name.loc.start; token = token.next) { + if (token.value === 'type' || token.value === 'extend') { + typeTargets.set(token.start, definition.name.value); + } + } + } + } + + for (const field of definition.fields ?? []) { + if (field.loc) { + const target = { + owner: definition.name.value, + field: field.name.value, + }; + fieldTargets.set(field.loc.start, target); + fieldTargets.set(field.name.loc.start, target); + } + } + } + + return { fieldTargets, typeTargets }; +}; + +/** + * Extract the code-generation markers that live in GraphQL SDL comments. + * + * Generation and linting consume this helper so marker recognition, target + * ownership, and invalid-target behavior cannot drift across pipelines. + */ +export const extractSchemaMarkers = (sdlSources) => { + const unionWrappers = new Set(); + const futureFields = new Set(); + const issues = []; + const unionOwners = new Map(); + const futureOwners = new Map(); + + for (const { sourceId, sdl } of normalizeSchemaSources(sdlSources)) { + const lines = sdl.split(/\r?\n/); + const starts = lineStartOffsets(sdl); + const { fieldTargets, typeTargets } = collectTargets(sdl); + + for (const comment of collectGraphQLComments(sdl)) { + const isUnionMarker = UNION_MARKER_PATTERN.test(comment.text.trim()); + const isFutureMarker = FUTURE_MARKER_PATTERN.test(comment.text.trim()); + if (!isUnionMarker && !isFutureMarker) continue; + + const markerLine = comment.line; + if (!comment.standalone) { + issues.push({ + kind: isUnionMarker ? 'union' : 'future', + reason: 'invalid-placement', + sourceId, + markerLine, + targetLine: null, + }); + continue; + } + + const index = markerLine - 1; + const targetPosition = nextSignificantTarget(lines, starts, index); + const targetLine = targetPosition?.line ?? null; + if (isUnionMarker) { + const typeName = targetPosition ? typeTargets.get(targetPosition.offset) : null; + if (typeName && OPERATION_ROOT_NAMES.has(typeName)) { + issues.push({ + kind: 'union', + reason: 'invalid-owner', + sourceId, + markerLine, + targetLine, + target: typeName, + }); + } else if (typeName) { + const previous = unionOwners.get(typeName); + if (previous) { + issues.push({ + kind: 'union', + reason: 'duplicate-marker', + sourceId, + markerLine, + targetLine, + target: typeName, + previous, + }); + } else { + unionOwners.set(typeName, { sourceId, markerLine }); + unionWrappers.add(typeName); + } + } else { + issues.push({ + kind: 'union', + reason: 'invalid-target', + sourceId, + markerLine, + targetLine, + }); + } + } else { + const target = targetPosition ? fieldTargets.get(targetPosition.offset) : null; + if (!target) { + issues.push({ + kind: 'future', + reason: 'invalid-target', + sourceId, + markerLine, + targetLine, + }); + } else if (target.field === '_placeholder') { + issues.push({ + kind: 'future', + reason: 'no-effect', + sourceId, + markerLine, + targetLine, + target: `${target.owner}.${target.field}`, + }); + } else if (!ASYNC_ROOT_NAMES.has(target.owner)) { + issues.push({ + kind: 'future', + reason: 'invalid-owner', + sourceId, + markerLine, + targetLine, + target: `${target.owner}.${target.field}`, + }); + } else { + const key = `${target.owner}.${target.field}`; + const previous = futureOwners.get(key); + if (previous) { + issues.push({ + kind: 'future', + reason: 'duplicate-marker', + sourceId, + markerLine, + targetLine, + target: key, + previous, + }); + } else { + futureOwners.set(key, { sourceId, markerLine }); + futureFields.add(key); + } + } + } + } + } + + return { futureFields, issues, unionWrappers }; +}; + +export const schemaMarkerIssueMessage = (issue, sourceLabel = (sourceId) => sourceId) => { + const marker = issue.kind === 'union' ? '# => Union' : '# Future'; + if (issue.reason === 'invalid-placement') { + return `"${marker}" must be a standalone comment immediately before its target`; + } + if (issue.reason === 'duplicate-marker') { + return `"${marker}" duplicates ${issue.target} ownership from ${sourceLabel(issue.previous.sourceId)}:${issue.previous.markerLine}`; + } + if (issue.reason === 'invalid-owner') { + return issue.kind === 'union' + ? `"${marker}" targets ${issue.target}; operation root types cannot be union wrappers` + : `"${marker}" targets ${issue.target}; only Query and Mutation fields may be asynchronous`; + } + if (issue.reason === 'no-effect') { + return `"${marker}" targets ${issue.target}; placeholder fields cannot carry generation markers`; + } + const target = issue.kind === 'union' ? 'object type definition' : 'field definition'; + return issue.targetLine ? `"${marker}" is not followed by a valid ${target}` : `"${marker}" has no following ${target} (end of file)`; +}; + +export const schemaMarkerIssueRule = (issue) => + issue.reason === 'duplicate-marker' + ? 'generation-marker-duplicate' + : issue.reason === 'invalid-placement' + ? 'generation-marker-placement' + : issue.kind === 'union' + ? 'union-marker-target' + : 'future-marker-target'; + +const formatSchemaMarkerIssue = (issue) => `${issue.sourceId}:${issue.markerLine}: ${schemaMarkerIssueMessage(issue)}`; + +export const assertValidSchemaMarkers = (markers) => { + if (markers.issues.length === 0) return; + throw new Error( + `Invalid GraphQL generation marker ownership:\n${markers.issues.map((issue) => `- ${formatSchemaMarkerIssue(issue)}`).join('\n')}`, + ); +}; diff --git a/packages/gql/schema-source-utils.mjs b/packages/gql/schema-source-utils.mjs new file mode 100644 index 000000000..0a7ed40f3 --- /dev/null +++ b/packages/gql/schema-source-utils.mjs @@ -0,0 +1,75 @@ +export const normalizeSchemaSources = (sources) => + [...sources].map((source, index) => (typeof source === 'string' ? { sourceId: ``, sdl: source } : source)); + +/** + * Collect real GraphQL comments while excluding `#` text inside quoted and + * block-string values. Position metadata lets marker consumers distinguish a + * standalone directive comment from an invalid trailing comment. + */ +export const collectGraphQLComments = (sdl) => { + const comments = []; + let index = 0; + let line = 1; + let lineStart = 0; + + const advance = () => { + const character = sdl[index]; + index += 1; + if (character === '\n') { + line += 1; + lineStart = index; + } + return character; + }; + + while (index < sdl.length) { + if (sdl.startsWith('"""', index)) { + advance(); + advance(); + advance(); + while (index < sdl.length) { + if (sdl.startsWith('"""', index) && (index === 0 || sdl[index - 1] !== '\\')) { + advance(); + advance(); + advance(); + break; + } + advance(); + } + continue; + } + + if (sdl[index] === '"') { + advance(); + while (index < sdl.length) { + const character = advance(); + if (character === '\\' && index < sdl.length) { + advance(); + } else if (character === '"') { + break; + } + } + continue; + } + + if (sdl[index] === '#') { + const start = index; + const commentLine = line; + const column = start - lineStart + 1; + while (index < sdl.length && sdl[index] !== '\n' && sdl[index] !== '\r') { + advance(); + } + comments.push({ + column, + line: commentLine, + standalone: /^\s*$/.test(sdl.slice(lineStart, start)), + text: sdl.slice(start, index), + }); + continue; + } + + advance(); + } + + return comments; +}; diff --git a/packages/gql/scripts/assert-generated-staged.mjs b/packages/gql/scripts/assert-generated-staged.mjs new file mode 100644 index 000000000..5d11c7ab3 --- /dev/null +++ b/packages/gql/scripts/assert-generated-staged.mjs @@ -0,0 +1,21 @@ +import { execFileSync } from 'node:child_process'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { GENERATED_DRIFT_PATHS } from '../generated-sync-manifest.mjs'; + +const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../../..'); +const git = (...args) => + execFileSync('git', args, { + cwd: repositoryRoot, + encoding: 'utf8', + }).trim(); + +const unstaged = git('diff', '--name-only', '--', ...GENERATED_DRIFT_PATHS); +const untracked = git('ls-files', '--others', '--exclude-standard', '--', ...GENERATED_DRIFT_PATHS); +const drift = [...new Set([...unstaged.split('\n'), ...untracked.split('\n')])].filter(Boolean).sort(); + +if (drift.length > 0) { + throw new Error( + `Generated or synchronized files changed after canonical generation. Stage these paths and retry:\n${drift.map((path) => `- ${path}`).join('\n')}`, + ); +} diff --git a/packages/gql/scripts/assert-generation-inputs-staged.mjs b/packages/gql/scripts/assert-generation-inputs-staged.mjs new file mode 100644 index 000000000..4fe2b228e --- /dev/null +++ b/packages/gql/scripts/assert-generation-inputs-staged.mjs @@ -0,0 +1,31 @@ +import { execFileSync } from 'node:child_process'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { GQL_GENERATION_INPUT_PATHS, isGqlGenerationInputPath } from '../generated-sync-manifest.mjs'; + +const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../../..'); +const gitLines = (...args) => { + const output = execFileSync('git', args, { + cwd: repositoryRoot, + encoding: 'utf8', + }).trim(); + return output ? output.split('\n') : []; +}; + +const changedInputs = (...args) => gitLines(...args, '--', ...GQL_GENERATION_INPUT_PATHS).filter(isGqlGenerationInputPath); + +const staged = changedInputs('diff', '--cached', '--name-only', '--diff-filter=ACMRD'); +const drift = [...changedInputs('diff', '--name-only'), ...changedInputs('ls-files', '--others', '--exclude-standard')] + .filter((entry, index, entries) => entries.indexOf(entry) === index) + .sort(); + +if (process.argv[2] === 'has-staged-inputs') { + process.exitCode = staged.length > 0 ? 0 : 1; +} else if (process.argv[2] === 'assert-staged-clean') { + if (drift.length === 0) process.exit(0); + throw new Error( + `Canonical GQL inputs contain unstaged or untracked changes. Stage the complete source snapshot before generation:\n${drift.map((path) => `- ${path}`).join('\n')}`, + ); +} else { + throw new Error(`Unknown command "${process.argv[2] ?? ''}". Expected has-staged-inputs or assert-staged-clean.`); +} diff --git a/packages/gql/scripts/custom-generated-guards.mjs b/packages/gql/scripts/custom-generated-guards.mjs new file mode 100644 index 000000000..18e133fd7 --- /dev/null +++ b/packages/gql/scripts/custom-generated-guards.mjs @@ -0,0 +1,587 @@ +import ts from 'typescript'; +import { CUSTOM_INPUT_CONTRACTS, TYPESCRIPT_CUSTOM_INPUT_PROJECTIONS } from '../custom-input-contracts.ts'; +import { PLATFORM_TYPE_DEFAULTS } from '../codegen/core/utils.ts'; + +export const GRAPHQL_CODEGEN_SCAFFOLDING = Object.freeze([ + 'export type Scalars', + 'Maybe<', + 'InputMaybe<', + "Scalars['", + 'MakeOptional', + 'MakeMaybe', + 'MakeEmpty', + 'Incremental', + 'Exact<', +]); + +export const requireNoGraphqlCodegenScaffolding = (source) => { + const remaining = GRAPHQL_CODEGEN_SCAFFOLDING.flatMap((token) => { + const count = source.split(token).length - 1; + return count === 0 ? [] : [`${JSON.stringify(token)} (${count})`]; + }); + if (remaining.length > 0) { + throw new Error(`Generated TypeScript still contains graphql-codegen scaffolding: ${remaining.join(', ')}.`); + } +}; + +const indentJSDoc = (block, indent) => + block + .split(/\r?\n/) + .map((line) => { + const trimmed = line.trimStart(); + return `${indent}${trimmed.startsWith('*') ? ' ' : ''}${trimmed}`; + }) + .join('\n'); + +export const renderDocumentedTypeAlias = (name, declaration, jsdoc = null) => { + const alias = declaration.startsWith('\n') ? `export type ${name} =${declaration};` : `export type ${name} = ${declaration};`; + return `${jsdoc ? `${indentJSDoc(jsdoc, '')}\n` : ''}${alias}`; +}; + +const propertyName = (member, ownerName) => { + if (!ts.isPropertySignature(member)) { + throw new Error(`${ownerName} custom generator only supports property signatures; found ${ts.SyntaxKind[member.kind]}.`); + } + + if (ts.isIdentifier(member.name) || ts.isStringLiteral(member.name) || ts.isNumericLiteral(member.name)) { + return member.name.text; + } + + throw new Error(`${ownerName} custom generator requires static property names; found ${member.name.getText()}.`); +}; + +export const requireExactInterfaceProperties = (source, ownerName, expectedProperties) => { + const sourceFile = ts.createSourceFile('generated-types.ts', source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS); + if (sourceFile.parseDiagnostics.length > 0) { + const diagnostics = sourceFile.parseDiagnostics.map((diagnostic) => diagnostic.messageText).join('; '); + throw new Error(`${ownerName} custom generator could not parse generated TypeScript: ${diagnostics}`); + } + + const declarations = sourceFile.statements.filter( + (statement) => ts.isInterfaceDeclaration(statement) && statement.name.text === ownerName, + ); + if (declarations.length !== 1) { + throw new Error(`${ownerName} generated interface must appear exactly once; found ${declarations.length}.`); + } + + const declaration = declarations[0]; + const membersByName = new Map(); + const actualProperties = declaration.members.map((member) => { + const name = propertyName(member, ownerName); + membersByName.set(name, member); + return name; + }); + const expected = new Set(expectedProperties); + const hasExactProperties = + expected.size === expectedProperties.length && + new Set(actualProperties).size === actualProperties.length && + actualProperties.length === expectedProperties.length && + actualProperties.every((property) => expected.has(property)); + if (!hasExactProperties) { + throw new Error( + `${ownerName} custom generator fields drifted; expected ${expectedProperties.join(', ')}, found ${actualProperties.join(', ')}.`, + ); + } + + const start = declaration.getStart(sourceFile); + const trailingBlankLine = source.slice(declaration.end).match(/^(?:\r?\n)+/)?.[0] ?? ''; + return { + start, + end: declaration.end + trailingBlankLine.length, + source: source.slice(start, declaration.end + trailingBlankLine.length), + assertPropertyContract(property, expectedContract) { + const member = membersByName.get(property); + if (!member) { + throw new Error(`${ownerName}.${property} is not a generated property.`); + } + const actualType = member.type?.getText(sourceFile).replace(/\s+/g, ' ').trim(); + const actualOptional = Boolean(member.questionToken); + const expectedType = expectedContract.type.replace(/\s+/g, ' ').trim(); + if (actualType !== expectedType || actualOptional !== expectedContract.optional) { + throw new Error( + `${ownerName}.${property} generated contract drifted; expected ${expectedContract.optional ? 'optional' : 'required'} ${expectedType}, found ${actualOptional ? 'optional' : 'required'} ${actualType ?? ''}.`, + ); + } + }, + propertyJSDoc(property, required = true) { + const member = membersByName.get(property); + if (!member) { + throw new Error(`${ownerName}.${property} is not a generated property.`); + } + const leading = source.slice(member.getFullStart(), member.getStart(sourceFile)); + const docs = leading.match(/\/\*\*[\s\S]*?\*\//g) ?? []; + if (docs.length === 0 && !required) return null; + if (docs.length !== 1) { + throw new Error(`${ownerName}.${property} must retain exactly one direct generated JSDoc block; found ${docs.length}.`); + } + return docs[0]; + }, + }; +}; + +const typescriptContractType = (type) => { + let base; + if (type.kind === 'list') { + if (!type.elementType) { + throw new Error('Custom input list contract requires an element type.'); + } + const element = typescriptContractType(type.elementType); + base = `${/[|&]/.test(element) ? `(${element})` : element}[]`; + } else if (type.kind === 'scalar') { + base = { + Boolean: 'boolean', + Float: 'number', + ID: 'string', + Int: 'number', + String: 'string', + }[type.name]; + if (!base) { + throw new Error(`Unsupported custom input scalar contract ${type.name}.`); + } + } else { + base = type.name; + } + + if (!base) { + throw new Error(`Custom input ${type.kind} contract requires a type name.`); + } + return type.nullable ? `(${base} | null)` : base; +}; + +export const requireTypeScriptInputContract = (source, ownerName) => { + const contract = CUSTOM_INPUT_CONTRACTS[ownerName]; + if (!contract) { + throw new Error(`${ownerName} has no canonical custom input contract.`); + } + const declaration = requireExactInterfaceProperties( + source, + ownerName, + contract.map((field) => field.name), + ); + for (const field of contract) { + declaration.assertPropertyContract(field.name, { + optional: field.type.nullable, + type: typescriptContractType(field.type), + }); + } + return declaration; +}; + +const generatedSourceFile = (source, label) => { + const sourceFile = ts.createSourceFile('generated-types.ts', source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS); + if (sourceFile.parseDiagnostics.length > 0) { + throw new Error( + `${label} could not parse generated TypeScript: ${sourceFile.parseDiagnostics + .map((diagnostic) => diagnostic.messageText) + .join('; ')}`, + ); + } + return sourceFile; +}; + +const declarationProperty = (sourceFile, ownerName, propertyName) => { + const owners = sourceFile.statements.filter((statement) => ts.isInterfaceDeclaration(statement) && statement.name.text === ownerName); + if (owners.length !== 1) { + throw new Error(`${ownerName} must have exactly one generated TypeScript interface; found ${owners.length}.`); + } + const properties = owners[0].members.filter((member) => staticMemberName(member) === propertyName); + if (properties.length !== 1 || !ts.isPropertySignature(properties[0])) { + throw new Error(`${ownerName}.${propertyName} must have exactly one generated TypeScript property; found ${properties.length}.`); + } + return properties[0]; +}; + +export const requireProductDiscriminantContracts = (source) => { + const sourceFile = generatedSourceFile(source, 'Product discriminant postcondition'); + const expected = Object.fromEntries( + Object.entries(PLATFORM_TYPE_DEFAULTS).map(([typeName, defaults]) => [ + typeName, + { + platform: `'${defaults.platform}'`, + type: `'${defaults.type}'`, + }, + ]), + ); + expected.ProductCommon = { + platform: [...new Set(Object.values(PLATFORM_TYPE_DEFAULTS).map(({ platform }) => platform))] + .map((platform) => `'${platform}'`) + .sort() + .join(' | '), + type: [...new Set(Object.values(PLATFORM_TYPE_DEFAULTS).map(({ type }) => type))] + .map((type) => `'${type}'`) + .sort() + .join(' | '), + }; + + for (const [ownerName, properties] of Object.entries(expected)) { + for (const [propertyName, expectedType] of Object.entries(properties)) { + const property = declarationProperty(sourceFile, ownerName, propertyName); + const actualType = property.type?.getText(sourceFile).replace(/\s+/g, ' ').trim(); + if (actualType !== expectedType) { + throw new Error( + `${ownerName}.${propertyName} discriminant drifted; expected ${expectedType}, found ${actualType ?? ''}.`, + ); + } + } + } +}; + +export const requireGeneratedEnumContracts = (source, enumContracts) => { + const sourceFile = generatedSourceFile(source, 'Enum postcondition'); + for (const [enumName, expectedValues] of enumContracts) { + const enums = sourceFile.statements.filter((statement) => ts.isEnumDeclaration(statement) && statement.name.text === enumName); + const aliases = sourceFile.statements.filter((statement) => ts.isTypeAliasDeclaration(statement) && statement.name.text === enumName); + const expectedEnums = enumName === 'ErrorCode' ? 1 : 0; + const expectedAliases = enumName === 'ErrorCode' ? 0 : 1; + if (enums.length !== expectedEnums || aliases.length !== expectedAliases) { + throw new Error( + `${enumName} enum contract drifted; expected ${expectedEnums} enum and ${expectedAliases} alias declarations, found ${enums.length} and ${aliases.length}.`, + ); + } + + const actualValues = []; + if (enumName === 'ErrorCode') { + for (const member of enums[0].members) { + if (!member.initializer || !ts.isStringLiteral(member.initializer)) { + throw new Error(`${enumName} enum member ${member.name.getText()} lost its string value.`); + } + actualValues.push(member.initializer.text); + } + } else { + const collectLiterals = (typeNode) => { + if (ts.isParenthesizedTypeNode(typeNode)) { + collectLiterals(typeNode.type); + } else if (ts.isUnionTypeNode(typeNode)) { + for (const member of typeNode.types) collectLiterals(member); + } else if (ts.isLiteralTypeNode(typeNode) && ts.isStringLiteral(typeNode.literal)) { + actualValues.push(typeNode.literal.text); + } else { + throw new Error(`${enumName} enum alias contains unsupported member ${typeNode.getText(sourceFile)}.`); + } + }; + collectLiterals(aliases[0].type); + } + + if (actualValues.length !== expectedValues.length || actualValues.some((value, index) => value !== expectedValues[index])) { + throw new Error(`${enumName} enum values drifted; expected ${expectedValues.join(', ')}, found ${actualValues.join(', ')}.`); + } + } +}; + +export const requireExactTypeAlias = (source, ownerName, expectedType) => { + const sourceFile = generatedSourceFile(source, `${ownerName} type alias postcondition`); + const aliases = sourceFile.statements.filter((statement) => ts.isTypeAliasDeclaration(statement) && statement.name.text === ownerName); + const interfaces = sourceFile.statements.filter((statement) => ts.isInterfaceDeclaration(statement) && statement.name.text === ownerName); + if (aliases.length !== 1 || interfaces.length !== 0) { + throw new Error( + `${ownerName} must produce exactly one type alias and no interface; found ${aliases.length} aliases and ${interfaces.length} interfaces.`, + ); + } + const actualType = aliases[0].type.getText(sourceFile).replace(/\s+/g, ' ').trim(); + if (actualType !== expectedType) { + throw new Error(`${ownerName} alias drifted; expected ${expectedType}, found ${actualType}.`); + } +}; + +export const resolveOperationArgsOwner = (source, { rootName, fieldName, ownerNames, argumentCount, argumentContracts }) => { + if (argumentContracts && argumentContracts.length !== argumentCount) { + throw new Error(`${rootName}.${fieldName} argument guard expected ${argumentCount} contracts, found ${argumentContracts.length}.`); + } + const sourceFile = generatedSourceFile(source, `${rootName}.${fieldName} args postcondition`); + const matches = sourceFile.statements.filter( + (statement) => + (ts.isInterfaceDeclaration(statement) || ts.isTypeAliasDeclaration(statement)) && ownerNames.includes(statement.name.text), + ); + if (argumentCount === 0) { + if (matches.length !== 0) { + throw new Error(`${rootName}.${fieldName} has no SDL arguments but generated ${matches.length} Args declarations.`); + } + return 'never'; + } + if (matches.length !== 1) { + throw new Error( + `${rootName}.${fieldName} has ${argumentCount} SDL arguments and must have exactly one generated Args declaration; found ${matches.length}.`, + ); + } + const declaration = matches[0]; + const expectedKind = argumentCount === 1 ? ts.SyntaxKind.TypeAliasDeclaration : ts.SyntaxKind.InterfaceDeclaration; + if (declaration.kind !== expectedKind) { + throw new Error( + `${rootName}.${fieldName} has ${argumentCount} SDL arguments and must generate a ${argumentCount === 1 ? 'type alias' : 'interface'} Args declaration; found ${ts.SyntaxKind[declaration.kind]}.`, + ); + } + if (argumentContracts) { + if (argumentCount === 1) { + const argument = argumentContracts[0]; + const expectedType = argument.optional ? `${argument.type} | undefined` : argument.type; + const actualType = declaration.type.getText(sourceFile).replace(/\s+/g, ' ').trim(); + if (actualType !== expectedType) { + throw new Error(`${rootName}.${fieldName} Args alias drifted; expected ${expectedType}, found ${actualType}.`); + } + } else { + const actualProperties = new Map(); + for (const member of declaration.members) { + const name = staticMemberName(member); + if (!name || !ts.isPropertySignature(member) || !member.type) { + throw new Error(`${rootName}.${fieldName} Args interface only supports typed static properties.`); + } + if (actualProperties.has(name)) { + throw new Error(`${rootName}.${fieldName} Args interface duplicates ${name}.`); + } + actualProperties.set(name, { + optional: Boolean(member.questionToken), + type: member.type.getText(sourceFile).replace(/\s+/g, ' ').trim(), + }); + } + const expectedNames = new Set(argumentContracts.map(({ name }) => name)); + if (actualProperties.size !== argumentContracts.length || [...actualProperties.keys()].some((name) => !expectedNames.has(name))) { + throw new Error( + `${rootName}.${fieldName} Args fields drifted; expected ${[...expectedNames].join(', ')}, found ${[...actualProperties.keys()].join(', ')}.`, + ); + } + for (const argument of argumentContracts) { + const actual = actualProperties.get(argument.name); + if (!actual || actual.optional !== argument.optional || actual.type !== argument.type) { + throw new Error( + `${rootName}.${fieldName} Args.${argument.name} drifted; expected ${argument.optional ? 'optional' : 'required'} ${argument.type}, found ${actual ? `${actual.optional ? 'optional' : 'required'} ${actual.type}` : ''}.`, + ); + } + } + } + } + return declaration.name.text; +}; + +const staticMemberName = (member) => { + if ( + !ts.isPropertySignature(member) || + !(ts.isIdentifier(member.name) || ts.isStringLiteral(member.name) || ts.isNumericLiteral(member.name)) + ) { + return null; + } + return member.name.text; +}; + +export const operationFieldNames = (source, rootName, expectedFieldNames = null) => { + const sourceFile = generatedSourceFile(source, `${rootName} operation fields`); + const roots = sourceFile.statements.filter((statement) => ts.isInterfaceDeclaration(statement) && statement.name.text === rootName); + if (roots.length !== 1) { + throw new Error(`${rootName} must have exactly one generated operation interface; found ${roots.length}.`); + } + + const names = roots[0].members.map((member) => { + const name = staticMemberName(member); + if (!name) { + throw new Error(`${rootName} operation interface only supports static property signatures; found ${ts.SyntaxKind[member.kind]}.`); + } + return name; + }); + if (new Set(names).size !== names.length) { + throw new Error(`${rootName} operation interface contains duplicate field declarations.`); + } + if (expectedFieldNames) { + const expected = new Set(expectedFieldNames); + if ( + names.length !== expectedFieldNames.length || + expected.size !== expectedFieldNames.length || + names.some((name) => !expected.has(name)) + ) { + throw new Error(`${rootName} operation fields drifted; expected ${expectedFieldNames.join(', ')}, found ${names.join(', ')}.`); + } + } + return names; +}; + +/** + * Derive the final TypeScript alias for a schema-marked nullable result + * wrapper. The generated interface is parsed structurally so one-field + * wrappers and multiline/nested TypeScript types follow the same path as + * larger wrappers instead of depending on line-oriented regexes. + */ +export const deriveMarkedUnionAlias = (source, ownerName) => { + const sourceFile = generatedSourceFile(source, `${ownerName} union wrapper`); + const declarations = sourceFile.statements.filter( + (statement) => ts.isInterfaceDeclaration(statement) && statement.name.text === ownerName, + ); + if (declarations.length !== 1) { + throw new Error(`${ownerName} Union marker must have exactly one generated interface before rewriting; found ${declarations.length}.`); + } + + const declaration = declarations[0]; + if (declaration.members.length === 0) { + throw new Error(`${ownerName} Union marker cannot rewrite an empty generated interface.`); + } + + const names = declaration.members.map((member) => propertyName(member, ownerName)); + const exactDeclaration = requireExactInterfaceProperties(source, ownerName, names); + const unionEntries = []; + const seenTypes = new Set(); + let hasNull = false; + + const collectMembers = (typeNode, jsdoc) => { + if (ts.isParenthesizedTypeNode(typeNode)) { + collectMembers(typeNode.type, jsdoc); + return; + } + if (ts.isUnionTypeNode(typeNode)) { + for (const member of typeNode.types) collectMembers(member, jsdoc); + return; + } + + const normalized = typeNode.getText(sourceFile).replace(/\s+/g, ' ').trim(); + if (!normalized || normalized === 'undefined') return; + if (normalized === 'null') { + hasNull = true; + return; + } + if (seenTypes.has(normalized)) return; + seenTypes.add(normalized); + unionEntries.push({ jsdoc, type: normalized }); + }; + + for (const member of declaration.members) { + const name = propertyName(member, ownerName); + if (!member.questionToken) { + throw new Error(`${ownerName}.${name} Union marker field must remain optional.`); + } + if (!member.type) { + throw new Error(`${ownerName}.${name} Union marker field must retain its generated type.`); + } + collectMembers(member.type, exactDeclaration.propertyJSDoc(name, false)); + } + + if (hasNull) { + unionEntries.push({ jsdoc: null, type: 'null' }); + } + if (unionEntries.length === 0) { + throw new Error(`${ownerName} Union marker produced no representable alias members.`); + } + + const flatType = unionEntries.map((entry) => entry.type).join(' | '); + const documentedType = unionEntries.some((entry) => entry.jsdoc) + ? ['', ...unionEntries.flatMap((entry) => [...(entry.jsdoc ? [indentJSDoc(entry.jsdoc, ' ')] : []), ` | ${entry.type}`])].join('\n') + : flatType; + + return { + declaration: documentedType, + source: exactDeclaration.source, + type: flatType, + }; +}; + +/** + * Verify that every SDL generation marker has exactly one observable effect in + * the final TypeScript output. This turns graphql-codegen formatting drift + * into a hard failure instead of silently publishing a synchronous operation + * or an unflattened result wrapper. + */ +export const requireGeneratedMarkerEffects = (source, markers, unionContracts) => { + const sourceFile = generatedSourceFile(source, 'Schema marker postcondition'); + const interfaces = sourceFile.statements.filter(ts.isInterfaceDeclaration); + const aliases = sourceFile.statements.filter(ts.isTypeAliasDeclaration); + + for (const target of markers.futureFields) { + const separator = target.indexOf('.'); + const ownerName = target.slice(0, separator); + const fieldName = target.slice(separator + 1); + const owners = interfaces.filter((declaration) => declaration.name.text === ownerName); + const fields = owners.flatMap((owner) => owner.members.filter((member) => staticMemberName(member) === fieldName)); + if (owners.length !== 1 || fields.length !== 1) { + throw new Error(`${target} Future marker must map to exactly one generated property; found ${fields.length}.`); + } + const type = fields[0].type; + if ( + !type || + !ts.isTypeReferenceNode(type) || + !ts.isIdentifier(type.typeName) || + type.typeName.text !== 'Promise' || + type.typeArguments?.length !== 1 + ) { + throw new Error(`${target} Future marker did not produce exactly one Promise return.`); + } + } + + for (const typeName of markers.unionWrappers) { + const matchingAliases = aliases.filter((declaration) => declaration.name.text === typeName); + const matchingInterfaces = interfaces.filter((declaration) => declaration.name.text === typeName); + if (matchingAliases.length !== 1 || matchingInterfaces.length !== 0) { + throw new Error( + `${typeName} Union marker must produce exactly one type alias; found ${matchingAliases.length} aliases and ${matchingInterfaces.length} interfaces.`, + ); + } + const expectedMembers = unionContracts.get(typeName); + if (!expectedMembers) { + throw new Error(`${typeName} Union marker is missing its canonical alias contract.`); + } + const members = []; + const collectMembers = (typeNode) => { + if (ts.isParenthesizedTypeNode(typeNode)) { + collectMembers(typeNode.type); + } else if (ts.isUnionTypeNode(typeNode)) { + for (const member of typeNode.types) collectMembers(member); + } else { + members.push(typeNode.getText(sourceFile).replace(/\s+/g, ' ').trim()); + } + }; + collectMembers(matchingAliases[0].type); + const expectedSet = new Set(expectedMembers); + const actualSet = new Set(members); + if ( + members.length !== expectedMembers.length || + actualSet.size !== members.length || + expectedSet.size !== expectedMembers.length || + members.some((member) => !expectedSet.has(member)) + ) { + throw new Error( + `${typeName} Union marker alias body drifted; expected ${expectedMembers.join(' | ')}, found ${members.join(' | ')}.`, + ); + } + } +}; + +export const rewriteRequestPurchaseTypeAliases = (source) => { + const projection = TYPESCRIPT_CUSTOM_INPUT_PROJECTIONS.RequestPurchaseProps; + const requestPurchaseProps = requireTypeScriptInputContract(source, 'RequestPurchaseProps'); + const requestPurchaseJSDoc = requestPurchaseProps.propertyJSDoc('requestPurchase'); + const requestSubscriptionJSDoc = requestPurchaseProps.propertyJSDoc('requestSubscription'); + const purchaseTypeJSDoc = requestPurchaseProps.propertyJSDoc('type'); + const useAlternativeBillingJSDoc = requestPurchaseProps.propertyJSDoc('useAlternativeBilling'); + + let output = [ + source.slice(0, requestPurchaseProps.start), + [ + 'export type RequestPurchaseProps =', + ' | {', + indentJSDoc(requestPurchaseJSDoc, ' '), + ' request: RequestPurchasePropsByPlatforms;', + indentJSDoc(purchaseTypeJSDoc, ' '), + " type: 'in-app';", + indentJSDoc(useAlternativeBillingJSDoc, ' '), + ' useAlternativeBilling?: boolean | null;', + ' }', + ' | {', + indentJSDoc(requestSubscriptionJSDoc, ' '), + ' request: RequestSubscriptionPropsByPlatforms;', + indentJSDoc(purchaseTypeJSDoc, ' '), + " type: 'subs';", + indentJSDoc(useAlternativeBillingJSDoc, ' '), + ' useAlternativeBilling?: boolean | null;', + ' };\n\n', + ].join('\n'), + source.slice(requestPurchaseProps.end), + ].join(''); + + const mutationArgs = requireExactInterfaceProperties(output, projection.operationArgsOwner, [projection.sourceProperty]); + mutationArgs.assertPropertyContract(projection.sourceProperty, { + optional: false, + type: 'RequestPurchaseProps', + }); + const paramsJSDoc = mutationArgs.propertyJSDoc(projection.sourceProperty, false); + output = [ + output.slice(0, mutationArgs.start), + `${renderDocumentedTypeAlias(projection.operationArgsOwner, 'RequestPurchaseProps', paramsJSDoc)}\n\n`, + output.slice(mutationArgs.end), + ].join(''); + + return output; +}; diff --git a/packages/gql/scripts/fix-generated-types.mjs b/packages/gql/scripts/fix-generated-types.mjs index d41a44d16..392e4fcd6 100644 --- a/packages/gql/scripts/fix-generated-types.mjs +++ b/packages/gql/scripts/fix-generated-types.mjs @@ -2,69 +2,128 @@ import { readFileSync, writeFileSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; import { dirname, resolve } from 'node:path'; import { parse } from 'graphql'; -import { dedupeDeprecatedJSDocTags } from './generated-doc-comments.mjs'; +import { parseSchema } from '../codegen/core/parser.ts'; +import { transformSchema } from '../codegen/core/transformer.ts'; +import { GRAPHQL_TO_TYPESCRIPT, PLATFORM_TYPE_DEFAULTS, toKebabCase } from '../codegen/core/utils.ts'; +import { injectPropertyDeprecationJSDoc, injectTypeDeprecationJSDoc, operationArgsOwnerNames } from './generated-doc-comments.mjs'; +import { SCHEMA_FILE_NAMES } from '../schema-files.mjs'; +import { GENERATED_SYNC_MANIFEST, gqlPackageRelativePath } from '../generated-sync-manifest.mjs'; +import { + deriveMarkedUnionAlias, + operationFieldNames, + renderDocumentedTypeAlias, + requireExactInterfaceProperties, + requireExactTypeAlias, + requireGeneratedEnumContracts, + requireGeneratedMarkerEffects, + requireNoGraphqlCodegenScaffolding, + requireProductDiscriminantContracts, + requireTypeScriptInputContract, + resolveOperationArgsOwner, + rewriteRequestPurchaseTypeAliases, +} from './custom-generated-guards.mjs'; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); -const targetPath = resolve(__dirname, '../src/generated/types.ts'); -const schemaFiles = [ - resolve(__dirname, '../src/api.graphql'), - resolve(__dirname, '../src/api-ios.graphql'), - resolve(__dirname, '../src/api-android.graphql'), - // webhook.graphql adds `webhookEventsSince` to the Query interface - // and marks it `# Future` so it gets the Promise<> wrap that all - // async query fields require. Without this entry, the marker would - // be silently ignored — caught in PR #123 (https://github.com/hyodotdev/openiap/pull/123) review. - resolve(__dirname, '../src/webhook.graphql'), -]; -const schemaDefinitionFiles = [ - '../src/schema.graphql', - '../src/type.graphql', - '../src/type-ios.graphql', - '../src/type-android.graphql', - '../src/api.graphql', - '../src/api-ios.graphql', - '../src/api-android.graphql', - '../src/error.graphql', - '../src/event.graphql', -].map((relativePath) => resolve(__dirname, relativePath)); +const targetPath = resolve(__dirname, '..', gqlPackageRelativePath(GENERATED_SYNC_MANIFEST.typescript.source)); +const parsedSchema = parseSchema(); +const irSchema = transformSchema(parsedSchema); +const schemaDefinitionSources = parsedSchema.sdlContents; +const schemaDefinitionFiles = [...schemaDefinitionSources.keys()]; +const schemaMarkers = parsedSchema.markers; +const schemaDeprecations = parsedSchema.deprecations; +const ROOT_OPERATION_NAMES = Object.freeze(irSchema.operations.map(({ name }) => name)); +const typeScriptTypeFromIR = (type, scalarDirection = 'output') => { + if (type.kind === 'list') { + const element = typeScriptTypeFromIR(type.elementType, scalarDirection); + const nullableElement = type.elementType.nullable ? `${element} | null` : element; + return /[|&]/.test(nullableElement) ? `(${nullableElement})[]` : `${nullableElement}[]`; + } + if (type.kind === 'scalar') { + const scalar = type.name === 'Void' ? 'void' : GRAPHQL_TO_TYPESCRIPT[type.name]?.[scalarDirection]; + if (!scalar) { + throw new Error(`Unsupported TypeScript scalar: ${type.name}`); + } + return scalar; + } + if (!type.name) { + throw new Error(`Unnamed ${type.kind} cannot appear in generated TypeScript.`); + } + return type.name; +}; +const typeScriptArgumentTypeFromIR = (type) => { + const base = typeScriptTypeFromIR(type, 'input'); + return type.nullable ? `(${base} | null)` : base; +}; +const operationContracts = new Map( + irSchema.operations.flatMap((operation) => + operation.fields.map((field) => [ + `${operation.name}.${field.name}`, + { + rootName: operation.name, + fieldName: field.name, + arguments: field.args.map((argument) => ({ + name: argument.name, + optional: argument.type.nullable, + type: typeScriptArgumentTypeFromIR(argument.type), + })), + }, + ]), + ), +); +const operationFieldsByRoot = new Map( + irSchema.operations.map((operation) => [ + operation.name, + operation.fields.map(({ name }) => name).filter((name) => name !== '_placeholder'), + ]), +); +// Preserve the published TypeScript order for webhook string unions. Those +// unions historically use graphql-codegen's deterministic ordering rather +// than SDL order; changing the shared schema inventory must not churn a public +// generated contract. Deprecation and ownership scans still cover webhook. +const enumOrderSchemaFiles = new Set( + SCHEMA_FILE_NAMES.filter((fileName) => fileName !== 'webhook.graphql').map((fileName) => resolve(__dirname, `../src/${fileName}`)), +); +const webhookEnumNames = new Set(); let content = readFileSync(targetPath, 'utf8'); // eslint-disable-next-line no-console console.log('[fix-generated-types] transforming output'); -const scalarReplacements = new Map([ - ["Scalars['ID']['output']", 'string'], - ["Scalars['ID']['input']", 'string'], - ["Scalars['String']['output']", 'string'], - ["Scalars['String']['input']", 'string'], - ["Scalars['Boolean']['output']", 'boolean'], - ["Scalars['Boolean']['input']", 'boolean'], - ["Scalars['Int']['output']", 'number'], - ["Scalars['Int']['input']", 'number'], - ["Scalars['Float']['output']", 'number'], - ["Scalars['Float']['input']", 'number'], -]); +const scalarReplacements = new Map( + Object.entries(GRAPHQL_TO_TYPESCRIPT).flatMap(([name, { input, output }]) => [ + [`Scalars['${name}']['output']`, output], + [`Scalars['${name}']['input']`, input], + ]), +); for (const [from, to] of scalarReplacements) { - const pattern = new RegExp(from.replace(/[[\]]/g, (m) => `\\${m}`), 'g'); + const pattern = new RegExp( + from.replace(/[[\]]/g, (m) => `\\${m}`), + 'g', + ); content = content.replace(pattern, to); } -// Create simple type alias for PurchaseInput -const purchaseInputPattern = /export interface PurchaseInput \{[\s\S]*?\}\n+/; -if (purchaseInputPattern.test(content)) { - content = content.replace(purchaseInputPattern, 'export type PurchaseInput = Purchase;\n\n'); -} - const iosTypeMap = new Map(); const enumValueOrder = new Map(); +const typeDeprecations = schemaDeprecations.typeReasons; +const operationArgDeprecations = schemaDeprecations.operationArguments.map(({ rootName, fieldName, argumentName, reason }) => ({ + ownerNames: operationArgsOwnerNames(rootName, fieldName), + propertyName: argumentName, + reason, +})); for (const schemaPath of schemaDefinitionFiles) { - const sdl = readFileSync(schemaPath, 'utf8'); + const sdl = schemaDefinitionSources.get(schemaPath); const document = parse(sdl, { noLocation: true }); for (const definition of document.definitions) { - if ('name' in definition && definition.name) { + if (definition.kind === 'EnumTypeDefinition' || definition.kind === 'EnumTypeExtension') { + if (!enumOrderSchemaFiles.has(schemaPath)) { + webhookEnumNames.add(definition.name.value); + } + } + if (enumOrderSchemaFiles.has(schemaPath) && 'name' in definition && definition.name) { if (definition.kind === 'EnumTypeDefinition' || definition.kind === 'EnumTypeExtension') { const name = definition.name.value; const existing = enumValueOrder.get(name) ?? []; @@ -89,13 +148,7 @@ for (const [tsName, iosName] of iosTypeMap) { // Enforce IOS capitalization conventions for enum members and fields. content = content.replace(/\b([A-Za-z0-9]+)Ios\b/g, (_, prefix) => `${prefix}IOS`); content = content.replace(/\bIos\b/g, 'IOS'); - -const toKebabCase = (value) => value - .replace(/([a-z0-9])([A-Z])/g, '$1-$2') - .replace(/([A-Z])([A-Z][a-z])/g, '$1-$2') - .replace(/[_\s]+/g, '-') - .replace(/-+/g, '-') - .toLowerCase(); +content = injectPropertyDeprecationJSDoc(content, operationArgDeprecations); // Convert enums (except ErrorCode) to union literal types with kebab-case values. content = content.replace(/export enum (\w+) \{[\s\S]*?\}\n?/g, (match) => { @@ -113,15 +166,10 @@ content = content.replace(/export enum (\w+) \{[\s\S]*?\}\n?/g, (match) => { return `export type ${enumName} = ${literals.join(' | ')};\n`; }); -// Convert ErrorCode enum values to kebab-case -content = content.replace(/export enum [^{]+\{[\s\S]*?\}/g, (block) => { - const enumName = block.match(/export enum (\w+)/)[1]; - if (enumName === 'ErrorCode') { - return block.replace(/= '([^']+)'/g, (_, value) => `= '${toKebabCase(value)}'`); - } else { - return block.replace(/= '([^']+)'/g, (_, value) => `= '${toConstantCase(value)}'`); - } -}); +// ErrorCode is the only enum left after the conversion above. +content = content.replace(/export enum ErrorCode \{[\s\S]*?\}/, (block) => + block.replace(/= '([^']+)'/g, (_, value) => `= '${toKebabCase(value)}'`), +); const removeDefinition = (keyword) => { const pattern = new RegExp(`^export type ${keyword}[^]*?;\n`, 'm'); @@ -196,136 +244,45 @@ const convertArrays = () => { convertArrays(); -const toConstantCase = (value) => value - .replace(/([a-z0-9])([A-Z])/g, '$1_$2') - .replace(/([A-Z])([A-Z][a-z])/g, '$1_$2') - .replace(/-/g, '_') - .toUpperCase(); - -content = content.replace(/export enum [^{]+\{[\s\S]*?\}/g, (block) => { - const enumName = block.match(/export enum (\w+)/)[1]; - if (enumName === 'ErrorCode') return block; - return block.replace(/= '([^']+)'/g, (_, value) => `= '${toConstantCase(value)}'`); -}); - // Convert platform/type fields to literals and introduce a shared base for products // This keeps ProductCommon android-focused while reusing field definitions -const productTypeMapping = { - ProductIOS: { platform: "'ios'", type: "'in-app'" }, - ProductAndroid: { platform: "'android'", type: "'in-app'" }, - ProductSubscriptionIOS: { platform: "'ios'", type: "'subs'" }, - ProductSubscriptionAndroid: { platform: "'android'", type: "'subs'" }, -}; - -for (const [typeName, literals] of Object.entries(productTypeMapping)) { +for (const [typeName, defaults] of Object.entries(PLATFORM_TYPE_DEFAULTS)) { + const literals = { + platform: `'${defaults.platform}'`, + type: `'${defaults.type}'`, + }; const interfacePattern = new RegExp( - `(export interface ${typeName} extends ProductCommon \\{[\\s\\S]*?)` + - `(platform: [^;]+;)` + - `([\\s\\S]*?)` + - `(type: [^;]+;)`, - 'g' + `(export interface ${typeName} extends ProductCommon \\{[\\s\\S]*?)` + `(platform: [^;]+;)` + `([\\s\\S]*?)` + `(type: [^;]+;)`, + 'g', ); - content = content.replace(interfacePattern, (match, before, platformField, middle, typeField) => { + content = content.replace(interfacePattern, (_match, before, _platformField, middle) => { return `${before}platform: ${literals.platform};${middle}type: ${literals.type};`; }); } // Normalize ProductCommon to a single definition with literal union platform/type +const productDefaultUnion = (key) => + [...new Set(Object.values(PLATFORM_TYPE_DEFAULTS).map((defaults) => defaults[key]))] + .sort() + .map((value) => `'${value}'`) + .join(' | '); +const productPlatformUnion = productDefaultUnion('platform'); +const productTypeUnion = productDefaultUnion('type'); const productCommonMatch = content.match(/export interface ProductCommon \{([\s\S]*?)\}\n/); if (productCommonMatch) { const body = productCommonMatch[1] - .replace(/platform: 'android';/, "platform: 'android' | 'ios';") - .replace(/platform: IapPlatform;/, "platform: 'android' | 'ios';") - .replace(/type: 'in-app' \| 'subs';/, "type: 'in-app' | 'subs';") - .replace(/type: ProductType;/, "type: 'in-app' | 'subs';"); + .replace(/platform: 'android';/, `platform: ${productPlatformUnion};`) + .replace(/platform: IapPlatform;/, `platform: ${productPlatformUnion};`) + .replace(/type: 'in-app' \| 'subs';/, `type: ${productTypeUnion};`) + .replace(/type: ProductType;/, `type: ${productTypeUnion};`); content = content.replace(productCommonMatch[0], `export interface ProductCommon {${body}} \n`); } -// Collapse ProductCommonBase/ProductCommon into a single ProductCommon interface -const productCommonTypePattern = /export type ProductCommon = ProductCommonBase & \{[\s\S]*?platform: 'android';[\s\S]*?type: 'in-app' \| 'subs';[\s\S]*?\};\s*\n/; -const productCommonBasePattern = /export type ProductCommonBase = \{([\s\S]*?)\};\s*\n/; -const productCommonBaseMatch = content.match(productCommonBasePattern); -if (productCommonTypePattern.test(content)) { - const baseBody = (productCommonBaseMatch ? productCommonBaseMatch[1] : ` - currency: string; - debugDescription?: (string | null); - description: string; - displayName?: (string | null); - displayPrice: string; - id: string; - price?: (number | null); - title: string; -`).trimEnd(); - const merged = [ - 'export interface ProductCommon {', - baseBody, - " platform: 'android' | 'ios';", - " type: 'in-app' | 'subs';", - '}', - '', - ].join('\n'); - content = content.replace(productCommonTypePattern, merged); - if (productCommonBaseMatch) { - content = content.replace(productCommonBasePattern, ''); - } -} - -// Drop any generated ProductCommonIOS types -content = content.replace(/export type ProductCommonIOS = [\s\S]*?\};\s*\n/g, ''); -content = content.replace(/export interface ProductCommonIOS \{[\s\S]*?\}\s*\n/g, ''); - -// Ensure product interfaces extend ProductCommon directly -content = content.replace( - /export interface ProductIOS extends ProductCommonIOS \{/g, - 'export interface ProductIOS extends ProductCommon {' -); -content = content.replace( - /export interface ProductSubscriptionIOS extends ProductCommonIOS \{/g, - 'export interface ProductSubscriptionIOS extends ProductCommon {' -); - -content = content.replace( - /export interface RequestPurchaseProps \{[\s\S]*?\}\n\n/, - [ - 'export type RequestPurchaseProps =', - ' | {', - ' /** Per-platform purchase request props */', - ' request: RequestPurchasePropsByPlatforms;', - " type: 'in-app';", - ' /** Use alternative billing (Google Play alternative billing, Apple external purchase link) */', - ' useAlternativeBilling?: boolean | null;', - ' }', - ' | {', - ' /** Per-platform subscription request props */', - ' request: RequestSubscriptionPropsByPlatforms;', - " type: 'subs';", - ' /** Use alternative billing (Google Play alternative billing, Apple external purchase link) */', - ' useAlternativeBilling?: boolean | null;', - ' };\n\n', - ].join('\n'), -); +const purchaseInput = requireTypeScriptInputContract(content, 'PurchaseInput'); +content = [content.slice(0, purchaseInput.start), 'export type PurchaseInput = Purchase;\n\n', content.slice(purchaseInput.end)].join(''); -content = content.replace( - /export interface MutationRequestPurchaseArgs \{[\s\S]*?\}\n\n/, - [ - 'export type MutationRequestPurchaseArgs =', - ' | {', - ' /** Per-platform purchase request props */', - ' request: RequestPurchasePropsByPlatforms;', - " type: 'in-app';", - ' /** Use alternative billing (Google Play alternative billing, Apple external purchase link) */', - ' useAlternativeBilling?: boolean | null;', - ' }', - ' | {', - ' /** Per-platform subscription request props */', - ' request: RequestSubscriptionPropsByPlatforms;', - " type: 'subs';", - ' /** Use alternative billing (Google Play alternative billing, Apple external purchase link) */', - ' useAlternativeBilling?: boolean | null;', - ' };\n\n', - ].join('\n'), -); +content = rewriteRequestPurchaseTypeAliases(content); const needsParentheses = (value) => { const trimmed = value.trim(); @@ -344,42 +301,7 @@ const needsParentheses = (value) => { return /[|&]/.test(trimmed); }; -const unionWrapperNames = new Set(); -for (const file of schemaDefinitionFiles) { - let expectTypeName = false; - for (const line of readFileSync(file, 'utf8').split(/\r?\n/)) { - const trimmed = line.trim(); - if (trimmed.startsWith('#') && trimmed.toLowerCase().includes('=> union')) { - expectTypeName = true; - continue; - } - if (expectTypeName) { - if (trimmed.length === 0) { - continue; - } - if (trimmed.startsWith('#')) { - continue; - } - const typeMatch = trimmed.match(/^type\s+([A-Za-z0-9_]+)/); - if (typeMatch) { - unionWrapperNames.add(typeMatch[1]); - } - expectTypeName = false; - } - } -} - -// Extend FetchProductsResult to support mixed arrays for 'all' type -// MUST be done BEFORE interface parsing to ensure optionalUnionInterfaces map has the correct union -// The generated union `Product[] | ProductSubscription[] | null` doesn't support mixed arrays -// Add `(Product | ProductSubscription)[]` to the union to enable type narrowing -const fetchProductsResultPattern = /export type FetchProductsResult = Product\[\] \| ProductSubscription\[\] \| null;/; -if (fetchProductsResultPattern.test(content)) { - content = content.replace( - fetchProductsResultPattern, - 'export type FetchProductsResult = Product[] | ProductSubscription[] | (Product | ProductSubscription)[] | null;' - ); -} +const unionWrapperNames = schemaMarkers.unionWrappers; const singleFieldInterfaceTypes = new Map(); const optionalUnionInterfaces = new Map(); @@ -387,7 +309,7 @@ const interfacePattern = /export interface (\w+) \{\n([\s\S]*?)\n\}\n/g; let interfaceMatch; while ((interfaceMatch = interfacePattern.exec(content)) !== null) { const [, name, body] = interfaceMatch; - if (['Query', 'Mutation', 'Subscription'].includes(name)) { + if (ROOT_OPERATION_NAMES.includes(name)) { continue; } const rawLines = body.split(/\r?\n/); @@ -396,9 +318,12 @@ while ((interfaceMatch = interfacePattern.exec(content)) !== null) { .filter((line) => line.length > 0 && !line.startsWith('/**') && !line.startsWith('*')); const propertyLines = fieldLines.filter((line) => /^[A-Za-z0-9_]+\??:/.test(line)); - const propertyMatches = propertyLines - .map((line) => line.match(/^([A-Za-z0-9_]+)(\??): ([^;]+);$/)) - .filter(Boolean); + const propertyMatches = propertyLines.map((line) => line.match(/^([A-Za-z0-9_]+)(\??): ([^;]+);$/)).filter(Boolean); + + if (unionWrapperNames.has(name)) { + optionalUnionInterfaces.set(name, deriveMarkedUnionAlias(content, name)); + continue; + } if (propertyMatches.length === 0) { continue; @@ -409,182 +334,91 @@ while ((interfaceMatch = interfacePattern.exec(content)) !== null) { if (!shouldAlias) { continue; } - const [, , optionalMarker, rawType] = propertyMatches[0]; + const [propertyMatch] = propertyMatches; + const [, propertyName, optionalMarker, rawType] = propertyMatch; + const declaration = requireExactInterfaceProperties(content, name, [propertyName]); const grouped = needsParentheses(rawType.trim()) ? `(${rawType.trim()})` : rawType.trim(); - let finalType = optionalMarker === '?' - ? `${grouped} | undefined` - : rawType.trim(); + let finalType = optionalMarker === '?' ? `${grouped} | undefined` : rawType.trim(); if (name === 'VoidResult') { finalType = 'void'; } - singleFieldInterfaceTypes.set(name, finalType); - continue; - } - - const allOptional = propertyMatches.every((match) => match[2] === '?'); - if (!allOptional) { - continue; - } - - if (!unionWrapperNames.has(name)) { + const propertyJSDoc = declaration.propertyJSDoc(propertyName, false); + singleFieldInterfaceTypes.set(name, { + declaration: finalType, + jsdoc: propertyJSDoc, + source: declaration.source, + type: finalType, + }); continue; } - - const stripParens = (value) => { - let result = value.trim(); - const isWrapped = (str) => { - if (!str.startsWith('(') || !str.endsWith(')')) return false; - let depth = 0; - for (let i = 0; i < str.length; i += 1) { - const ch = str[i]; - if (ch === '(') depth += 1; - else if (ch === ')') depth -= 1; - if (depth === 0 && i < str.length - 1) { - return false; - } - } - return depth === 0; - }; - - while (isWrapped(result)) { - result = result.slice(1, -1).trim(); - } - return result; - }; - - const splitUnion = (value) => { - const tokens = []; - let current = ''; - let depth = 0; - for (let i = 0; i < value.length; i += 1) { - const ch = value[i]; - if (ch === '<' || ch === '(') { - depth += 1; - } else if (ch === '>' || ch === ')') { - depth -= 1; - } - if (ch === '|' && depth === 0) { - tokens.push(current.trim()); - current = ''; - continue; - } - current += ch; - } - if (current.trim()) { - tokens.push(current.trim()); - } - return tokens; - }; - - const unionTypes = []; - const seenTypes = new Set(); - let hasNull = false; - - for (const match of propertyMatches) { - const cleaned = stripParens(match[3]); - for (const token of splitUnion(cleaned)) { - const normalized = token.trim(); - if (!normalized || normalized === 'undefined') continue; - if (normalized === 'null') { - hasNull = true; - continue; - } - if (seenTypes.has(normalized)) continue; - seenTypes.add(normalized); - unionTypes.push(normalized); - } - } - - if (hasNull) { - unionTypes.push('null'); - } - - if (unionTypes.length > 0) { - optionalUnionInterfaces.set(name, unionTypes.join(' | ')); - } } - - - -const rootNames = ['Query', 'Mutation', 'Subscription']; -for (const root of rootNames) { +for (const root of ROOT_OPERATION_NAMES) { const pattern = new RegExp(`export interface ${root} \\{\\n([\\s\\S]*?)\\n\\}(\\n*)`); - content = content.replace(pattern, (match, body, trailingNewlines) => { + content = content.replace(pattern, (_match, body) => { const lines = body.split(/\r?\n/); - const transformed = lines.map((line) => { - const fieldMatch = line.match(/^(\s*)([A-Za-z0-9_]+)(\??):\s*([^;]+);$/); - if (!fieldMatch) { - return line; - } - const [, indent, fieldName, optionalMarker, typeSegmentRaw] = fieldMatch; - let typeSegment = typeSegmentRaw; - if (!typeSegment.includes('Promise<')) { - return line; - } - let updated = false; - for (const [interfaceName, replacementType] of singleFieldInterfaceTypes) { - const namePattern = new RegExp(`\\b${interfaceName}\\b`); - if (!namePattern.test(typeSegment)) { - continue; + const transformed = lines + .map((line) => { + const fieldMatch = line.match(/^(\s*)([A-Za-z0-9_]+)(\??):\s*([^;]+);$/); + if (!fieldMatch) { + return line; } - const replacePattern = new RegExp(`\\b${interfaceName}\\b`, 'g'); - typeSegment = typeSegment.replace(replacePattern, replacementType); - updated = true; - } - for (const [interfaceName, unionType] of optionalUnionInterfaces) { - const namePattern = new RegExp(`\\b${interfaceName}\\b`); - if (!namePattern.test(typeSegment)) { - continue; + const [, indent, fieldName, optionalMarker, typeSegmentRaw] = fieldMatch; + let typeSegment = typeSegmentRaw; + if (!typeSegment.includes('Promise<')) { + return line; } - const replacePattern = new RegExp(`\\b${interfaceName}\\b`, 'g'); - typeSegment = typeSegment.replace(replacePattern, `(${unionType})`); - updated = true; - } - if (!updated) { - return line; - } - return `${indent}${fieldName}${optionalMarker}: ${typeSegment};`; - }).join('\n'); - return `export interface ${root} {\n${transformed}\n}\n${trailingNewlines}`; + let updated = false; + for (const [interfaceName, replacement] of singleFieldInterfaceTypes) { + const namePattern = new RegExp(`\\b${interfaceName}\\b`); + if (!namePattern.test(typeSegment)) { + continue; + } + const replacePattern = new RegExp(`\\b${interfaceName}\\b`, 'g'); + typeSegment = typeSegment.replace(replacePattern, replacement.type); + updated = true; + } + for (const [interfaceName, union] of optionalUnionInterfaces) { + const namePattern = new RegExp(`\\b${interfaceName}\\b`); + if (!namePattern.test(typeSegment)) { + continue; + } + const replacePattern = new RegExp(`\\b${interfaceName}\\b`, 'g'); + typeSegment = typeSegment.replace(replacePattern, `(${union.type})`); + updated = true; + } + if (!updated) { + return line; + } + return `${indent}${fieldName}${optionalMarker}: ${typeSegment};`; + }) + .join('\n'); + return `export interface ${root} {\n${transformed}\n}\n\n`; }); } -for (const [name, aliasType] of singleFieldInterfaceTypes) { - const pattern = new RegExp(`export interface ${name} \\{[\\s\\S]*?\\}\n+`, 'g'); - content = content.replace(pattern, `export type ${name} = ${aliasType};\n\n`); -} - -for (const [name, unionType] of optionalUnionInterfaces) { - const pattern = new RegExp(`export interface ${name} \\{[\\s\\S]*?\\}\n+`, 'g'); - content = content.replace(pattern, `export type ${name} = ${unionType};\n\n`); +for (const [name, alias] of singleFieldInterfaceTypes) { + const occurrences = content.split(alias.source).length - 1; + if (occurrences !== 1) { + throw new Error(`${name} generated interface replacement must match exactly once; found ${occurrences}.`); + } + content = content.replace(alias.source, `${renderDocumentedTypeAlias(name, alias.declaration, alias.jsdoc)}\n\n`); } -const futureFields = new Set(); -for (const file of schemaFiles) { - let previousWasMarker = false; - for (const line of readFileSync(file, 'utf8').split(/\r?\n/)) { - const trimmed = line.trim(); - if (trimmed.startsWith('#') && trimmed.toLowerCase().includes('future')) { - previousWasMarker = true; - continue; - } - if (previousWasMarker) { - const match = trimmed.match(/^([A-Za-z0-9_]+)\s*\(/) || trimmed.match(/^([A-Za-z0-9_]+)\s*:/); - if (match) { - futureFields.add(match[1]); - } - previousWasMarker = false; - } +for (const [name, union] of optionalUnionInterfaces) { + const occurrences = content.split(union.source).length - 1; + if (occurrences !== 1) { + throw new Error(`${name} generated interface replacement must match exactly once; found ${occurrences}.`); } + content = content.replace(union.source, `${renderDocumentedTypeAlias(name, union.declaration)}\n\n`); } const wrapReturns = (interfaceName) => { const pattern = new RegExp(`export interface ${interfaceName} \\\{\\n([\\s\\S]*?)\\n\\}`, 'g'); - content = content.replace(pattern, (match, body) => { + content = content.replace(pattern, (_match, body) => { // Use multiline mode and [^;\n]+ to prevent matching across lines const transformed = body.replace(/^(\s*)([A-Za-z0-9_]+)(\??: )(?!Promise<)([^;\n]+);$/gm, (line, indent, name, sep, type) => { - if (!futureFields.has(name)) { + if (!schemaMarkers.futureFields.has(`${interfaceName}.${name}`)) { return line; } return `${indent}${name}${sep}Promise<${type}>;`; @@ -596,31 +430,21 @@ const wrapReturns = (interfaceName) => { wrapReturns('Query'); wrapReturns('Mutation'); -for (const [name, aliasType] of singleFieldInterfaceTypes) { - content = content.replaceAll(`Promise<${name}>`, `Promise<${aliasType}>`); +for (const [name, alias] of singleFieldInterfaceTypes) { + content = content.replaceAll(`Promise<${name}>`, `Promise<${alias.type}>`); } -for (const [name, unionType] of optionalUnionInterfaces) { - content = content.replaceAll(`Promise<${name}>`, `Promise<(${unionType})>`); +for (const [name, union] of optionalUnionInterfaces) { + content = content.replaceAll(`Promise<${name}>`, `Promise<(${union.type})>`); const nullableToken = `Promise<(${name} | null)>`; if (content.includes(nullableToken)) { - const unionWithNull = unionType.includes('null') ? unionType : `${unionType} | null`; + const unionWithNull = union.type.includes('null') ? union.type : `${union.type} | null`; content = content.replaceAll(nullableToken, `Promise<(${unionWithNull})>`); } } -// Fix Query interface to use FetchProductsResult type alias instead of inline union -// This ensures the Query['fetchProducts'] return type matches our implementation -// Must be done AFTER singleFieldInterfaceTypes replacement expands the type -content = content.replace( - /fetchProducts: Promise<\(Product\[\] \| ProductSubscription\[\] \| \(Product \| ProductSubscription\)\[\] \| null\)>/g, - 'fetchProducts: Promise' -); - content = content.replace(/^\s*_placeholder\??: [^;]+;\n/gm, ''); -const ROOT_DEFINITIONS = ['Query', 'Mutation', 'Subscription']; - const helperMarkers = (root) => ({ start: `// -- ${root} helper types (auto-generated)`, end: `// -- End ${root.toLowerCase()} helper types`, @@ -637,36 +461,35 @@ const removeRootHelpers = (root) => { content = content.slice(0, startIdx) + content.slice(finalEnd); }; -const findArgsType = (root, pascalFieldName) => { - const prefixes = new Set([ - `${root}${pascalFieldName}Args`, - `${root}${pascalFieldName.replace(/IOS/g, 'Ios')}Args`, - `${root}${pascalFieldName.replace(/Ios/g, 'IOS')}Args`, - ]); - for (const name of prefixes) { - if ( - content.includes(`export interface ${name} {`) || - content.includes(`export type ${name} =`) - ) { - return name; - } +const findArgsType = (root, fieldName) => { + const operationPath = `${root}.${fieldName}`; + const contract = operationContracts.get(operationPath); + if (!contract) { + throw new Error(`${operationPath} exists in generated TypeScript but not in the canonical SDL operation root.`); } - return 'never'; + const argsType = resolveOperationArgsOwner(content, { + rootName: root, + fieldName, + ownerNames: operationArgsOwnerNames(root, fieldName), + argumentCount: contract.arguments.length, + argumentContracts: contract.arguments, + }); + const allOptional = contract.arguments.length > 0 && contract.arguments.every(({ optional }) => optional); + return { + argsType, + mapType: contract.arguments.length > 1 && allOptional ? `${argsType} | undefined` : argsType, + }; }; const buildRootHelpers = (root) => { - const rootMatch = content.match(new RegExp(`export interface ${root} {\n([\\s\\S]*?)\n}\n`)); - if (!rootMatch) return ''; - const body = rootMatch[1]; - const fieldPattern = /^\s*([A-Za-z0-9_]+)\??:\s*[^;]+;$/gm; - const entries = []; - let fieldMatch; - while ((fieldMatch = fieldPattern.exec(body)) !== null) { - const fieldName = fieldMatch[1]; - const pascal = fieldName[0].toUpperCase() + fieldName.slice(1); - const argsType = findArgsType(root, pascal); - entries.push({ fieldName, argsType }); + const expectedFields = operationFieldsByRoot.get(root); + if (!expectedFields) { + throw new Error(`${root} is missing from the canonical IR operation roots.`); } + const entries = operationFieldNames(content, root, expectedFields).map((fieldName) => { + const { mapType } = findArgsType(root, fieldName); + return { fieldName, mapType }; + }); if (entries.length === 0) return ''; const { start, end } = helperMarkers(root); const mapName = `${root}ArgsMap`; @@ -675,8 +498,8 @@ const buildRootHelpers = (root) => { const lines = []; lines.push(start); lines.push(`export type ${mapName} = {`); - for (const { fieldName, argsType } of entries) { - lines.push(` ${fieldName}: ${argsType};`); + for (const { fieldName, mapType } of entries) { + lines.push(` ${fieldName}: ${mapType};`); } lines.push('};'); lines.push(''); @@ -696,7 +519,7 @@ const buildRootHelpers = (root) => { }; const helperBlocks = []; -for (const root of ROOT_DEFINITIONS) { +for (const root of ROOT_OPERATION_NAMES) { removeRootHelpers(root); const block = buildRootHelpers(root); if (block) helperBlocks.push(block); @@ -709,6 +532,35 @@ if (helperBlocks.length > 0) { content += helperBlocks.join('\n'); } -content = dedupeDeprecatedJSDocTags(content); +content = injectTypeDeprecationJSDoc(content, typeDeprecations); +content = content.replace(/(?:\r?\n){3,}(?=export )/g, '\n\n'); +const enumContracts = new Map( + irSchema.enums.map((irEnum) => { + const values = irEnum.values.map(({ rawValue }) => rawValue); + return [irEnum.name, irEnum.name === 'ErrorCode' || webhookEnumNames.has(irEnum.name) ? values.sort() : values]; + }), +); +requireGeneratedEnumContracts(content, enumContracts); +requireExactTypeAlias(content, 'VoidResult', 'void'); +requireProductDiscriminantContracts(content); + +const unionContracts = new Map( + irSchema.objects + .filter((object) => object.isResultUnion) + .map((object) => { + const entries = object.resultUnionEntries ?? []; + const members = []; + let hasNull = false; + for (const entry of entries) { + const member = typeScriptTypeFromIR(entry.type); + if (!members.includes(member)) members.push(member); + hasNull ||= entry.type.nullable; + } + if (hasNull) members.push('null'); + return [object.name, members]; + }), +); +requireGeneratedMarkerEffects(content, schemaMarkers, unionContracts); +requireNoGraphqlCodegenScaffolding(content); writeFileSync(targetPath, content); diff --git a/packages/gql/scripts/generated-doc-comments.mjs b/packages/gql/scripts/generated-doc-comments.mjs index c71a0d7c2..0dca687e8 100644 --- a/packages/gql/scripts/generated-doc-comments.mjs +++ b/packages/gql/scripts/generated-doc-comments.mjs @@ -1,25 +1,155 @@ -const JSDOC_BLOCK = /\/\*\*[\s\S]*?\*\//g; -const DEPRECATED_TAG_LINE = /^\s*(?:\/\*\*|\*)\s*@deprecated\b/; +import ts from 'typescript'; + +const escapeRegExp = (value) => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + +const appendDeprecatedTag = (block, reason, typeName) => { + if (/(?:\/\*\*|\*)\s*@deprecated\b/.test(block)) { + throw new Error(`${typeName} already contains a manual @deprecated JSDoc tag.`); + } + + const normalizedReason = reason.replace(/\s+/g, ' ').trim(); + if (!normalizedReason || normalizedReason.includes('*/')) { + throw new Error(`${typeName} has an invalid @deprecated reason.`); + } + + if (block.includes('\n')) { + const closingIndex = block.lastIndexOf('*/'); + const beforeClosing = block.slice(0, closingIndex).replace(/\s*$/, ''); + return `${beforeClosing}\n * @deprecated ${normalizedReason}\n */`; + } + + const prose = block.slice(3, -2).trim(); + return ['/**', ...(prose ? [` * ${prose}`] : []), ` * @deprecated ${normalizedReason}`, ' */'].join('\n'); +}; + +/** + * graphql-codegen emits field-level deprecation tags, but GraphQL object type + * deprecation is a project extension and is omitted from TypeScript output. + * Inject the canonical type directive reason into the generated declaration's + * nearest JSDoc block, failing closed if the declaration or ownership is + * ambiguous. + */ +export function injectTypeDeprecationJSDoc(source, deprecations) { + let output = source; + + for (const [typeName, reason] of deprecations) { + const declarationRe = new RegExp(`(^|\\n)(export\\s+(?:enum|interface|type)\\s+${escapeRegExp(typeName)}\\b)`, 'm'); + const matches = [...output.matchAll(new RegExp(declarationRe.source, 'gm'))]; + if (matches.length !== 1) { + throw new Error(`${typeName} must have exactly one generated TypeScript declaration; found ${matches.length}.`); + } + + const declarationIndex = matches[0].index + (matches[0][1]?.length ?? 0); + const prefix = output.slice(0, declarationIndex); + const blockStart = prefix.lastIndexOf('/**'); + const jsdoc = blockStart === -1 ? null : /^\/\*\*[\s\S]*?\*\/\s*$/.exec(prefix.slice(blockStart)); + if (!jsdoc) { + const block = appendDeprecatedTag('/** */', reason, typeName); + output = `${output.slice(0, declarationIndex)}${block}\n${output.slice(declarationIndex)}`; + continue; + } + + const blockEnd = blockStart + jsdoc[0].trimEnd().length; + const block = output.slice(blockStart, blockEnd); + const replacement = appendDeprecatedTag(block, reason, typeName); + output = output.slice(0, blockStart) + replacement + output.slice(blockEnd); + } + + return output; +} + +const staticPropertyName = (member) => { + if (!ts.isPropertySignature(member)) return null; + if (ts.isIdentifier(member.name) || ts.isStringLiteral(member.name) || ts.isNumericLiteral(member.name)) { + return member.name.text; + } + return null; +}; + +export const operationArgsOwnerNames = (rootName, fieldName) => { + const pascalFieldName = `${fieldName[0]?.toUpperCase() ?? ''}${fieldName.slice(1)}`; + return [ + `${rootName}${pascalFieldName.replace(/IOS/g, 'Ios')}Args`, + `${rootName}${pascalFieldName}Args`, + `${rootName}${pascalFieldName.replace(/Ios/g, 'IOS')}Args`, + ].filter((name, index, names) => names.indexOf(name) === index); +}; + +const reindentJSDoc = (block, indent) => + block + .split(/\r?\n/) + .map((line, index) => { + const normalized = line.trimStart(); + return index === 0 ? normalized : `${indent}${normalized.startsWith('*') ? ' ' : ''}${normalized}`; + }) + .join('\n'); /** - * Keep the first real `@deprecated` JSDoc tag in each block. - * - * GraphQL codegen appends the directive reason after the schema description. - * Descriptions also carry richer deprecation guidance for non-TypeScript - * generators, so TypeScript can receive two tags. Prose that merely mentions - * `@deprecated` must remain untouched. + * graphql-codegen does not emit @deprecated tags for operation arguments. + * Attach canonical directive reasons to their generated Args properties before + * later compatibility rewrites run. */ -export function dedupeDeprecatedJSDocTags(source) { - return source.replace(JSDOC_BLOCK, (block) => { - let hasDeprecatedTag = false; - return block +export function injectPropertyDeprecationJSDoc(source, deprecations) { + const sourceFile = ts.createSourceFile('generated-types.ts', source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS); + if (sourceFile.parseDiagnostics.length > 0) { + throw new Error( + `Generated TypeScript could not be parsed for property deprecations: ${sourceFile.parseDiagnostics + .map((diagnostic) => diagnostic.messageText) + .join('; ')}`, + ); + } + + const replacements = []; + for (const { ownerName, ownerNames = ownerName ? [ownerName] : [], propertyName, reason } of deprecations) { + const declarations = sourceFile.statements.filter( + (statement) => ts.isInterfaceDeclaration(statement) && ownerNames.includes(statement.name.text), + ); + if (declarations.length !== 1) { + throw new Error(`${ownerNames.join(' or ')} must have exactly one generated TypeScript interface; found ${declarations.length}.`); + } + const resolvedOwnerName = declarations[0].name.text; + const members = declarations[0].members.filter((member) => staticPropertyName(member) === propertyName); + if (members.length !== 1) { + throw new Error(`${resolvedOwnerName}.${propertyName} must have exactly one generated TypeScript property; found ${members.length}.`); + } + + const member = members[0]; + const memberStart = member.getStart(sourceFile); + const leadingStart = member.getFullStart(); + const leading = source.slice(leadingStart, memberStart); + const docs = [...leading.matchAll(/\/\*\*[\s\S]*?\*\//g)]; + if (docs.length > 1) { + throw new Error(`${resolvedOwnerName}.${propertyName} has ambiguous generated JSDoc; found ${docs.length} blocks.`); + } + if (docs.length === 1) { + const start = leadingStart + docs[0].index; + const block = docs[0][0]; + const lineStart = source.lastIndexOf('\n', start - 1) + 1; + const indent = source.slice(lineStart, start); + replacements.push({ + start, + end: start + block.length, + text: reindentJSDoc(appendDeprecatedTag(block, reason, `${resolvedOwnerName}.${propertyName}`), indent), + }); + continue; + } + + const lineStart = source.lastIndexOf('\n', memberStart - 1) + 1; + const indent = source.slice(lineStart, memberStart); + const block = appendDeprecatedTag('/** */', reason, `${resolvedOwnerName}.${propertyName}`) .split('\n') - .filter((line) => { - if (!DEPRECATED_TAG_LINE.test(line)) return true; - if (hasDeprecatedTag) return false; - hasDeprecatedTag = true; - return true; - }) + .map((line, index) => (index === 0 ? line : `${indent}${line}`)) .join('\n'); - }); + replacements.push({ + start: memberStart, + end: memberStart, + text: `${block}\n${indent}`, + }); + } + + let output = source; + for (const replacement of replacements.sort((a, b) => b.start - a.start)) { + output = output.slice(0, replacement.start) + replacement.text + output.slice(replacement.end); + } + return output; } diff --git a/packages/gql/scripts/generated-sync-materializer.mjs b/packages/gql/scripts/generated-sync-materializer.mjs new file mode 100644 index 000000000..907133d73 --- /dev/null +++ b/packages/gql/scripts/generated-sync-materializer.mjs @@ -0,0 +1,15 @@ +import { postProcessKotlinSource } from './kotlin-platform-postprocess.mjs'; + +const MODE_HANDLERS = Object.freeze({ + copy: (source) => source, + 'google-kotlin': (source) => postProcessKotlinSource(source, 'google'), + 'kmp-kotlin': (source) => postProcessKotlinSource(source, 'kmp'), +}); + +export function materializeGeneratedSyncEdge(edge, source) { + const handler = MODE_HANDLERS[edge.mode]; + if (!handler) { + throw new Error(`Unknown sync mode "${edge.mode}" for ${edge.groupName}.${edge.targetName}`); + } + return handler(source); +} diff --git a/packages/gql/scripts/kotlin-platform-postprocess.mjs b/packages/gql/scripts/kotlin-platform-postprocess.mjs new file mode 100644 index 000000000..060792eff --- /dev/null +++ b/packages/gql/scripts/kotlin-platform-postprocess.mjs @@ -0,0 +1,185 @@ +const PROFILES = Object.freeze({ + google: Object.freeze({ + packageName: 'dev.hyo.openiap', + blankLineBeforePackage: false, + validateEnumRoundTrips: true, + }), + kmp: Object.freeze({ + packageName: 'io.github.hyochan.kmpiap.openiap', + blankLineBeforePackage: true, + validateEnumRoundTrips: false, + }), +}); + +function setPackage(source, { packageName, blankLineBeforePackage }) { + const lines = source.split('\n'); + const packageIndices = []; + const fileAnnotationIndices = []; + + for (let index = 0; index < lines.length; index += 1) { + if (lines[index].startsWith('package ')) packageIndices.push(index); + if (lines[index].startsWith('@file:')) fileAnnotationIndices.push(index); + } + + if (packageIndices.length > 1) { + throw new Error(`Kotlin source contains multiple package declarations`); + } + + if (packageIndices.length === 1) { + const packageIndex = packageIndices[0]; + const lastFileAnnotation = fileAnnotationIndices.at(-1) ?? -1; + if (packageIndex > lastFileAnnotation) { + lines[packageIndex] = `package ${packageName}`; + return lines.join('\n'); + } + lines.splice(packageIndex, 1); + } + + const insertionIndex = lines.reduce((last, line, index) => (line.startsWith('@file:') ? index : last), -1) + 1; + const insertion = blankLineBeforePackage ? ['', `package ${packageName}`] : [`package ${packageName}`]; + lines.splice(insertionIndex, 0, ...insertion); + return lines.join('\n'); +} + +function ensureEnumCompanionSemicolons(source) { + return source.replace(/(\n\s*\w+\("[^"]*"\))\n\n(\s+companion object)/g, '$1;\n\n$2'); +} + +function rewriteGoogleEnumAliases(source) { + const lines = source.split('\n'); + + for (let index = 0; index < lines.length; index += 1) { + const header = lines[index].match(/^\s*public\s+enum\s+class\s+(\w+)\s*\(\s*val\s+rawValue:\s*String\s*\)\s*\{\s*$/); + if (!header) continue; + + const enumName = header[1]; + const constants = []; + let cursor = index + 1; + while (cursor < lines.length) { + const constant = lines[cursor].match(/^(\s*)(\w+)\("([^"]+)"\)(,|;)$/); + if (constant) { + const [, , name, rawValue] = constant; + constants.push({ name, rawValue }); + } + if (lines[cursor].trim().endsWith(';')) break; + cursor += 1; + } + + if (constants.length === 0 || cursor >= lines.length) { + throw new Error(`Kotlin Google enum ${enumName} has no terminated raw-value constants`); + } + + let whenIndex = cursor + 1; + while ( + whenIndex < lines.length && + !/\bwhen\s*\(\s*value\s*\)/.test(lines[whenIndex]) && + !/^\s*public\s+(?:enum\s+)?class\s+/.test(lines[whenIndex]) + ) { + whenIndex += 1; + } + if (whenIndex >= lines.length || !/\bwhen\s*\(\s*value\s*\)/.test(lines[whenIndex])) { + throw new Error(`Kotlin Google enum ${enumName} is missing fromJson when(value) parsing`); + } + + let elseIndex = whenIndex + 1; + while ( + elseIndex < lines.length && + !/\belse\s*->/.test(lines[elseIndex]) && + !/^\s*public\s+(?:enum\s+)?class\s+/.test(lines[elseIndex]) + ) { + elseIndex += 1; + } + if (elseIndex >= lines.length || !/\belse\s*->/.test(lines[elseIndex])) { + throw new Error(`Kotlin Google enum ${enumName} is missing its fromJson else branch`); + } + + let caseStart = whenIndex + 1; + while (caseStart < elseIndex && lines[caseStart].trim().length === 0) { + caseStart += 1; + } + if (caseStart >= elseIndex) { + throw new Error(`Kotlin Google enum ${enumName} has no fromJson cases`); + } + + const parsedCases = new Map(); + const constantNames = new Set(constants.map(({ name }) => name)); + for (const line of lines.slice(caseStart, elseIndex)) { + if (!line.trim()) continue; + const parsedCase = line.match(/^\s*"([^"]+)"\s*->\s*([A-Za-z_]\w*)\.([A-Za-z_]\w*)\s*$/); + if (!parsedCase || parsedCase[2] !== enumName) { + throw new Error(`Kotlin Google enum ${enumName} has an unsupported fromJson case: ${line.trim()}`); + } + const [, alias, , constantName] = parsedCase; + if (!constantNames.has(constantName)) { + throw new Error(`Kotlin Google enum ${enumName} maps alias "${alias}" to unknown constant ${constantName}`); + } + const prior = parsedCases.get(alias); + if (prior && prior !== constantName) { + throw new Error(`Kotlin Google enum ${enumName} maps alias "${alias}" to multiple constants`); + } + parsedCases.set(alias, constantName); + } + + for (const { name, rawValue } of constants) { + if (parsedCases.get(rawValue) !== name) { + throw new Error(`Kotlin Google enum ${enumName}.${name} raw value "${rawValue}" does not round-trip through fromJson`); + } + } + + const caseIndent = lines[caseStart].match(/^(\s*)/)?.[1] ?? ' '.repeat(12); + const rewrittenCases = []; + for (const { name, rawValue } of constants) { + const aliases = [ + rawValue, + ...[...parsedCases.entries()].filter(([, constantName]) => constantName === name).map(([alias]) => alias), + name, + ]; + if (name.endsWith('Ios')) { + aliases.push(`${name.slice(0, -3)}IOS`); + } + for (const alias of new Set(aliases)) { + rewrittenCases.push(`${caseIndent}"${alias}" -> ${enumName}.${name}`); + } + } + lines.splice(caseStart, elseIndex - caseStart, ...rewrittenCases); + } + + return lines.join('\n'); +} + +function assertPostProcessed(source, profile) { + const expectedPackage = `package ${PROFILES[profile].packageName}`; + const packageLines = source.split('\n').filter((line) => line.startsWith('package ')); + if (packageLines.length !== 1 || packageLines[0] !== expectedPackage) { + throw new Error(`Kotlin ${profile} output must contain exactly ${expectedPackage}`); + } + const lines = source.split('\n'); + const packageIndex = lines.indexOf(expectedPackage); + const lastFileAnnotation = lines.reduce((last, line, index) => (line.startsWith('@file:') ? index : last), -1); + if (lastFileAnnotation > packageIndex) { + throw new Error(`Kotlin ${profile} output places a file annotation after its package`); + } + + const missingSemicolon = source.match( + /public enum class \w+\(val rawValue: String\) \{[\s\S]*?\n\s+\w+\("[^"]*"\)\n\n\s+companion object/, + ); + if (missingSemicolon) { + throw new Error(`Kotlin ${profile} output contains an enum companion without a semicolon`); + } +} + +export function postProcessKotlinSource(source, profile) { + const options = PROFILES[profile]; + if (!options) { + throw new Error(`Unknown Kotlin platform post-process profile: ${profile}`); + } + + let result = setPackage(source, options); + result = ensureEnumCompanionSemicolons(result); + if (options.validateEnumRoundTrips) { + result = rewriteGoogleEnumAliases(result); + } + if (!result.endsWith('\n')) result += '\n'; + assertPostProcessed(result, profile); + return result; +} diff --git a/packages/gql/scripts/standalone-generated-refreshers.test.mjs b/packages/gql/scripts/standalone-generated-refreshers.test.mjs new file mode 100644 index 000000000..ee26d3561 --- /dev/null +++ b/packages/gql/scripts/standalone-generated-refreshers.test.mjs @@ -0,0 +1,316 @@ +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import { + chmodSync, + copyFileSync, + lstatSync, + mkdirSync, + mkdtempSync, + readFileSync, + readdirSync, + readlinkSync, + rmSync, + statSync, + writeFileSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import { basename, dirname, join, relative, resolve } from 'node:path'; +import { afterEach, test } from 'node:test'; +import { GENERATED_SYNC_MANIFEST } from '../generated-sync-manifest.mjs'; + +const repositoryRoot = resolve(import.meta.dirname, '../../..'); +const generatedHeaderSource = readFileSync(resolve(repositoryRoot, 'packages/gql/codegen/core/generated-header.ts'), 'utf8'); +const headerGuidance = [...generatedHeaderSource.matchAll(/`\$\{commentPrefix\} ([^`\r\n]+)`/g)] + .map((match) => match[1]) + .find((line) => line.includes('generated-types workflow')); +assert.ok(headerGuidance, 'canonical generated header guidance is missing'); +const temporaryRoots = []; + +afterEach(() => { + for (const root of temporaryRoots.splice(0)) { + rmSync(root, { recursive: true, force: true }); + } +}); + +const refreshers = [ + { + groupName: 'typescript', + targetName: 'reactNative', + scriptPath: 'libraries/react-native-iap/scripts/update-types.mjs', + runtime: 'node', + fixture: `// ============================================================================ +// AUTO-GENERATED TYPES — DO NOT EDIT DIRECTLY +// Run \`npm run generate\` after updating any *.graphql schema file. +// ============================================================================ + +export interface ProductRequest {} +`, + }, + { + groupName: 'typescript', + targetName: 'expo', + scriptPath: 'libraries/expo-iap/scripts/update-types.mjs', + runtime: 'node', + fixture: `// ============================================================================ +// AUTO-GENERATED TYPES — DO NOT EDIT DIRECTLY +// Run \`npm run generate\` after updating any *.graphql schema file. +// ============================================================================ + +export interface ProductRequest {} +`, + }, + { + groupName: 'dart', + targetName: 'flutter', + scriptPath: 'libraries/flutter_inapp_purchase/scripts/generate-type.sh', + runtime: 'shell', + fixture: `// ============================================================================ +// AUTO-GENERATED TYPES — DO NOT EDIT DIRECTLY +// Run \`bun run generate\` after updating any *.graphql schema file. +// ============================================================================ + +class ProductRequest {} +`, + }, + { + groupName: 'gdscript', + targetName: 'godot', + scriptPath: 'libraries/godot-iap/scripts/generate-types.sh', + runtime: 'shell', + fixture: `# ============================================================================ +# AUTO-GENERATED TYPES — DO NOT EDIT DIRECTLY +# Generated from OpenIAP GraphQL schema (https://openiap.dev) +# Run \`bun run generate\` to regenerate this file. +# ============================================================================ + +class ProductRequest: +\tpass +`, + }, + { + groupName: 'kotlin', + targetName: 'kmp', + scriptPath: 'libraries/kmp-iap/scripts/generate-types.sh', + runtime: 'shell', + fixture: `// ============================================================================ +// AUTO-GENERATED TYPES — DO NOT EDIT DIRECTLY +// Run \`bun run generate\` after updating any *.graphql schema file. +// ============================================================================ + +package io.github.hyochan.kmpiap.openiap + +public data class ProductRequest( + val ids: List, +) +`, + }, +]; + +const manifestTargetFor = ({ groupName, targetName }) => { + const target = GENERATED_SYNC_MANIFEST[groupName]?.targets[targetName]?.path; + assert.ok(target, `missing manifest target ${groupName}.${targetName}`); + return target; +}; + +const packageRootFor = ({ scriptPath }) => scriptPath.slice(0, scriptPath.indexOf('/scripts/')); + +const normalizeFixtureHeader = (fixture, commentPrefix) => { + const lines = fixture.split('\n'); + const separator = `${commentPrefix} ${'='.repeat(76)}`; + const closingIndex = lines.indexOf(separator, 2); + assert.ok(closingIndex > 2, 'fixture generated header is unterminated'); + const candidates = lines + .slice(2, closingIndex) + .map((line, index) => ({ index: index + 2, line })) + .filter(({ line }) => line.startsWith(`${commentPrefix} Run \``)); + assert.equal(candidates.length, 1); + lines[candidates[0].index] = `${commentPrefix} ${headerGuidance}`; + return lines.join('\n'); +}; + +const fakeCurlSource = `#!/usr/bin/env bash +set -euo pipefail +if [[ "\${FAKE_CURL_EXIT:-0}" != "0" ]]; then + exit "\${FAKE_CURL_EXIT}" +fi +output="" +url="" +while [[ "$#" -gt 0 ]]; do + case "$1" in + -o) + output="$2" + shift 2 + ;; + -fL) + shift + ;; + *) + url="$1" + shift + ;; + esac +done +printf '%s' "\${FAKE_CURL_BODY}" > "$output" +printf '%s' "$url" > "\${FAKE_CURL_LOG}" +`; + +function createIsolatedCheckout(definition, { withVersions = true } = {}) { + const root = mkdtempSync(join(tmpdir(), 'openiap-type-refresh-')); + temporaryRoots.push(root); + + const packageRootRelative = packageRootFor(definition); + const packageRoot = join(root, packageRootRelative); + const isolatedScript = join(packageRoot, 'scripts', basename(definition.scriptPath)); + const manifestTarget = manifestTargetFor(definition); + const targetRelative = relative(packageRootRelative, manifestTarget); + const isolatedTarget = join(packageRoot, targetRelative); + const binDirectory = join(root, 'bin'); + const otherCwd = join(root, 'unrelated-cwd'); + const curlLog = join(root, 'curl-url.txt'); + + mkdirSync(dirname(isolatedScript), { recursive: true }); + mkdirSync(dirname(isolatedTarget), { recursive: true }); + mkdirSync(binDirectory, { recursive: true }); + mkdirSync(otherCwd, { recursive: true }); + copyFileSync(resolve(repositoryRoot, definition.scriptPath), isolatedScript); + writeFileSync(join(binDirectory, 'curl'), fakeCurlSource); + chmodSync(join(binDirectory, 'curl'), 0o755); + if (withVersions) { + writeFileSync(join(packageRoot, 'openiap-versions.json'), `${JSON.stringify({ spec: '2.5.0' }, null, 2)}\n`); + } + + return { + curlLog, + isolatedScript, + isolatedTarget, + manifestTarget, + otherCwd, + root, + }; +} + +function runRefresher(definition, checkout, { args = [], body = definition.fixture, curlExit = '0' } = {}) { + const command = definition.runtime === 'node' ? process.execPath : 'bash'; + const commandArgs = [checkout.isolatedScript, ...args]; + return spawnSync(command, commandArgs, { + cwd: checkout.otherCwd, + encoding: 'utf8', + env: { + ...process.env, + PATH: `${join(checkout.root, 'bin')}:${process.env.PATH}`, + FAKE_CURL_BODY: body, + FAKE_CURL_EXIT: curlExit, + FAKE_CURL_LOG: checkout.curlLog, + }, + }); +} + +function assertNoRefreshTemps(checkout) { + const parentEntries = readdirSync(dirname(checkout.isolatedTarget)); + assert.equal( + parentEntries.some((entry) => entry.includes('.tmp.') || entry.startsWith('.openiap-types-')), + false, + ); +} + +test('standalone generated refreshers stay linked to manifest targets', () => { + for (const definition of refreshers) { + const source = readFileSync(resolve(repositoryRoot, definition.scriptPath), 'utf8'); + assert.match(source, /raw\.githubusercontent\.com\/hyodotdev\/openiap\//); + assert.match(source, /docs-/); + assert.ok(source.includes(manifestTargetFor(definition))); + assert.ok(source.includes(headerGuidance)); + assert.doesNotMatch(source, /github\.com\/hyodotdev\/openiap\/releases/); + assert.doesNotMatch(source, /\.zip|unzip/); + assert.doesNotMatch(source, /packages\/gql\/scripts\//); + + if (definition.runtime === 'node') { + assert.ok(source.includes('dirname(TARGET_FILE)')); + assert.ok(source.includes('renameSync(tempFile, TARGET_FILE)')); + assert.ok(source.includes('versionOverride ?? readPinnedSpecVersion()')); + assert.ok(source.includes('--tag requires a version')); + assert.ok(source.includes('Unknown argument')); + assert.doesNotMatch(source, /process\.cwd\(\)/); + } else { + assert.ok(source.includes('mktemp "${TARGET_FILE}.tmp.XXXXXX"')); + assert.match(source, /chmod 0644/); + assert.ok(source.includes('mv -f "$TEMP_FILE" "$TARGET_FILE"')); + } + } + + const exampleAddons = resolve(repositoryRoot, 'libraries/godot-iap/Example/addons'); + assert.equal(lstatSync(exampleAddons).isSymbolicLink(), true); + assert.equal(readlinkSync(exampleAddons), '../addons'); + assert.doesNotMatch( + readFileSync(resolve(repositoryRoot, 'libraries/godot-iap/scripts/generate-types.sh'), 'utf8'), + /Example\/addons|EXAMPLE_ADDON_DIR/, + ); +}); + +test('standalone refreshers validate and atomically replace in isolation', () => { + for (const definition of refreshers) { + const checkout = createIsolatedCheckout(definition); + writeFileSync(checkout.isolatedTarget, 'preserve-me\n', { mode: 0o644 }); + + const success = runRefresher(definition, checkout); + assert.equal(success.status, 0, `${definition.scriptPath}\n${success.stderr}`); + const commentPrefix = definition.groupName === 'gdscript' ? '#' : '//'; + const expected = normalizeFixtureHeader(definition.fixture, commentPrefix); + assert.equal(readFileSync(checkout.isolatedTarget, 'utf8'), expected); + assert.equal(statSync(checkout.isolatedTarget).mode & 0o777, 0o644); + assert.equal( + readFileSync(checkout.curlLog, 'utf8'), + `https://raw.githubusercontent.com/hyodotdev/openiap/docs-2.5.0/${checkout.manifestTarget}`, + ); + assertNoRefreshTemps(checkout); + + const idempotent = runRefresher(definition, checkout, { body: expected }); + assert.equal(idempotent.status, 0, `${definition.scriptPath}\n${idempotent.stderr}`); + assert.equal(readFileSync(checkout.isolatedTarget, 'utf8'), expected); + + writeFileSync(checkout.isolatedTarget, 'preserve-invalid\n', { + mode: 0o644, + }); + const invalid = runRefresher(definition, checkout, { + body: 'not generated\n', + }); + assert.notEqual(invalid.status, 0, definition.scriptPath); + assert.equal(readFileSync(checkout.isolatedTarget, 'utf8'), 'preserve-invalid\n'); + assertNoRefreshTemps(checkout); + + writeFileSync(checkout.isolatedTarget, 'preserve-download\n', { + mode: 0o644, + }); + const downloadFailure = runRefresher(definition, checkout, { + curlExit: '22', + }); + assert.notEqual(downloadFailure.status, 0, definition.scriptPath); + assert.equal(readFileSync(checkout.isolatedTarget, 'utf8'), 'preserve-download\n'); + assertNoRefreshTemps(checkout); + } +}); + +test('Node refreshers keep explicit tag overrides independent of metadata', () => { + for (const definition of refreshers.filter(({ runtime }) => runtime === 'node')) { + const checkout = createIsolatedCheckout(definition, { + withVersions: false, + }); + writeFileSync(checkout.isolatedTarget, 'preserve-me\n', { mode: 0o644 }); + + const override = runRefresher(definition, checkout, { + args: ['--tag', 'gql-v2.5.0'], + }); + assert.equal(override.status, 0, `${definition.scriptPath}\n${override.stderr}`); + assert.equal( + readFileSync(checkout.curlLog, 'utf8'), + `https://raw.githubusercontent.com/hyodotdev/openiap/docs-2.5.0/${checkout.manifestTarget}`, + ); + + const expected = readFileSync(checkout.isolatedTarget, 'utf8'); + for (const args of [[], ['--tag'], ['--unknown']]) { + const failure = runRefresher(definition, checkout, { args }); + assert.notEqual(failure.status, 0, `${definition.scriptPath} ${args}`); + assert.equal(readFileSync(checkout.isolatedTarget, 'utf8'), expected); + } + } +}); diff --git a/packages/gql/scripts/sync-to-platforms.mjs b/packages/gql/scripts/sync-to-platforms.mjs index b2b454cca..47f9f3896 100755 --- a/packages/gql/scripts/sync-to-platforms.mjs +++ b/packages/gql/scripts/sync-to-platforms.mjs @@ -1,228 +1,30 @@ -#!/usr/bin/env bun -import { - copyFileSync, - existsSync, - mkdirSync, - readFileSync, - writeFileSync, -} from 'node:fs'; +#!/usr/bin/env node +import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; import { dirname, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; -import { execSync } from 'node:child_process'; +import { GENERATED_SYNC_EDGES } from '../generated-sync-manifest.mjs'; +import { materializeGeneratedSyncEdge } from './generated-sync-materializer.mjs'; -const __filename = fileURLToPath(import.meta.url); -const __dirname = dirname(__filename); -const gqlRoot = resolve(__dirname, '..'); -const monorepoRoot = resolve(gqlRoot, '../..'); +const scriptDirectory = dirname(fileURLToPath(import.meta.url)); +const monorepoRoot = resolve(scriptDirectory, '../../..'); +const fromRoot = (path) => resolve(monorepoRoot, path); -// Kotlin → Google (Android) -const kotlinSource = resolve(gqlRoot, 'src/generated/Types.kt'); -const kotlinTarget = resolve(monorepoRoot, 'packages/google/openiap/src/main/java/dev/hyo/openiap/Types.kt'); - -// Swift → Apple (iOS) -const swiftSource = resolve(gqlRoot, 'src/generated/Types.swift'); -const swiftTarget = resolve(monorepoRoot, 'packages/apple/Sources/Models/Types.swift'); - -// Library targets — generated types are copied in with per-library -// transformations (package names, file extensions, etc.) so that -// `libraries/*/` stay in lockstep with the gql schema instead of needing -// hand edits after every regeneration. -const dartSource = resolve(gqlRoot, 'src/generated/types.dart'); -const dartTarget = resolve( - monorepoRoot, - 'libraries/flutter_inapp_purchase/lib/types.dart', -); - -const gdSource = resolve(gqlRoot, 'src/generated/types.gd'); -const gdTarget = resolve( - monorepoRoot, - 'libraries/godot-iap/addons/godot-iap/types.gd', -); - -const tsSource = resolve(gqlRoot, 'src/generated/types.ts'); -const rnTsTarget = resolve(monorepoRoot, 'libraries/react-native-iap/src/types.ts'); -const expoTsTarget = resolve(monorepoRoot, 'libraries/expo-iap/src/types.ts'); - -// `webhook-client.ts` is a hand-maintained runtime helper rather than -// generated output, but it lives in `packages/gql` so RN and Expo can -// share a single canonical implementation. Sync alongside the types so -// the two never drift. -const webhookClientSource = resolve(gqlRoot, 'src/webhook-client.ts'); -const rnWebhookClientTarget = resolve( - monorepoRoot, - 'libraries/react-native-iap/src/webhook-client.ts', -); -const expoWebhookClientTarget = resolve( - monorepoRoot, - 'libraries/expo-iap/src/webhook-client.ts', -); - -const kitApiSource = resolve(gqlRoot, 'src/kit-api.ts'); -const rnKitApiTarget = resolve( - monorepoRoot, - 'libraries/react-native-iap/src/kit-api.ts', -); -const expoKitApiTarget = resolve( - monorepoRoot, - 'libraries/expo-iap/src/kit-api.ts', -); - -const kmpSource = resolve(gqlRoot, 'src/generated/Types.kt'); -const kmpTarget = resolve( - monorepoRoot, - 'libraries/kmp-iap/library/src/commonMain/kotlin/io/github/hyochan/kmpiap/openiap/Types.kt', -); - -const csharpSource = resolve(gqlRoot, 'src/generated/Types.cs'); -const mauiTarget = resolve( - monorepoRoot, - 'libraries/maui-iap/src/OpenIap.Maui/Types.cs', -); - -console.log('📦 Syncing generated types to platforms...\n'); - -// Sync Kotlin to Google (Android) -if (existsSync(kotlinSource)) { - mkdirSync(dirname(kotlinTarget), { recursive: true }); - copyFileSync(kotlinSource, kotlinTarget); - console.log('✅ Kotlin → Google (Android)'); - console.log(` ${kotlinTarget}\n`); - - // Run Google post-processing - try { - const googleRoot = resolve(monorepoRoot, 'packages/google'); - const postProcessScript = resolve(googleRoot, 'scripts/post-process-types.sh'); - - if (existsSync(postProcessScript)) { - execSync(`bash "${postProcessScript}"`, { cwd: googleRoot, stdio: 'inherit' }); - } - } catch (error) { - console.warn('⚠️ Google post-processing failed (optional)'); +for (const source of new Set(GENERATED_SYNC_EDGES.map((edge) => edge.source))) { + if (!existsSync(fromRoot(source))) { + throw new Error(`Canonical sync source not found: ${fromRoot(source)}`); } -} else { - console.warn('⚠️ Kotlin types not found, skipping Google sync'); } -// Sync Swift to Apple (iOS) -if (existsSync(swiftSource)) { - mkdirSync(dirname(swiftTarget), { recursive: true }); - copyFileSync(swiftSource, swiftTarget); - console.log('✅ Swift → Apple (iOS)'); - console.log(` ${swiftTarget}\n`); -} else { - console.warn('⚠️ Swift types not found, skipping Apple sync'); -} - -// Sync Dart to flutter_inapp_purchase -// Note: the flutter_inapp_purchase CLAUDE.md explicitly excludes -// `lib/types.dart` from the Dart format check, so we intentionally copy -// the raw generator output verbatim. `bun run generate` is reproducible -// because no formatter mutates the file afterwards. -if (existsSync(dartSource)) { - mkdirSync(dirname(dartTarget), { recursive: true }); - copyFileSync(dartSource, dartTarget); - console.log('✅ Dart → flutter_inapp_purchase'); - console.log(` ${dartTarget}\n`); -} +console.log('📦 Syncing generated sources to platforms...\n'); -// Sync GDScript to godot-iap -if (existsSync(gdSource)) { - mkdirSync(dirname(gdTarget), { recursive: true }); - copyFileSync(gdSource, gdTarget); - console.log('✅ GDScript → godot-iap'); - console.log(` ${gdTarget}\n`); -} - -// Sync TypeScript to react-native-iap + expo-iap -if (existsSync(tsSource)) { - for (const target of [rnTsTarget, expoTsTarget]) { - mkdirSync(dirname(target), { recursive: true }); - copyFileSync(tsSource, target); - } - console.log('✅ TypeScript → react-native-iap + expo-iap'); - console.log(` ${rnTsTarget}`); - console.log(` ${expoTsTarget}\n`); -} - -// Sync the webhook client to react-native-iap + expo-iap. Doing this -// during type-sync means the per-library copies can never silently -// drift from the canonical implementation in `packages/gql`. -if (existsSync(webhookClientSource)) { - for (const target of [rnWebhookClientTarget, expoWebhookClientTarget]) { - mkdirSync(dirname(target), { recursive: true }); - copyFileSync(webhookClientSource, target); - } - console.log('✅ webhook-client → react-native-iap + expo-iap'); - console.log(` ${rnWebhookClientTarget}`); - console.log(` ${expoWebhookClientTarget}\n`); -} - -if (existsSync(kitApiSource)) { - for (const target of [rnKitApiTarget, expoKitApiTarget]) { - mkdirSync(dirname(target), { recursive: true }); - copyFileSync(kitApiSource, target); - } - console.log('✅ kit-api → react-native-iap + expo-iap'); - console.log(` ${rnKitApiTarget}`); - console.log(` ${expoKitApiTarget}\n`); -} - -// Sync Kotlin to kmp-iap with the library-specific package declaration and -// the enum-companion semicolon that Kotlin requires. This mirrors the -// post-process that packages/google runs; without it the KMP module would -// not compile against the upstream gql types. -if (existsSync(kmpSource)) { - mkdirSync(dirname(kmpTarget), { recursive: true }); - let text = readFileSync(kmpSource, 'utf8'); - - // Insert the package declaration AFTER every leading `@file:` - // annotation. Kotlin requires file annotations to precede the package - // directive, so we walk the file line-by-line, find the last `@file:` - // (the generator can legitimately emit comments/blank lines between - // annotations), and splice the package declaration immediately after - // it. Falling back to a plain prepend is wrong because it would place - // the package before any subsequent `@file:` lines. - if (!/\bpackage io\.github\.hyochan\.kmpiap\.openiap\b/.test(text)) { - const pkg = 'package io.github.hyochan.kmpiap.openiap'; - const lines = text.split('\n'); - let lastFileAnnotation = -1; - for (let i = 0; i < lines.length; i++) { - if (lines[i].startsWith('@file:')) { - lastFileAnnotation = i; - } - } - if (lastFileAnnotation >= 0) { - lines.splice(lastFileAnnotation + 1, 0, '', pkg); - } else { - lines.unshift(pkg, ''); - } - text = lines.join('\n'); - } - - // Kotlin enums that declare a companion object require a trailing - // semicolon after the last enum entry. Match the same pattern used by - // packages/google/scripts/post-process-types.sh so the files stay in - // lockstep. - text = text.replace( - /(\n\s*\w+\([^)]*\))\n\n(\s+companion object)/g, - '$1;\n\n$2', - ); - - writeFileSync(kmpTarget, text); - console.log('✅ Kotlin → kmp-iap (with package + enum-semicolon post-process)'); - console.log(` ${kmpTarget}\n`); -} +for (const edge of GENERATED_SYNC_EDGES) { + const source = fromRoot(edge.source); + const destination = fromRoot(edge.path); + mkdirSync(dirname(destination), { recursive: true }); + writeFileSync(destination, materializeGeneratedSyncEdge(edge, readFileSync(source, 'utf8'))); -// Sync C# to maui-iap. The generator already emits the -// `OpenIap` namespace declaration that the MAUI library imports via -// `using OpenIap;`, so the file is copied verbatim — no per-library -// post-processing is needed (unlike the kmp-iap Kotlin path, which has to -// inject a different package declaration). -if (existsSync(csharpSource)) { - mkdirSync(dirname(mauiTarget), { recursive: true }); - copyFileSync(csharpSource, mauiTarget); - console.log('✅ C# → maui-iap'); - console.log(` ${mauiTarget}\n`); + console.log(`✅ ${edge.label}`); + console.log(` ${destination}\n`); } console.log('🎉 Platform sync complete!\n'); diff --git a/packages/gql/scripts/verify-generated-sync.mjs b/packages/gql/scripts/verify-generated-sync.mjs new file mode 100644 index 000000000..4f07f650c --- /dev/null +++ b/packages/gql/scripts/verify-generated-sync.mjs @@ -0,0 +1,38 @@ +import { existsSync, readFileSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { GENERATED_SYNC_EDGES } from '../generated-sync-manifest.mjs'; +import { materializeGeneratedSyncEdge } from './generated-sync-materializer.mjs'; + +const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../../..'); + +export function collectGeneratedSyncDrift(root = repositoryRoot) { + const sourceCache = new Map(); + const drift = []; + + for (const edge of GENERATED_SYNC_EDGES) { + const sourcePath = resolve(root, edge.source); + const targetPath = resolve(root, edge.path); + if (!existsSync(sourcePath)) { + drift.push(`${edge.source} is missing`); + continue; + } + if (!existsSync(targetPath)) { + drift.push(`${edge.path} is missing`); + continue; + } + + let source = sourceCache.get(edge.source); + if (source === undefined) { + source = readFileSync(sourcePath, 'utf8'); + sourceCache.set(edge.source, source); + } + const expected = materializeGeneratedSyncEdge(edge, source); + const actual = readFileSync(targetPath, 'utf8'); + if (actual !== expected) { + drift.push(`${edge.path} is not the ${edge.mode} materialization of ${edge.source}`); + } + } + + return drift; +} diff --git a/packages/gql/src/api-ios.graphql b/packages/gql/src/api-ios.graphql index cdc7be72b..c0ec16ddf 100644 --- a/packages/gql/src/api-ios.graphql +++ b/packages/gql/src/api-ios.graphql @@ -123,13 +123,13 @@ extend type Mutation { """ Buy the currently promoted product. - @deprecated Use promotedProductListenerIOS to receive the productId, - then call requestPurchase with that SKU instead. In StoreKit 2, - promoted products can be purchased directly via the standard purchase flow. See: https://openiap.dev/docs/apis/ios/request-purchase-on-promoted-product-ios """ # Future - requestPurchaseOnPromotedProductIOS: Boolean! @deprecated(reason: "Use promotedProductListenerIOS + requestPurchase instead") + requestPurchaseOnPromotedProductIOS: Boolean! + @deprecated( + reason: "Use promotedProductListenerIOS to receive the productId, then call requestPurchase with that SKU instead. In StoreKit 2, promoted products can be purchased directly via the standard purchase flow." + ) """ Present the manage-subscriptions sheet and return changed purchases (iOS 15+). See: https://openiap.dev/docs/apis/ios/show-manage-subscriptions-ios diff --git a/packages/gql/src/codegen-defaults.test.ts b/packages/gql/src/codegen-defaults.test.ts index fac2bbb8a..b87b83fce 100644 --- a/packages/gql/src/codegen-defaults.test.ts +++ b/packages/gql/src/codegen-defaults.test.ts @@ -1,11 +1,13 @@ -import { describe, expect, it } from "vitest"; -import { CSharpPlugin } from "../codegen/plugins/csharp"; -import { GDScriptPlugin } from "../codegen/plugins/gdscript"; -import { KotlinPlugin } from "../codegen/plugins/kotlin"; -import type { IREnum, IRField, IRSchema, IRType } from "../codegen/core/types"; +import { describe, expect, it } from 'vitest'; +import { CSharpPlugin } from '../codegen/plugins/csharp'; +import { DartPlugin } from '../codegen/plugins/dart'; +import { GDScriptPlugin } from '../codegen/plugins/gdscript'; +import { KotlinPlugin } from '../codegen/plugins/kotlin'; +import { SwiftPlugin } from '../codegen/plugins/swift'; +import type { IREnum, IRField, IRSchema, IRType } from '../codegen/core/types'; -const stringType: IRType = { kind: "scalar", name: "String", nullable: false }; -const floatType: IRType = { kind: "scalar", name: "Float", nullable: false }; +const stringType: IRType = { kind: 'scalar', name: 'String', nullable: false }; +const floatType: IRType = { kind: 'scalar', name: 'Float', nullable: false }; function field(name: string, type: IRType, defaultValue?: unknown): IRField { return { @@ -23,7 +25,7 @@ function schema(fields: IRField[], enums: IREnum[] = []): IRSchema { objects: [], inputs: [ { - name: "DefaultInput", + name: 'DefaultInput', fields, hasRequiredFields: true, isCustomType: false, @@ -31,111 +33,97 @@ function schema(fields: IRField[], enums: IREnum[] = []): IRSchema { ], unions: [], operations: [], - metadata: { - unionWrapperNames: new Set(), - futureFieldNames: new Set(), - platformDefaults: new Map(), - singleFieldObjects: new Map(), - unionMembership: new Map(), - inputsWithRequiredFields: new Set(), - }, }; } -describe("codegen defaults", () => { - it("keeps unsupported non-null C# defaults required and escapes string literals", () => { - const output = new CSharpPlugin({ outputPath: "Types.cs" }).generate( - schema([ - field("unsupportedDefault", stringType, { raw: "unsupported" }), - field("escapedString", stringType, 'quote " and slash \\'), - ]), - ); +function objectSchema(fields: IRField[], enums: IREnum[]): IRSchema { + return { + ...schema([], enums), + inputs: [], + objects: [ + { + name: 'MappedProduct', + fields, + interfaces: [], + unions: [], + isResultUnion: false, + }, + ], + }; +} - expect(output).toContain( - "public required string UnsupportedDefault { get; init; }", - ); - expect(output).toContain( - 'public string EscapedString { get; init; } = "quote \\" and slash \\\\";', +describe('codegen defaults', () => { + it('keeps unsupported non-null C# defaults required and escapes string literals', () => { + const output = new CSharpPlugin({ outputPath: 'Types.cs' }).generate( + schema([field('unsupportedDefault', stringType, { raw: 'unsupported' }), field('escapedString', stringType, 'quote " and slash \\')]), ); + + expect(output).toContain('public required string UnsupportedDefault { get; init; }'); + expect(output).toContain('public string EscapedString { get; init; } = "quote \\" and slash \\\\";'); }); - it("emits whole-number GraphQL Float defaults as Kotlin Double literals", () => { + it('emits whole-number GraphQL Float defaults as Kotlin Double literals', () => { const output = new KotlinPlugin({ - outputPath: "Types.kt", - packageName: "dev.hyo.openiap", - }).generate( - schema([ - field("wholeWeight", floatType, 0), - field("fractionalWeight", floatType, 1.5), - ]), - ); + outputPath: 'Types.kt', + packageName: 'dev.hyo.openiap', + }).generate(schema([field('wholeWeight', floatType, 0), field('fractionalWeight', floatType, 1.5)])); - expect(output).toContain("val wholeWeight: Double = 0.0"); - expect(output).toContain("val fractionalWeight: Double = 1.5,"); + expect(output).toContain('val wholeWeight: Double = 0.0'); + expect(output).toContain('val fractionalWeight: Double = 1.5,'); }); - it("emits GraphQL enum defaults as GDScript field initializers", () => { + it('emits GraphQL enum defaults as GDScript field initializers', () => { const rendererEnum: IREnum = { - name: "Renderer", + name: 'Renderer', isErrorCode: false, values: [ { - name: "UNSPECIFIED", - rawValue: "unspecified", + name: 'UNSPECIFIED', + rawValue: 'unspecified', legacyAliases: [], }, { - name: "GOOGLE_RENDERED", - rawValue: "google-rendered", + name: 'GOOGLE_RENDERED', + rawValue: 'google-rendered', legacyAliases: [], }, ], }; const rendererType: IRType = { - kind: "enum", - name: "Renderer", + kind: 'enum', + name: 'Renderer', nullable: true, }; - const output = new GDScriptPlugin({ outputPath: "types.gd" }).generate( - schema( - [field("renderer", rendererType, "GOOGLE_RENDERED")], - [rendererEnum], - ), + const output = new GDScriptPlugin({ outputPath: 'types.gd' }).generate( + schema([field('renderer', rendererType, 'GOOGLE_RENDERED')], [rendererEnum]), ); - expect(output).toContain( - "var renderer: Renderer = Renderer.GOOGLE_RENDERED", - ); + expect(output).toContain('var renderer: Renderer = Renderer.GOOGLE_RENDERED'); - const csharpOutput = new CSharpPlugin({ outputPath: "Types.cs" }).generate( - schema( - [field("renderer", rendererType, "GOOGLE_RENDERED")], - [rendererEnum], - ), - ); - expect(csharpOutput).toContain( - "public Renderer? Renderer { get; init; } = global::OpenIap.Renderer.GoogleRendered;", + const csharpOutput = new CSharpPlugin({ outputPath: 'Types.cs' }).generate( + schema([field('renderer', rendererType, 'GOOGLE_RENDERED')], [rendererEnum]), ); + expect(csharpOutput).toContain('public Renderer? Renderer { get; init; } = global::OpenIap.Renderer.GoogleRendered;'); }); - it("preserves null for GDScript enum inputs without defaults", () => { + it('preserves null for GDScript enum inputs without defaults', () => { const rendererEnum: IREnum = { - name: "Renderer", + name: 'Renderer', isErrorCode: false, values: [ { - name: "UNSPECIFIED", - rawValue: "unspecified", + name: 'UNSPECIFIED', + rawValue: 'unspecified', legacyAliases: [], }, ], }; - const output = new GDScriptPlugin({ outputPath: "types.gd" }).generate( + const output = new GDScriptPlugin({ outputPath: 'types.gd' }).generate( schema( [ - field("renderer", { - kind: "enum", - name: "Renderer", + field('renderer', { + kind: 'enum', + name: 'Renderer', nullable: true, }), ], @@ -143,41 +131,97 @@ describe("codegen defaults", () => { ), ); - expect(output).toContain("var renderer: Variant = null"); - expect(output).toContain("if renderer != null:"); + expect(output).toContain('var renderer: Variant = null'); + expect(output).toContain('if renderer != null:'); }); - it("emits GraphQL enum-list defaults as GDScript array initializers", () => { + it('emits GraphQL enum-list defaults as GDScript array initializers', () => { // Regression: list defaults used to fall through to `[]`, silently // dropping schema defaults such as `categories: [InAppMessageCategoryAndroid!] // = [TRANSACTIONAL]` while every other language plugin kept them. const categoryEnum: IREnum = { - name: "Category", + name: 'Category', isErrorCode: false, values: [ { - name: "TRANSACTIONAL", - rawValue: "transactional", + name: 'TRANSACTIONAL', + rawValue: 'transactional', legacyAliases: [], }, { - name: "PROMOTIONAL", - rawValue: "promotional", + name: 'PROMOTIONAL', + rawValue: 'promotional', legacyAliases: [], }, ], }; const listType: IRType = { - kind: "list", + kind: 'list', nullable: false, - elementType: { kind: "enum", name: "Category", nullable: false }, + elementType: { kind: 'enum', name: 'Category', nullable: false }, + }; + const output = new GDScriptPlugin({ outputPath: 'types.gd' }).generate( + schema([field('categories', listType, ['TRANSACTIONAL'])], [categoryEnum]), + ); + + expect(output).toContain('var categories: Array[Category] = [Category.TRANSACTIONAL]'); + }); + + it('renders object defaults from IR without re-reading product policy', () => { + const platformEnum: IREnum = { + name: 'IapPlatform', + isErrorCode: false, + values: [ + { name: 'IOS', rawValue: 'ios', legacyAliases: [] }, + { name: 'Android', rawValue: 'android', legacyAliases: [] }, + ], }; - const output = new GDScriptPlugin({ outputPath: "types.gd" }).generate( - schema([field("categories", listType, ["TRANSACTIONAL"])], [categoryEnum]), + const productTypeEnum: IREnum = { + name: 'ProductType', + isErrorCode: false, + values: [ + { name: 'InApp', rawValue: 'in-app', legacyAliases: [] }, + { name: 'Subs', rawValue: 'subs', legacyAliases: [] }, + ], + }; + const product = objectSchema( + [ + field('platform', { kind: 'enum', name: 'IapPlatform', nullable: false }, 'ios'), + field('type', { kind: 'enum', name: 'ProductType', nullable: false }, 'in-app'), + ], + [platformEnum, productTypeEnum], ); - expect(output).toContain( - "var categories: Array[Category] = [Category.TRANSACTIONAL]", + expect(new SwiftPlugin({ outputPath: 'Types.swift' }).generate(product)).toContain('public var platform: IapPlatform = .ios'); + expect(new KotlinPlugin({ outputPath: 'Types.kt' }).generate(product)).toContain('val platform: IapPlatform = IapPlatform.Ios'); + expect(new DartPlugin({ outputPath: 'types.dart' }).generate(product)).toContain('this.platform = IapPlatform.IOS'); + expect(new CSharpPlugin({ outputPath: 'Types.cs' }).generate(product)).toContain( + 'public IapPlatform Platform { get; init; } = global::OpenIap.IapPlatform.IOS;', ); + expect(new GDScriptPlugin({ outputPath: 'types.gd' }).generate(product)).toContain('var platform: IapPlatform = IapPlatform.IOS'); + }); + + it('derives Swift ErrorCode compatibility cases from IR aliases', () => { + const errorCode: IREnum = { + name: 'ErrorCode', + isErrorCode: true, + values: [ + { + name: 'LegacyFailure', + rawValue: 'legacy-failure', + legacyAliases: [], + }, + { + name: 'CanonicalFailure', + rawValue: 'canonical-failure', + legacyAliases: ['legacy-failure', 'LegacyFailure'], + }, + ], + }; + + const output = new SwiftPlugin({ outputPath: 'Types.swift' }).generate(schema([], [errorCode])); + + expect(output).toContain('case "legacy-failure", "LegacyFailure":\n self = .canonicalFailure // Legacy alias'); + expect(output).toContain('case "canonical-failure", "CanonicalFailure":\n self = .canonicalFailure'); }); }); diff --git a/packages/gql/src/codegen-entrypoint.test.ts b/packages/gql/src/codegen-entrypoint.test.ts new file mode 100644 index 000000000..6f9d68aec --- /dev/null +++ b/packages/gql/src/codegen-entrypoint.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from 'vitest'; +import codegenConfig from '../codegen.js'; +import { CodeGenerator, LANGUAGE_OUTPUT_PATHS, SUPPORTED_LANGUAGES, normalizeLanguages } from '../codegen/index.js'; +import { GENERATED_SYNC_MANIFEST, generatedSourceFileName, gqlPackageRelativePath } from '../generated-sync-manifest.mjs'; + +describe('code generator entrypoint', () => { + it('defaults to every implemented native/framework language', () => { + expect(normalizeLanguages()).toEqual(SUPPORTED_LANGUAGES); + expect(() => new CodeGenerator()).not.toThrow(); + }); + + it('rejects unknown and empty language requests', () => { + expect(() => normalizeLanguages(['kotln'])).toThrow('Unsupported codegen language: kotln'); + expect(() => normalizeLanguages([])).toThrow('At least one codegen language is required'); + expect( + () => + new CodeGenerator({ + languages: ['kotln' as never], + }), + ).toThrow('Unsupported codegen language: kotln'); + }); + + it('deduplicates validated language requests without changing order', () => { + expect(normalizeLanguages(['kotlin', 'swift', 'kotlin'])).toEqual(['kotlin', 'swift']); + }); + + it('derives every producer output filename from the sync manifest', () => { + const nativeGeneratedGroups = Object.entries(GENERATED_SYNC_MANIFEST) + .filter(([groupName, definition]) => definition.generated && groupName !== 'typescript') + .map(([groupName]) => groupName) + .sort(); + + expect([...SUPPORTED_LANGUAGES].sort()).toEqual(nativeGeneratedGroups); + expect(LANGUAGE_OUTPUT_PATHS).toEqual( + Object.fromEntries(SUPPORTED_LANGUAGES.map((language) => [language, generatedSourceFileName(language)])), + ); + expect(Object.keys(codegenConfig.generates ?? {})).toEqual([gqlPackageRelativePath(GENERATED_SYNC_MANIFEST.typescript.source)]); + }); +}); diff --git a/packages/gql/src/custom-generated-guards.test.mjs b/packages/gql/src/custom-generated-guards.test.mjs new file mode 100644 index 000000000..006494ae7 --- /dev/null +++ b/packages/gql/src/custom-generated-guards.test.mjs @@ -0,0 +1,434 @@ +import ts from 'typescript'; +import { describe, expect, it } from 'vitest'; +import { + GRAPHQL_CODEGEN_SCAFFOLDING, + deriveMarkedUnionAlias, + operationFieldNames, + renderDocumentedTypeAlias, + requireExactInterfaceProperties, + requireExactTypeAlias, + requireGeneratedEnumContracts, + requireGeneratedMarkerEffects, + requireNoGraphqlCodegenScaffolding, + requireProductDiscriminantContracts, + requireTypeScriptInputContract, + resolveOperationArgsOwner, + rewriteRequestPurchaseTypeAliases, +} from '../scripts/custom-generated-guards.mjs'; + +describe('custom generated TypeScript guards', () => { + it('fails closed when graphql-codegen scaffolding survives post-processing', () => { + expect(() => requireNoGraphqlCodegenScaffolding('export interface Product { id: string; }')).not.toThrow(); + for (const token of GRAPHQL_CODEGEN_SCAFFOLDING) { + expect(() => requireNoGraphqlCodegenScaffolding(`before ${token} after`), token).toThrow( + 'still contains graphql-codegen scaffolding', + ); + } + }); + + it('reads only top-level interface properties through comment and type decoys', () => { + const declaration = requireExactInterfaceProperties( + `export interface MutationRequestPurchaseArgs { + /** A comment containing fake?: string and a closing brace }. */ + params: { + nested: string; + }; +} + +export interface Unrelated { + extra: string; +} +`, + 'MutationRequestPurchaseArgs', + ['params'], + ); + + expect(declaration.source).toContain('nested: string'); + expect(declaration.source).not.toContain('Unrelated'); + expect(declaration.propertyJSDoc('params')).toBe('/** A comment containing fake?: string and a closing brace }. */'); + }); + + it('supports quoted static properties and rejects extra fields', () => { + expect(() => + requireExactInterfaceProperties( + `export interface RequestPurchaseProps { + requestPurchase?: string; + requestSubscription?: string; + type?: string; + "useAlternativeBilling"?: boolean; + futureField?: string; +}`, + 'RequestPurchaseProps', + ['requestPurchase', 'requestSubscription', 'type', 'useAlternativeBilling'], + ), + ).toThrow('found requestPurchase, requestSubscription, type, useAlternativeBilling, futureField'); + + expect(() => + requireExactInterfaceProperties( + `export interface DuplicateProperty { + first: string; + first: string; +}`, + 'DuplicateProperty', + ['first', 'second'], + ), + ).toThrow('expected first, second, found first, first'); + }); + + it('fails closed for duplicate, missing, dynamic, and non-property declarations', () => { + expect(() => + requireExactInterfaceProperties( + 'export interface Duplicate { value: string }\nexport interface Duplicate { value: string }', + 'Duplicate', + ['value'], + ), + ).toThrow('must appear exactly once; found 2'); + + expect(() => requireExactInterfaceProperties('export interface Present { value: string }', 'Missing', ['value'])).toThrow( + 'must appear exactly once; found 0', + ); + + expect(() => requireExactInterfaceProperties('export interface Dynamic { [key: string]: string }', 'Dynamic', ['key'])).toThrow( + 'only supports property signatures', + ); + + expect(() => requireExactInterfaceProperties('export interface MethodOwner { run(): void }', 'MethodOwner', ['run'])).toThrow( + 'only supports property signatures', + ); + }); + + it('fails closed when a rewritten property loses or duplicates direct JSDoc', () => { + const missingDoc = requireExactInterfaceProperties('export interface MissingDoc { value: string }', 'MissingDoc', ['value']); + expect(() => missingDoc.propertyJSDoc('value')).toThrow('must retain exactly one direct generated JSDoc block; found 0'); + expect(missingDoc.propertyJSDoc('value', false)).toBeNull(); + + const duplicateDoc = requireExactInterfaceProperties( + `export interface DuplicateDoc { + /** First. */ + /** Second. */ + value: string +}`, + 'DuplicateDoc', + ['value'], + ); + expect(() => duplicateDoc.propertyJSDoc('value')).toThrow('must retain exactly one direct generated JSDoc block; found 2'); + }); + + it('preserves operation argument guidance through the purchase union rewrite', () => { + const result = rewriteRequestPurchaseTypeAliases(`export interface RequestPurchaseProps { + /** Per-platform purchase request props */ + requestPurchase?: (RequestPurchasePropsByPlatforms | null); + /** Per-platform subscription request props */ + requestSubscription?: (RequestSubscriptionPropsByPlatforms | null); + /** Explicit purchase type hint */ + type?: (ProductQueryType | null); + /** Alternative billing flag */ + useAlternativeBilling?: (boolean | null); +} + +export interface MutationRequestPurchaseArgs { + /** + * Purchase request wrapper. + * @deprecated Use the replacement argument instead. + */ + params: RequestPurchaseProps; +} + +/** Unrelated generated type. */ +export interface Unrelated { + value: string; +} +`); + + expect(result).toContain( + '/**\n * Purchase request wrapper.\n * @deprecated Use the replacement argument instead.\n */\nexport type MutationRequestPurchaseArgs = RequestPurchaseProps;', + ); + expect(result).not.toContain('export interface MutationRequestPurchaseArgs'); + expect(result).not.toContain('export interface RequestPurchaseProps'); + expect(result).toContain( + 'export type MutationRequestPurchaseArgs = RequestPurchaseProps;\n\n/** Unrelated generated type. */\nexport interface Unrelated', + ); + + const sourceFile = ts.createSourceFile('generated-types.ts', result, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS); + const alias = sourceFile.statements.find( + (statement) => ts.isTypeAliasDeclaration(statement) && statement.name.text === 'MutationRequestPurchaseArgs', + ); + expect(ts.getJSDocTags(alias).map((tag) => [tag.tagName.text, tag.comment])).toContainEqual([ + 'deprecated', + 'Use the replacement argument instead.', + ]); + }); + + it('fails closed when a purchase rewrite input type or optionality drifts', () => { + const valid = `export interface RequestPurchaseProps { + /** Purchase. */ + requestPurchase?: (RequestPurchasePropsByPlatforms | null); + /** Subscription. */ + requestSubscription?: (RequestSubscriptionPropsByPlatforms | null); + /** Type. */ + type?: (ProductQueryType | null); + /** Alternative billing. */ + useAlternativeBilling?: (boolean | null); +} + +export interface MutationRequestPurchaseArgs { + params: RequestPurchaseProps; +} +`; + + expect(() => + rewriteRequestPurchaseTypeAliases( + valid.replace('requestPurchase?: (RequestPurchasePropsByPlatforms | null)', 'requestPurchase?: number'), + ), + ).toThrow('RequestPurchaseProps.requestPurchase generated contract drifted'); + expect(() => + rewriteRequestPurchaseTypeAliases( + valid.replace( + 'requestSubscription?: (RequestSubscriptionPropsByPlatforms | null)', + 'requestSubscription: (RequestSubscriptionPropsByPlatforms | null)', + ), + ), + ).toThrow('RequestPurchaseProps.requestSubscription generated contract drifted'); + expect(() => rewriteRequestPurchaseTypeAliases(valid.replace('params: RequestPurchaseProps', 'params?: RequestPurchaseProps'))).toThrow( + 'MutationRequestPurchaseArgs.params generated contract drifted', + ); + }); + + it('enforces the PurchaseInput alias source contract before rewriting', () => { + const valid = `export interface PurchaseInput { + id: string; + productId: string; + ids?: (string[] | null); + transactionDate: number; + purchaseToken?: (string | null); + store?: (IapStore | null); + platform?: (IapPlatform | null); + quantity: number; + purchaseState: PurchaseState; + isAutoRenewing: boolean; +}`; + + expect(() => requireTypeScriptInputContract(valid, 'PurchaseInput')).not.toThrow(); + expect(() => + requireTypeScriptInputContract(valid.replace('transactionDate: number', 'transactionDate?: number'), 'PurchaseInput'), + ).toThrow('PurchaseInput.transactionDate generated contract drifted'); + }); + + it('fails closed when product discriminants drift', () => { + const valid = `export interface ProductCommon { + platform: 'android' | 'ios'; + type: 'in-app' | 'subs'; +} +export interface ProductAndroid { + platform: 'android'; + type: 'in-app'; +} +export interface ProductIOS { + platform: 'ios'; + type: 'in-app'; +} +export interface ProductSubscriptionAndroid { + platform: 'android'; + type: 'subs'; +} +export interface ProductSubscriptionIOS { + platform: 'ios'; + type: 'subs'; +}`; + + expect(() => requireProductDiscriminantContracts(valid)).not.toThrow(); + expect(() => requireProductDiscriminantContracts(valid.replace("platform: 'android';", 'platform: IapPlatform;'))).toThrow( + 'ProductAndroid.platform discriminant drifted', + ); + }); + + it('fails closed when enum conversion leaves the wrong declaration kind', () => { + const valid = `export enum ErrorCode { Unknown = 'unknown' } +export type IapStore = 'apple' | 'google';`; + + const contracts = new Map([ + ['ErrorCode', ['unknown']], + ['IapStore', ['apple', 'google']], + ]); + + expect(() => requireGeneratedEnumContracts(valid, contracts)).not.toThrow(); + expect(() => + requireGeneratedEnumContracts( + `export enum ErrorCode { Unknown = "unknown" } +export type IapStore = "apple" | 'google';`, + contracts, + ), + ).not.toThrow(); + expect(() => + requireGeneratedEnumContracts( + valid.replace("export type IapStore = 'apple' | 'google';", "export declare enum IapStore { Apple = 'apple' }"), + contracts, + ), + ).toThrow('IapStore enum contract drifted'); + expect(() => requireGeneratedEnumContracts(valid.replace(" | 'google'", ''), contracts)).toThrow('IapStore enum values drifted'); + }); + + it('fails closed when VoidResult stops being the canonical void alias', () => { + expect(() => requireExactTypeAlias('export type VoidResult = void;', 'VoidResult', 'void')).not.toThrow(); + expect(() => + requireExactTypeAlias( + `export interface VoidResult { + success: + boolean; +}`, + 'VoidResult', + 'void', + ), + ).toThrow('must produce exactly one type alias and no interface'); + }); + + it('does not collapse argument-bearing operations to no-argument helpers', () => { + const source = 'export type QueryFetchProductsArgs = string;'; + const options = { + rootName: 'Query', + fieldName: 'fetchProducts', + ownerNames: ['QueryFetchProductsArgs'], + argumentCount: 1, + }; + + expect(resolveOperationArgsOwner(source, options)).toBe('QueryFetchProductsArgs'); + expect(() => resolveOperationArgsOwner('', options)).toThrow('must have exactly one generated Args declaration; found 0'); + expect(() => + resolveOperationArgsOwner(source, { + ...options, + argumentCount: 0, + }), + ).toThrow('has no SDL arguments but generated 1 Args declarations'); + }); + + it('requires one-argument aliases and multi-argument interfaces', () => { + const oneArgument = { + rootName: 'Query', + fieldName: 'single', + ownerNames: ['QuerySingleArgs'], + argumentCount: 1, + argumentContracts: [{ name: 'value', optional: false, type: 'string' }], + }; + const multipleArguments = { + rootName: 'Mutation', + fieldName: 'multiple', + ownerNames: ['MutationMultipleArgs'], + argumentCount: 2, + argumentContracts: [ + { name: 'first', optional: false, type: 'string' }, + { name: 'second', optional: true, type: '(number | null)' }, + ], + }; + + expect(resolveOperationArgsOwner('export type QuerySingleArgs = string;', oneArgument)).toBe('QuerySingleArgs'); + expect(() => resolveOperationArgsOwner('export interface QuerySingleArgs { value: string }', oneArgument)).toThrow( + 'must generate a type alias Args declaration', + ); + + expect( + resolveOperationArgsOwner('export interface MutationMultipleArgs { first: string; second?: (number | null) }', multipleArguments), + ).toBe('MutationMultipleArgs'); + expect(() => resolveOperationArgsOwner('export type MutationMultipleArgs = string;', multipleArguments)).toThrow( + 'must generate a interface Args declaration', + ); + expect(() => resolveOperationArgsOwner('export type QuerySingleArgs = number;', oneArgument)).toThrow('Args alias drifted'); + expect(() => + resolveOperationArgsOwner('export interface MutationMultipleArgs { wrong: string; second?: (number | null) }', multipleArguments), + ).toThrow('Args fields drifted'); + }); + + it('discovers every root operation field structurally across multiline types', () => { + const source = `export interface Query { + compact?: Promise; + multiline?: Promise< + string | number + >; + "quoted"?: Promise; +}`; + + expect(operationFieldNames(source, 'Query')).toEqual(['compact', 'multiline', 'quoted']); + expect(() => operationFieldNames(source, 'Query', ['compact', 'multiline'])).toThrow('Query operation fields drifted'); + expect(() => operationFieldNames('export interface Query { duplicate: string; duplicate: number }', 'Query')).toThrow( + 'contains duplicate field declarations', + ); + expect(() => operationFieldNames('export interface Query { resolve(): string }', 'Query')).toThrow( + 'only supports static property signatures', + ); + }); + + it('attaches single-field argument guidance to its emitted alias', () => { + const source = renderDocumentedTypeAlias('QueryLegacyArgs', 'string', '/** @deprecated Use modern instead. */'); + const sourceFile = ts.createSourceFile('generated-types.ts', source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS); + const alias = sourceFile.statements[0]; + + expect(ts.isTypeAliasDeclaration(alias)).toBe(true); + expect(ts.getJSDocTags(alias).map((tag) => [tag.tagName.text, tag.comment])).toEqual([['deprecated', 'Use modern instead.']]); + }); + + it('derives one-field marked unions through the canonical rewrite path', () => { + const alias = deriveMarkedUnionAlias( + `export interface Result { + /** Canonical result value. */ + value?: (Promise | null); +} +`, + 'Result', + ); + + expect(alias.type).toBe('Promise | null'); + expect(renderDocumentedTypeAlias('Result', alias.declaration)).toContain( + '/** Canonical result value. */\n | Promise\n | null', + ); + }); + + it('fails closed when marked union fields stop being optional', () => { + expect(() => + deriveMarkedUnionAlias( + `export interface Result { + value: string; +} +`, + 'Result', + ), + ).toThrow('Result.value Union marker field must remain optional'); + }); + + it('fails closed when a generation marker has no exact output effect', () => { + const markers = { + futureFields: new Set(['Query.currentValue']), + issues: [], + unionWrappers: new Set(['Result']), + }; + const valid = `export interface Query { + currentValue: Promise; +} +export type Result = string | null; +`; + const unionContracts = new Map([['Result', ['string', 'null']]]); + + expect(() => requireGeneratedMarkerEffects(valid, markers, unionContracts)).not.toThrow(); + expect(() => + requireGeneratedMarkerEffects( + valid.replace('string | null', 'number | string | null'), + markers, + new Map([['Result', ['number', 'string', 'null']]]), + ), + ).not.toThrow(); + expect(() => requireGeneratedMarkerEffects(valid.replace('Promise', 'string'), markers, unionContracts)).toThrow( + 'Query.currentValue Future marker did not produce exactly one Promise return', + ); + expect(() => + requireGeneratedMarkerEffects( + valid.replace('export type Result = string | null;', 'export interface Result { value?: string }'), + markers, + unionContracts, + ), + ).toThrow('Result Union marker must produce exactly one type alias; found 0 aliases and 1 interfaces'); + expect(() => requireGeneratedMarkerEffects(valid.replace('string | null', 'never'), markers, unionContracts)).toThrow( + 'Result Union marker alias body drifted; expected string | null, found never', + ); + expect(() => + requireGeneratedMarkerEffects(valid.replace('string | null', 'string | null | WrongType'), markers, unionContracts), + ).toThrow('Result Union marker alias body drifted; expected string | null, found string | null | WrongType'); + }); +}); diff --git a/packages/gql/src/deprecation-transformer.test.ts b/packages/gql/src/deprecation-transformer.test.ts new file mode 100644 index 000000000..7c24086a7 --- /dev/null +++ b/packages/gql/src/deprecation-transformer.test.ts @@ -0,0 +1,465 @@ +import { describe, expect, it } from 'vitest'; +import { buildASTSchema, parse } from 'graphql'; +import { transformSchema } from '../codegen/core/transformer'; +import { CSharpPlugin } from '../codegen/plugins/csharp'; +import { DartPlugin } from '../codegen/plugins/dart'; +import { GDScriptPlugin } from '../codegen/plugins/gdscript'; +import { KotlinPlugin } from '../codegen/plugins/kotlin'; +import { SwiftPlugin } from '../codegen/plugins/swift'; +import { + GRAPHQL_TO_CSHARP, + GRAPHQL_TO_DART, + GRAPHQL_TO_GDSCRIPT, + GRAPHQL_TO_KOTLIN, + GRAPHQL_TO_SWIFT, + GRAPHQL_TO_TYPESCRIPT, + SUPPORTED_GRAPHQL_SCALARS, +} from '../codegen/core/utils'; +import { extractSchemaDeprecations } from '../schema-deprecations.mjs'; + +function transform(sdl: string, unionWrappers: string[] = []) { + const source = `directive @openiapDeprecated(reason: String!) on OBJECT | INTERFACE | UNION | ENUM | INPUT_OBJECT +${sdl}`; + return transformSchema({ + schema: buildASTSchema(parse(source)), + markers: { + unionWrappers: new Set(unionWrappers), + futureFields: new Set(), + issues: [], + }, + deprecations: extractSchemaDeprecations([source]), + sdlContents: new Map([['schema.graphql', source]]), + }); +} + +describe('deprecation documentation transformation', () => { + it('rejects ambiguous enum wire values and unmapped scalars', () => { + expect(() => transform('enum Ambiguous { FooBar Foo_Bar }')).toThrow( + 'Ambiguous enum values FooBar and Foo_Bar both serialize as "foo-bar".', + ); + expect(() => transform('scalar Money type Query { price: Money }')).toThrow('Unsupported GraphQL scalar Money'); + expect(() => transform('scalar Money type Query { ok: Boolean }')).toThrow('Unsupported GraphQL scalar Money'); + + for (const mapping of [ + GRAPHQL_TO_TYPESCRIPT, + GRAPHQL_TO_SWIFT, + GRAPHQL_TO_KOTLIN, + GRAPHQL_TO_DART, + GRAPHQL_TO_GDSCRIPT, + GRAPHQL_TO_CSHARP, + ]) { + expect(new Set(Object.keys(mapping))).toEqual(SUPPORTED_GRAPHQL_SCALARS); + } + for (const plugin of [ + new SwiftPlugin({ outputPath: 'Types.swift' }), + new KotlinPlugin({ outputPath: 'Types.kt' }), + new DartPlugin({ outputPath: 'types.dart' }), + new GDScriptPlugin({ outputPath: 'types.gd' }), + new CSharpPlugin({ outputPath: 'Types.cs' }), + ]) { + expect(() => plugin.mapScalar('Money')).toThrow('GraphQL scalar mapping'); + } + }); + + it('fails closed when ProductCommon platform defaults lose exact schema ownership', () => { + const productContract = ` + enum IapPlatform { IOS Android } + enum ProductType { InApp Subs } + interface ProductCommon { + platform: IapPlatform! + type: ProductType! + } + type ProductAndroid implements ProductCommon { + platform: IapPlatform! + type: ProductType! + } + type ProductIOS implements ProductCommon { + platform: IapPlatform! + type: ProductType! + } + type ProductSubscriptionAndroid implements ProductCommon { + platform: IapPlatform! + type: ProductType! + } + type ProductSubscriptionIOS implements ProductCommon { + platform: IapPlatform! + type: ProductType! + } + type Query { product: ProductAndroid } + `; + + const schema = transform(productContract); + expect( + schema.objects.find((objectType) => objectType.name === 'ProductSubscriptionIOS')?.fields.find((field) => field.name === 'type') + ?.defaultValue, + ).toBe('subs'); + + expect(() => + transform( + productContract.replace( + 'type Query { product: ProductAndroid }', + `type ProductVision implements ProductCommon { + platform: IapPlatform! + type: ProductType! + } + type Query { product: ProductAndroid }`, + ), + ), + ).toThrow('ProductCommon platform-default coverage drifted'); + + expect(() => + transform( + productContract.replace( + `type ProductIOS implements ProductCommon { + platform: IapPlatform!`, + `type ProductIOS implements ProductCommon { + platform: String!`, + ), + ), + ).toThrow('ProductIOS.platform platform-default contract must remain non-null IapPlatform'); + + expect(() => transform(productContract.replace('IOS Android', 'IOS'))).toThrow( + 'ProductAndroid.platform platform default "android" is not a IapPlatform wire value', + ); + }); + + it('uses directive reasons once for object types and fields', () => { + const schema = transform(` + """Legacy offer metadata.""" + type LegacyOffer @openiapDeprecated(reason: "Use DiscountOffer instead.") { + """Legacy identifier.""" + legacyId: String @deprecated(reason: "Use id instead.") + } + + """Legacy billing selector.""" + enum LegacyBillingMode @openiapDeprecated(reason: "Use BillingProgram instead.") { + """Legacy choice.""" + LEGACY @deprecated(reason: "Use MODERN instead.") + MODERN + } + `); + const legacyOffer = schema.objects.find((object) => object.name === 'LegacyOffer'); + const legacyBillingMode = schema.enums.find((enumeration) => enumeration.name === 'LegacyBillingMode'); + + expect(legacyOffer?.description).toBe('Legacy offer metadata.\n@deprecated Use DiscountOffer instead.'); + expect(legacyOffer?.fields[0]?.description).toBe('Legacy identifier.\n@deprecated Use id instead.'); + expect(legacyBillingMode?.description).toBe('Legacy billing selector.\n@deprecated Use BillingProgram instead.'); + expect(legacyBillingMode?.values[0]?.description).toBe('Legacy choice.\n@deprecated Use MODERN instead.'); + }); + + it('preserves type-level reasons on operation roots', () => { + const schema = transform(` + """Legacy query root.""" + type Query @openiapDeprecated(reason: "Use the replacement root.") { + value: String + } + `); + + expect(schema.operations[0]?.description).toBe('Legacy query root.\n@deprecated Use the replacement root.'); + expect(new GDScriptPlugin({ outputPath: 'types.gd' }).generate(schema)).toContain( + '## Legacy query root. @deprecated Use the replacement root.\nclass Query:', + ); + }); + + it('preserves operation argument reasons in every custom generator', () => { + const schema = transform(` + type Query { + value( + """Legacy selector.""" + legacy: String @deprecated(reason: "Use modern instead.") + ): String + } + `); + const plugins = [ + new SwiftPlugin({ outputPath: 'Types.swift' }), + new KotlinPlugin({ outputPath: 'Types.kt' }), + new DartPlugin({ outputPath: 'types.dart' }), + new GDScriptPlugin({ outputPath: 'types.gd' }), + new CSharpPlugin({ outputPath: 'Types.cs' }), + ]; + + for (const plugin of plugins) { + expect(plugin.generate(schema)).toContain('@deprecated Use modern instead.'); + } + }); + + it('preserves reasons on custom VoidResult declarations', () => { + const schema = transform(` + """Generic completion result.""" + type VoidResult @openiapDeprecated(reason: "Use the operation return value instead.") { + success: Boolean! + } + `); + const plugins = [ + new SwiftPlugin({ outputPath: 'Types.swift' }), + new KotlinPlugin({ outputPath: 'Types.kt' }), + new DartPlugin({ outputPath: 'types.dart' }), + new CSharpPlugin({ outputPath: 'Types.cs' }), + ]; + + for (const plugin of plugins) { + expect(plugin.generate(schema)).toContain('@deprecated Use the operation return value instead.'); + } + }); + + it('preserves reasons on result-union variants', () => { + const schema = transform( + ` + type LegacyResult { + """Legacy result branch.""" + legacy: String @deprecated(reason: "Use modern instead.") + modern: String + } + `, + ['LegacyResult'], + ); + const plugins = [ + new SwiftPlugin({ outputPath: 'Types.swift' }), + new KotlinPlugin({ outputPath: 'Types.kt' }), + new DartPlugin({ outputPath: 'types.dart' }), + new CSharpPlugin({ outputPath: 'Types.cs' }), + ]; + + for (const plugin of plugins) { + expect(plugin.generate(schema)).toContain('@deprecated Use modern instead.'); + } + }); + + it('fails closed when a custom input gains an unhandled field', () => { + expect(() => + transform(` + input RequestPurchaseProps { + requestPurchase: RequestPurchasePropsByPlatforms + requestSubscription: RequestSubscriptionPropsByPlatforms + type: ProductQueryType = InApp + useAlternativeBilling: Boolean + unexpected: String + } + input RequestPurchasePropsByPlatforms { + apple: String + google: String + ios: String + android: String + } + input RequestSubscriptionPropsByPlatforms { + apple: String + google: String + ios: String + android: String + } + enum ProductQueryType { InApp Subs All } + `), + ).toThrow('RequestPurchaseProps custom input contract fields drifted'); + expect(() => + transform(` + input DiscountOfferInputIOS { + identifier: String! + keyIdentifier: String! + nonce: String! + signature: String! + timestamp: Float! + unexpected: String + } + `), + ).toThrow('DiscountOfferInputIOS custom input contract fields drifted'); + }); + + it('fails closed when custom input type, nullability, or defaults drift', () => { + expect(() => + transform(` + input PurchaseInput { + id: String! + productId: String! + ids: [String!] + transactionDate: Float! + purchaseToken: String + store: IapStore + platform: IapPlatform + quantity: Int! + purchaseState: PurchaseState! + isAutoRenewing: Boolean! + } + enum IapStore { Apple Google } + enum IapPlatform { Ios Android } + enum PurchaseState { Purchased } + `), + ).toThrow('PurchaseInput.id custom input contract drifted'); + + expect(() => + transform(` + input DiscountOfferInputIOS { + identifier: String! + keyIdentifier: String! + nonce: String! + signature: String! + timestamp: Int! + } + `), + ).toThrow('DiscountOfferInputIOS.timestamp custom input contract drifted'); + + expect(() => + transform(` + input RequestPurchaseProps { + requestPurchase: RequestPurchasePropsByPlatforms + requestSubscription: RequestSubscriptionPropsByPlatforms + type: ProductQueryType = Subs + useAlternativeBilling: Boolean + } + input RequestPurchasePropsByPlatforms { + apple: RequestPurchaseIosProps + google: RequestPurchaseAndroidProps + ios: RequestPurchaseIosProps + android: RequestPurchaseAndroidProps + } + input RequestSubscriptionPropsByPlatforms { + apple: RequestSubscriptionIosProps + google: RequestSubscriptionAndroidProps + ios: RequestSubscriptionIosProps + android: RequestSubscriptionAndroidProps + } + input RequestPurchaseIosProps { value: String } + input RequestPurchaseAndroidProps { value: String } + input RequestSubscriptionIosProps { value: String } + input RequestSubscriptionAndroidProps { value: String } + enum ProductQueryType { InApp Subs All } + `), + ).toThrow('RequestPurchaseProps.type custom input contract drifted'); + }); + + it('fails closed when nested platform input projections drift', () => { + expect(() => + transform(` + input RequestPurchasePropsByPlatforms { + apple: RequestPurchaseIosProps + google: String + ios: RequestPurchaseIosProps + android: RequestPurchaseAndroidProps + } + input RequestPurchaseIosProps { value: String } + input RequestPurchaseAndroidProps { value: String } + `), + ).toThrow('RequestPurchasePropsByPlatforms.google custom input contract drifted'); + }); + + it('requires exact concrete projections of interface field deprecations', () => { + const schema = transform(` + interface LegacyCommon { + platform: String @deprecated(reason: "Use store instead.") + } + type LegacyAndroid implements LegacyCommon { + platform: String @deprecated(reason: "Use store instead.") + } + `); + const legacy = schema.objects.find((object) => object.name === 'LegacyAndroid'); + + expect(legacy?.fields[0]?.description).toBe('@deprecated Use store instead.'); + expect(new GDScriptPlugin({ outputPath: 'types.gd' }).generate(schema)).toContain( + '## @deprecated Use store instead.\n\tvar platform: Variant = null', + ); + }); + + it('fails closed when an implementation omits or conflicts with interface deprecation guidance', () => { + expect(() => + transform(` + interface LegacyCommon { + platform: String @deprecated(reason: "Use store instead.") + } + type LegacyAndroid implements LegacyCommon { + platform: String + } + `), + ).toThrow('must repeat the exact interface-owned deprecation guidance'); + + expect(() => + transform(` + interface LegacyCommon { + platform: String @deprecated(reason: "Use store instead.") + } + type LegacyAndroid implements LegacyCommon { + platform: String @deprecated(reason: "Use purchaseStore instead.") + } + `), + ).toThrow('conflicts with the exact interface-owned deprecation guidance'); + }); + + it('fails closed when descriptions duplicate directive-owned tags', () => { + expect(() => + transform(` + """ + Legacy offer metadata. + @deprecated Manual duplicate. + """ + type LegacyOffer @openiapDeprecated(reason: "Canonical reason.") { + id: String + } + `), + ).toThrow('duplicates directive-owned @deprecated guidance'); + }); + + it('fails closed for an explicitly empty canonical reason', () => { + expect(() => + transform(` + type LegacyOffer @openiapDeprecated(reason: "") { + id: String + } + `), + ).toThrow('must declare exactly one non-empty string'); + }); + + it('fails closed for missing or unknown type-level directive arguments', () => { + expect(() => + transform(` + type LegacyOffer @openiapDeprecated { + id: String + } + `), + ).toThrow(); + + expect(() => + transform(` + type LegacyOffer @openiapDeprecated(foo: "Use DiscountOffer instead.") { + id: String + } + `), + ).toThrow(); + }); +}); + +describe('generation marker transformation', () => { + it('fails closed when a union wrapper has a required field', () => { + expect(() => + transform( + ` + type Result { + value: String! + } + `, + ['Result'], + ), + ).toThrow('Result # => Union wrapper fields must all be nullable; required: value.'); + }); + + it('fails closed when a union wrapper is empty', () => { + expect(() => + transform( + ` + type Result + `, + ['Result'], + ), + ).toThrow('Result # => Union wrapper must declare at least one nullable result field.'); + }); + + it('fails closed when a union wrapper targets an operation root', () => { + expect(() => + transform( + ` + type Query { + value: String + } + `, + ['Query'], + ), + ).toThrow('Query cannot use # => Union because operation root types cannot be union wrappers.'); + }); +}); diff --git a/packages/gql/src/error.graphql b/packages/gql/src/error.graphql index 5421391d3..a2eb2b4f0 100644 --- a/packages/gql/src/error.graphql +++ b/packages/gql/src/error.graphql @@ -9,12 +9,9 @@ enum ErrorCode { RemoteError NetworkError ServiceError - # @deprecated Use PurchaseVerificationFailed instead - ReceiptFailed - # @deprecated Use PurchaseVerificationFinished instead - ReceiptFinished - # @deprecated Use PurchaseVerificationFinishFailed instead - ReceiptFinishedFailed + ReceiptFailed @deprecated(reason: "Use PurchaseVerificationFailed instead") + ReceiptFinished @deprecated(reason: "Use PurchaseVerificationFinished instead") + ReceiptFinishedFailed @deprecated(reason: "Use PurchaseVerificationFinishFailed instead") PurchaseVerificationFailed PurchaseVerificationFinished PurchaseVerificationFinishFailed diff --git a/packages/gql/src/generated-compatibility.test.ts b/packages/gql/src/generated-compatibility.test.ts index 8d1a04cc9..3b9084230 100644 --- a/packages/gql/src/generated-compatibility.test.ts +++ b/packages/gql/src/generated-compatibility.test.ts @@ -1,126 +1,552 @@ -import { readFileSync } from "node:fs"; -import { describe, expect, it } from "vitest"; +import { readFileSync } from 'node:fs'; +import { Kind, parse } from 'graphql'; +import { describe, expect, it } from 'vitest'; +import { SCHEMA_FILE_NAMES } from '../schema-files.mjs'; +import { extractSchemaMarkers } from '../schema-markers.mjs'; +import { assertValidSchemaDeprecations, extractSchemaDeprecations } from '../schema-deprecations.mjs'; +import { requireNoGraphqlCodegenScaffolding } from '../scripts/custom-generated-guards.mjs'; function generated(name: string): string { - return readFileSync(new URL(`./generated/${name}`, import.meta.url), "utf8"); + return readFileSync(new URL(`./generated/${name}`, import.meta.url), 'utf8'); } -describe("generated compatibility", () => { - it("keeps the canonical DiscountOffer type reference in the schema", () => { - const schema = readFileSync( - new URL("./type.graphql", import.meta.url), - "utf8", +const schemaSources = () => SCHEMA_FILE_NAMES.map((fileName) => readFileSync(new URL(`./${fileName}`, import.meta.url), 'utf8')); + +function canonicalDeprecations() { + const deprecations = extractSchemaDeprecations(schemaSources()); + assertValidSchemaDeprecations(deprecations); + return deprecations.entries; +} + +function interfaceUnionOwners(): Map { + const document = parse(schemaSources().join('\n')); + const objectInterfaces = new Map>(); + const unionMembers = new Map(); + + for (const definition of document.definitions) { + if (definition.kind === Kind.OBJECT_TYPE_DEFINITION || definition.kind === Kind.OBJECT_TYPE_EXTENSION) { + const interfaces = objectInterfaces.get(definition.name.value) ?? new Set(); + for (const implemented of definition.interfaces ?? []) { + interfaces.add(implemented.name.value); + } + objectInterfaces.set(definition.name.value, interfaces); + } else if (definition.kind === Kind.UNION_TYPE_DEFINITION) { + unionMembers.set( + definition.name.value, + (definition.types ?? []).map((member) => member.name.value), + ); + } + } + + const resolved = new Map>(); + const interfacesFor = (typeName: string, visiting = new Set()): Set => { + const cached = resolved.get(typeName); + if (cached) return cached; + if (visiting.has(typeName)) { + throw new Error(`Cyclic union membership while resolving ${typeName}`); + } + const direct = objectInterfaces.get(typeName); + if (direct) return direct; + const members = unionMembers.get(typeName); + if (!members || members.length === 0) return new Set(); + + const nextVisiting = new Set(visiting).add(typeName); + const memberInterfaces = members.map((member) => interfacesFor(member, nextVisiting)); + const shared = new Set( + [...memberInterfaces[0]].filter((name) => memberInterfaces.slice(1).every((interfaces) => interfaces.has(name))), ); - const typeIndex = schema.indexOf("type DiscountOffer {"); + resolved.set(typeName, shared); + return shared; + }; + + const owners = new Map(); + for (const unionName of unionMembers.keys()) { + for (const interfaceName of interfacesFor(unionName)) { + owners.set(interfaceName, [...(owners.get(interfaceName) ?? []), unionName]); + } + } + return owners; +} + +function declarationAfterDeprecationTag(source: string, tagOffset: number): string { + const blockStart = source.lastIndexOf('/**', tagOffset); + const priorBlockEnd = source.lastIndexOf('*/', tagOffset); + let cursor: number; + + if (blockStart > priorBlockEnd) { + const blockEnd = source.indexOf('*/', tagOffset); + if (blockEnd === -1) return ''; + cursor = blockEnd + 2; + } else { + const lineEnd = source.indexOf('\n', tagOffset); + cursor = lineEnd === -1 ? source.length : lineEnd + 1; + } + + for (const line of source.slice(cursor).split(/\r?\n/)) { + const trimmed = line.trim(); + if ( + !trimmed || + trimmed.startsWith('///') || + trimmed.startsWith('##') || + trimmed.startsWith('/**') || + trimmed.startsWith('*') || + trimmed === '*/' || + /^@\w/.test(trimmed) || + /^\[[^\]]+\]$/.test(trimmed) + ) { + continue; + } + return trimmed; + } + + return ''; +} + +type GeneratedFileName = 'types.ts' | 'Types.swift' | 'Types.kt' | 'types.dart' | 'types.gd' | 'Types.cs'; + +const generatedFiles: GeneratedFileName[] = ['types.ts', 'Types.swift', 'Types.kt', 'types.dart', 'types.gd', 'Types.cs']; + +function mergeAttachmentMultiplicity(existing: string[], additional: string[]): string[] { + const maximumCounts = new Map(); + for (const entries of [existing, additional]) { + const counts = new Map(); + for (const entry of entries) { + counts.set(entry, (counts.get(entry) ?? 0) + 1); + } + for (const [entry, count] of counts) { + maximumCounts.set(entry, Math.max(maximumCounts.get(entry) ?? 0, count)); + } + } + return [...maximumCounts].flatMap(([entry, count]) => Array.from({ length: count }, () => entry)); +} + +const pascalCase = (value: string): string => + value + .split('_') + .filter(Boolean) + .map((part) => `${part.charAt(0).toUpperCase()}${part.slice(1).toLowerCase()}`) + .join(''); + +const upperCamelName = (value: string): string => + value.includes('_') ? pascalCase(value) : `${value.charAt(0).toUpperCase()}${value.slice(1)}`; + +const snakeCase = (value: string): string => + value + .replace(/([a-z0-9])([A-Z])/g, '$1_$2') + .replace(/([A-Z]+)([A-Z][a-z])/g, '$1_$2') + .toLowerCase(); + +function topLevelDeclarationName(file: GeneratedFileName, line: string): string | null { + if (/^\s/.test(line)) return null; + const patterns: Record = { + 'types.ts': /^export (?:interface|type|enum) ([A-Za-z_$][\w$]*)\b/, + 'Types.swift': /^public (?:struct|enum|protocol|typealias) ([A-Za-z_]\w*)\b/, + 'Types.kt': /^public (?:data class|enum class|sealed interface|interface|typealias) ([A-Za-z_]\w*)\b/, + 'types.dart': /^(?:(?:abstract|sealed) )?class ([A-Za-z_]\w*)\b|^enum ([A-Za-z_]\w*)\b|^typedef ([A-Za-z_]\w*)\b/, + 'types.gd': /^class ([A-Za-z_]\w*):|^enum ([A-Za-z_]\w*)\b/, + 'Types.cs': /^public (?:(?:sealed|abstract) )?(?:record(?: class)?|class|interface|enum) ([A-Za-z_]\w*)\b/, + }; + const match = patterns[file].exec(line); + return match?.slice(1).find(Boolean) ?? null; +} + +function declarationSymbol(file: GeneratedFileName, declaration: string): string | null { + const topLevel = topLevelDeclarationName(file, declaration); + if (topLevel) return topLevel; + + const line = declaration.trim(); + const patterns: Record = { + 'types.ts': [/^(?:readonly\s+)?([A-Za-z_$][\w$]*)\??\s*:/, /^([A-Za-z_$][\w$]*)\s*=/], + 'Types.swift': [/^(?:public\s+)?(?:var|let)\s+([A-Za-z_]\w*)\b/, /^(?:public\s+)?func\s+([A-Za-z_]\w*)\b/, /^case\s+([A-Za-z_]\w*)\b/], + 'Types.kt': [ + /^(?:override\s+)?(?:val|var)\s+([A-Za-z_]\w*)\b/, + /^(?:suspend\s+)?fun\s+([A-Za-z_]\w*)\b/, + /^([A-Za-z_]\w*)\s*(?:\(|,|$)/, + ], + 'types.dart': [/\bget\s+([A-Za-z_]\w*)\s*;/, /\b([A-Za-z_]\w*)\s*\(/, /\b([A-Za-z_]\w*)\s*;$/, /\b([A-Za-z_]\w*)\s*,?$/], + 'types.gd': [/^var\s+([A-Za-z_]\w*)\b/, /^class\s+([A-Za-z_]\w*):/, /^([A-Z][A-Z0-9_]*)\s*=/, /^static func\s+([A-Za-z_]\w*)\b/], + 'Types.cs': [/\b([A-Za-z_]\w*)\s*\{\s*get\b/, /\b([A-Za-z_]\w*)\s*\(/, /^([A-Za-z_]\w*)\s*,?$/], + }; + + for (const pattern of patterns[file]) { + const match = pattern.exec(line); + if (match) return match[1]; + } + return null; +} + +function normalizedOwner(_file: GeneratedFileName, generatedOwner: string): string { + if (generatedOwner.endsWith('Resolver')) { + return generatedOwner.slice(0, -'Resolver'.length); + } + return generatedOwner; +} + +function gdscriptOperationHelperOwners(): Map { + const owners = new Map(); + for (const definition of parse(schemaSources().join('\n')).definitions) { + if (definition.kind !== Kind.OBJECT_TYPE_DEFINITION && definition.kind !== Kind.OBJECT_TYPE_EXTENSION) { + continue; + } + if (!['Query', 'Mutation', 'Subscription'].includes(definition.name.value)) { + continue; + } + for (const field of definition.fields ?? []) { + owners.set(`${snakeCase(field.name.value)}_args`, definition.name.value); + } + } + return owners; +} + +function expectedGeneratedSymbol(file: GeneratedFileName, entry: ReturnType[number]): string { + if (!entry.parentName) return entry.name; + if (entry.kind === Kind.ENUM_VALUE_DEFINITION) { + if (file === 'types.gd') { + return entry.name.includes('_') ? entry.name : snakeCase(entry.name).toUpperCase(); + } + const value = entry.name.includes('_') ? pascalCase(entry.name) : entry.name; + return file === 'Types.swift' ? `${value.charAt(0).toLowerCase()}${value.slice(1)}` : value; + } + if (entry.parentName === 'Query' || entry.parentName === 'Mutation' || entry.parentName === 'Subscription') { + if (file === 'Types.cs') return `${upperCamelName(entry.name)}Async`; + if (file === 'types.gd') return `${entry.name}Field`; + return entry.name; + } + if (file === 'Types.cs') { + return entry.name === 'ios' ? 'IOS' : upperCamelName(entry.name); + } + if (file === 'types.gd') return snakeCase(entry.name); + return entry.name; +} + +function collectDeprecationAttachments(file: GeneratedFileName, source: string, reason: string): Array<{ owner: string; symbol: string }> { + const gdscriptHelperOwners = file === 'types.gd' ? gdscriptOperationHelperOwners() : null; + const topLevels: Array<{ name: string; offset: number }> = []; + let lineOffset = 0; + for (const line of source.split(/\r?\n/)) { + const name = topLevelDeclarationName(file, line); + if (name) topLevels.push({ name, offset: lineOffset }); + lineOffset += line.length + 1; + } + + const tag = `@deprecated ${reason}`; + const attachments: Array<{ owner: string; symbol: string }> = []; + let tagOffset = source.indexOf(tag); + while (tagOffset !== -1) { + const declaration = declarationAfterDeprecationTag(source, tagOffset); + const symbol = declarationSymbol(file, declaration); + const declarationOffset = source.indexOf(declaration, tagOffset); + const declarationLineStart = declarationOffset === -1 ? -1 : source.lastIndexOf('\n', declarationOffset - 1) + 1; + const declarationOwner = + declarationOffset !== -1 && declarationLineStart === declarationOffset ? topLevelDeclarationName(file, declaration) : null; + const precedingOwner = [...topLevels].reverse().find((candidate) => candidate.offset < tagOffset); + const owner = (file === 'types.gd' && symbol ? gdscriptHelperOwners?.get(symbol) : null) ?? declarationOwner ?? precedingOwner?.name; + if (owner && symbol) { + attachments.push({ + owner: normalizedOwner(file, owner), + symbol, + }); + } + tagOffset = source.indexOf(tag, tagOffset + tag.length); + } + return attachments; +} + +function interfaceImplementors(): Map { + const result = new Map(); + for (const sdl of schemaSources()) { + for (const definition of parse(sdl).definitions) { + if (definition.kind !== Kind.OBJECT_TYPE_DEFINITION && definition.kind !== Kind.OBJECT_TYPE_EXTENSION) { + continue; + } + for (const implemented of definition.interfaces ?? []) { + result.set(implemented.name.value, [...(result.get(implemented.name.value) ?? []), definition.name.value]); + } + } + } + return new Map([...result].map(([name, owners]) => [name, [...new Set(owners)]])); +} + +describe('generated compatibility', () => { + it('contains no graphql-codegen scaffolding after TypeScript post-processing', () => { + expect(() => requireNoGraphqlCodegenScaffolding(generated('types.ts'))).not.toThrow(); + }); + + it('keeps the canonical DiscountOffer type reference in the schema', () => { + const schema = readFileSync(new URL('./type.graphql', import.meta.url), 'utf8'); + const typeIndex = schema.indexOf('type DiscountOffer {'); const descriptionEnd = schema.lastIndexOf('"""', typeIndex); const descriptionStart = schema.lastIndexOf('"""', descriptionEnd - 1); const description = schema.slice(descriptionStart, descriptionEnd); - expect(description).toContain( - "@see https://openiap.dev/docs/types/discount-offer", - ); - expect(description).not.toContain( - "@see https://openiap.dev/docs/features/discount", - ); + expect(description).toContain('@see https://openiap.dev/docs/types/discount-offer'); + expect(description).not.toContain('@see https://openiap.dev/docs/features/discount'); }); - it("emits one deprecation tag per TypeScript doc block", () => { - const typescript = generated("types.ts"); - const duplicateBlocks = ( - typescript.match(/\/\*\*[\s\S]*?\*\//g) ?? [] - ).filter( - (block) => - (block.match(/^\s*(?:\/\*\*|\*)\s*@deprecated\b/gm) ?? []).length > 1, + it('emits one deprecation tag per TypeScript doc block', () => { + const typescript = generated('types.ts'); + const duplicateBlocks = (typescript.match(/\/\*\*[\s\S]*?\*\//g) ?? []).filter( + (block) => (block.match(/^\s*(?:\/\*\*|\*)\s*@deprecated\b/gm) ?? []).length > 1, ); expect(duplicateBlocks).toEqual([]); - expect(typescript).toContain( - "@deprecated One-time offers belong to ProductAndroid.discountOffers;", + expect(typescript).toContain('@deprecated One-time offers belong to ProductAndroid.discountOffers;'); + }); + + it('keeps generated TypeScript aliases separated by one blank line', () => { + const typescript = generated('types.ts'); + expect(typescript).not.toMatch(/(?:\r?\n){3,}export /); + }); + + it('preserves complete offer guidance in generated GDScript docs', () => { + const gdscript = generated('types.gd'); + + expect(gdscript).toContain( + '## Standardized Android one-time product purchase options and offers. Native metadata uses Android-suffixed fields. @see https://openiap.dev/docs/types/discount-offer', + ); + expect(gdscript).toContain( + '## Legacy nullable compatibility field. Google Play does not populate one-time purchase offer details for subscription products. @deprecated One-time offers belong to ProductAndroid.discountOffers; subscriptions use subscriptionOffers.', ); }); - it("preserves the published MAUI 1.x string signatures", () => { - const csharp = generated("Types.cs"); + it('propagates canonical type and field deprecation reasons to every language', () => { + const generatedFiles = ['types.ts', 'Types.swift', 'Types.kt', 'types.dart', 'types.gd', 'Types.cs']; + for (const file of generatedFiles) { + const source = generated(file); + expect(source).toContain('@deprecated Use the standardized DiscountOffer type for Android one-time offers.'); + expect(source).toContain( + '@deprecated One-time offers belong to ProductAndroid.discountOffers; subscriptions use subscriptionOffers.', + ); + expect(source).toContain('@deprecated Use enableBillingProgramAndroid with BillingProgramAndroid instead.'); + expect(source).toContain('@deprecated Use enableBillingProgramAndroid in InitConnectionConfig instead.'); + } - for (const method of [ - "DeepLinkToSubscriptions", - "FinishTransaction", - "RestorePurchases", - ]) { + // Most TypeScript GraphQL enums become string unions, so their individual + // members have no declaration that can carry member-level JSDoc. ErrorCode + // intentionally remains an enum and must retain member documentation. + for (const file of generatedFiles.filter((file) => file !== 'types.ts')) { + const source = generated(file); + expect(source).toContain('@deprecated Use BillingProgramAndroid.USER_CHOICE_BILLING instead.'); + } + }); + + it('covers every representable canonical deprecation in generated docs', () => { + const entries = canonicalDeprecations(); + const { unionWrappers } = extractSchemaMarkers(schemaSources()); + const implementors = interfaceImplementors(); + const unionOwners = interfaceUnionOwners(); + + expect(entries.length).toBeGreaterThan(0); + for (const file of generatedFiles) { + const source = generated(file); + const representableEntries = entries.filter((entry) => { + if (file === 'types.ts' && entry.kind === Kind.ENUM_VALUE_DEFINITION && entry.parentName !== 'ErrorCode') { + return false; + } + if ( + file === 'types.gd' && + (entry.kind.includes('Interface') || + entry.parentName === 'Subscription' || + (entry.parentName && unionWrappers.has(entry.parentName))) + ) { + return false; + } + if (entry.ownerPath === 'PurchaseInput.platform' && file !== 'types.gd') { + // Only GDScript retains PurchaseInput as a field-bearing class. + // Other targets intentionally alias/wrap it without declarations. + return false; + } + return true; + }); + + const expectedByReason = new Map(); + for (const entry of representableEntries) { + let expectedOwners = [entry.parentName ?? entry.name]; + if (entry.parentKind?.includes('Interface') && entry.parentName) { + const generatedImplementors = implementors.get(entry.parentName) ?? []; + expectedOwners = + file === 'types.ts' + ? [entry.parentName] + : file === 'types.gd' + ? generatedImplementors + : [entry.parentName, ...generatedImplementors]; + if (file === 'Types.swift' || file === 'types.dart') { + expectedOwners.push(...(unionOwners.get(entry.parentName) ?? [])); + } + } + const expectedSymbols = [expectedGeneratedSymbol(file, entry)]; + if ( + file === 'types.gd' && + (entry.parentName === 'Query' || entry.parentName === 'Mutation' || entry.parentName === 'Subscription') + ) { + // GDScript exposes each operation through both its metadata field + // class and a public argument-builder helper. Both are generated + // from the same schema description and must carry the canonical + // deprecation reason. + expectedSymbols.push(`${snakeCase(entry.name)}_args`); + } + for (const owner of expectedOwners) { + for (const expectedSymbol of expectedSymbols) { + const expectedCount = + (file === 'types.ts' || file === 'types.dart') && entry.ownerPath === 'RequestPurchaseProps.useAlternativeBilling' ? 2 : 1; + const key = `${owner}.${expectedSymbol}`; + expectedByReason.set( + entry.reason, + mergeAttachmentMultiplicity( + expectedByReason.get(entry.reason) ?? [], + Array.from({ length: expectedCount }, () => key), + ), + ); + } + } + } + + for (const [reason, expectedAttachments] of expectedByReason) { + const actualAttachments = collectDeprecationAttachments(file, source, reason).map( + (attachment) => `${attachment.owner}.${attachment.symbol}`, + ); + expect(actualAttachments.sort(), `${file} must attach "${reason}" only to the canonical generated owners`).toEqual( + expectedAttachments.sort(), + ); + } + } + }); + + it('does not let same-name tags on another owner satisfy ownership', () => { + const source = `export interface ExpectedOwner { + platform: string; +} +export interface WrongOwner { + /** @deprecated Use store instead */ + platform: string; +} +/** @deprecated Use Target instead. */ +export interface TargetCompat {} +export interface Target {}`; + + expect(collectDeprecationAttachments('types.ts', source, 'Use store instead')).toEqual([{ owner: 'WrongOwner', symbol: 'platform' }]); + expect(collectDeprecationAttachments('types.ts', source, 'Use Target instead.')).toEqual([ + { owner: 'TargetCompat', symbol: 'TargetCompat' }, + ]); + expect(declarationSymbol('types.ts', 'export interface TargetCompat {}')).not.toBe('Target'); + }); + + it('rejects correct-owner tags accompanied by same-reason wrong-owner tags', () => { + const source = `export interface ExpectedOwner { + /** @deprecated Use store instead */ + platform: string; +} +export interface WrongOwner { + /** @deprecated Use store instead */ + platform: string; +}`; + const actual = collectDeprecationAttachments('types.ts', source, 'Use store instead').map( + (attachment) => `${attachment.owner}.${attachment.symbol}`, + ); + + expect(actual).toContain('ExpectedOwner.platform'); + expect(actual).toContain('WrongOwner.platform'); + expect(actual.sort()).not.toEqual(['ExpectedOwner.platform']); + }); + + it('preserves the published MAUI 1.x string signatures', () => { + const csharp = generated('Types.cs'); + + for (const method of ['DeepLinkToSubscriptions', 'FinishTransaction', 'RestorePurchases']) { expect(csharp).toContain(`Task ${method}Async(`); expect(csharp).not.toContain(`Task ${method}Async(`); } }); - it("keeps new user-choice details optional outside Kotlin", () => { - expect(generated("types.ts")).toContain( - "productDetailsAndroid?: (DeveloperProvidedBillingProductAndroid[] | null);", - ); - expect(generated("types.dart")).toContain( - "final List? productDetailsAndroid;", - ); - expect(generated("Types.swift")).toContain( - "public var productDetailsAndroid: [DeveloperProvidedBillingProductAndroid]? = nil", - ); - expect(generated("Types.cs")).toContain( - "public IReadOnlyList? ProductDetailsAndroid { get; init; }", + it('keeps new user-choice details optional outside Kotlin', () => { + expect(generated('types.ts')).toContain('productDetailsAndroid?: (DeveloperProvidedBillingProductAndroid[] | null);'); + expect(generated('types.dart')).toContain('final List? productDetailsAndroid;'); + expect(generated('Types.swift')).toContain('public var productDetailsAndroid: [DeveloperProvidedBillingProductAndroid]? = nil'); + expect(generated('Types.cs')).toContain( + 'public IReadOnlyList? ProductDetailsAndroid { get; init; }', ); }); - it("keeps additive Kotlin fields out of published data-class constructors", () => { - const kotlin = generated("Types.kt"); + it('keeps additive Kotlin fields out of published data-class constructors', () => { + const kotlin = generated('Types.kt'); const userChoice = kotlin.slice( - kotlin.indexOf("public data class UserChoiceBillingDetails("), - kotlin.indexOf("public data class ValidTimeWindowAndroid("), + kotlin.indexOf('public data class UserChoiceBillingDetails('), + kotlin.indexOf('public data class ValidTimeWindowAndroid('), ); const purchaseError = kotlin.slice( - kotlin.indexOf("public data class PurchaseError("), - kotlin.indexOf("public data class PurchaseIOS("), + kotlin.indexOf('public data class PurchaseError('), + kotlin.indexOf('public data class PurchaseIOS('), ); const iapkitResult = kotlin.slice( - kotlin.indexOf("public data class RequestVerifyPurchaseWithIapkitResult("), - kotlin.indexOf("public data class SubscriptionCommitmentInfoIOS("), + kotlin.indexOf('public data class RequestVerifyPurchaseWithIapkitResult('), + kotlin.indexOf('public data class SubscriptionCommitmentInfoIOS('), ); const iapkitProps = kotlin.slice( - kotlin.indexOf("public data class RequestVerifyPurchaseWithIapkitProps("), - kotlin.indexOf("public data class SubscriptionProductReplacementParamsAndroid("), - ); - const withoutDocComments = (value: string) => - value.replace(/\/\*\*[\s\S]*?\*\//g, "").replace(/\s+/g, " "); - const iapkitResultPrimary = withoutDocComments( - iapkitResult.slice(0, iapkitResult.indexOf(") {") + 3), - ); - const iapkitPropsPrimary = withoutDocComments( - iapkitProps.slice(0, iapkitProps.indexOf(") {") + 3), + kotlin.indexOf('public data class RequestVerifyPurchaseWithIapkitProps('), + kotlin.indexOf('public data class SubscriptionProductReplacementParamsAndroid('), ); + const withoutDocComments = (value: string) => value.replace(/\/\*\*[\s\S]*?\*\//g, '').replace(/\s+/g, ' '); + const iapkitResultPrimary = withoutDocComments(iapkitResult.slice(0, iapkitResult.indexOf(') {') + 3)); + const iapkitPropsPrimary = withoutDocComments(iapkitProps.slice(0, iapkitProps.indexOf(') {') + 3)); - expect(userChoice).toContain("val externalTransactionToken: String,"); - expect(userChoice).toContain("val products: List"); - expect(userChoice).toContain( - "var productDetailsAndroid: List? = null", - ); - expect(purchaseError).toContain( - "var subResponseCodeAndroid: SubResponseCodeAndroid? = null", - ); - expect(userChoice).toContain("private set"); - expect(purchaseError).toContain("private set"); + expect(userChoice).toContain('val externalTransactionToken: String,'); + expect(userChoice).toContain('val products: List'); + expect(userChoice).toContain('var productDetailsAndroid: List? = null'); + expect(purchaseError).toContain('var subResponseCodeAndroid: SubResponseCodeAndroid? = null'); + expect(userChoice).toContain('private set'); + expect(purchaseError).toContain('private set'); expect(iapkitResultPrimary).toContain( - "public data class RequestVerifyPurchaseWithIapkitResult( val isValid: Boolean, val state: IapkitPurchaseState, val store: IapStore ) {", + 'public data class RequestVerifyPurchaseWithIapkitResult( val isValid: Boolean, val state: IapkitPurchaseState, val store: IapStore ) {', ); - expect(iapkitResult).toContain( - "var clientPayload: IapkitProductClientPayload? = null", - ); - expect(iapkitResult).toContain("var productId: String? = null"); - expect(iapkitResultPrimary).not.toContain("clientPayload"); - expect(iapkitResultPrimary).not.toContain("productId"); + expect(iapkitResult).toContain('var clientPayload: IapkitProductClientPayload? = null'); + expect(iapkitResult).toContain('var productId: String? = null'); + expect(iapkitResultPrimary).not.toContain('clientPayload'); + expect(iapkitResultPrimary).not.toContain('productId'); expect(iapkitPropsPrimary).toContain( - "public data class RequestVerifyPurchaseWithIapkitProps( val amazon: RequestVerifyPurchaseWithIapkitAmazonProps? = null, val apiKey: String? = null, val apple: RequestVerifyPurchaseWithIapkitAppleProps? = null, val baseUrl: String? = null, val google: RequestVerifyPurchaseWithIapkitGoogleProps? = null ) {", + 'public data class RequestVerifyPurchaseWithIapkitProps( val amazon: RequestVerifyPurchaseWithIapkitAmazonProps? = null, val apiKey: String? = null, val apple: RequestVerifyPurchaseWithIapkitAppleProps? = null, val baseUrl: String? = null, val google: RequestVerifyPurchaseWithIapkitGoogleProps? = null ) {', + ); + expect(iapkitProps).toContain('var includeClientPayload: Boolean? = null'); + expect(iapkitResult).toContain('private set'); + expect(iapkitProps).toContain('private set'); + expect(iapkitPropsPrimary).not.toContain('includeClientPayload'); + }); + + it('preserves the published TypeScript webhook union order', () => { + const typescript = generated('types.ts'); + + expect(typescript).toContain( + "export type SubscriptionState = 'active' | 'expired' | 'in-billing-retry' | 'in-grace-period' | 'paused' | 'refunded' | 'revoked' | 'unknown';", ); - expect(iapkitProps).toContain( - "var includeClientPayload: Boolean? = null", + expect(typescript).toContain( + "export type WebhookCancellationReason = 'billing-error' | 'other' | 'price-increase-declined' | 'product-unavailable' | 'refunded' | 'user-canceled';", ); - expect(iapkitResult).toContain("private set"); - expect(iapkitProps).toContain("private set"); - expect(iapkitPropsPrimary).not.toContain("includeClientPayload"); + expect(typescript).toContain( + "export type WebhookEventType = 'purchase-consumption-request' | 'purchase-refunded' | 'subscription-canceled' | 'subscription-expired' | 'subscription-in-billing-retry' | 'subscription-in-grace-period' | 'subscription-paused' | 'subscription-price-change' | 'subscription-product-changed' | 'subscription-recovered' | 'subscription-renewed' | 'subscription-resumed' | 'subscription-revoked' | 'subscription-started' | 'subscription-uncanceled' | 'test-notification';", + ); + }); + + it('preserves schema prose in custom purchase and discount generators', () => { + const swift = generated('Types.swift'); + const kotlin = generated('Types.kt'); + const dart = generated('types.dart'); + const csharp = generated('Types.cs'); + + for (const fieldDescription of [ + 'Discount identifier', + 'Key identifier for validation', + 'Cryptographic nonce', + 'Signature for validation', + 'Timestamp of discount offer', + ]) { + expect(swift).toContain(`/// ${fieldDescription}`); + } + + for (const source of [swift, kotlin, csharp]) { + expect(source).toContain('Explicit purchase type hint (defaults to in-app)'); + expect(source).toContain('Per-platform purchase request props'); + expect(source).toContain('Per-platform subscription request props'); + } + expect(dart).toContain('Per-platform purchase request props'); + expect(dart).toContain('Per-platform subscription request props'); }); }); diff --git a/packages/gql/src/generated-doc-comments.test.mjs b/packages/gql/src/generated-doc-comments.test.mjs index f68a4e630..4e4e5b0b7 100644 --- a/packages/gql/src/generated-doc-comments.test.mjs +++ b/packages/gql/src/generated-doc-comments.test.mjs @@ -1,23 +1,113 @@ import { describe, expect, it } from 'vitest'; -import { dedupeDeprecatedJSDocTags } from '../scripts/generated-doc-comments.mjs'; +import { injectPropertyDeprecationJSDoc, injectTypeDeprecationJSDoc, operationArgsOwnerNames } from '../scripts/generated-doc-comments.mjs'; describe('generated TypeScript documentation comments', () => { - it('deduplicates tag lines without treating prose mentions as tags', () => { + it('injects canonical type reasons into existing and missing JSDoc blocks', () => { const source = `/** - * Explains the @deprecated directive in prose. - * @deprecated Keep the detailed reason. - * @deprecated Drop the shorter duplicate. + * Legacy offer. */ -export interface Legacy {} +export interface LegacyOffer {} -/** @deprecated Keep this single-line tag. */ -export interface OtherLegacy {}`; +export type OtherLegacy = { + id: string; +}; - const result = dedupeDeprecatedJSDocTags(source); +export enum LegacyMode { + Old = 'old', +}`; - expect(result).toContain('Explains the @deprecated directive in prose.'); - expect(result).toContain('@deprecated Keep the detailed reason.'); - expect(result).not.toContain('@deprecated Drop the shorter duplicate.'); - expect(result).toContain('/** @deprecated Keep this single-line tag. */'); + const result = injectTypeDeprecationJSDoc( + source, + new Map([ + ['LegacyOffer', 'Use SubscriptionOffer instead.'], + ['OtherLegacy', 'Use DiscountOffer instead.'], + ['LegacyMode', 'Use BillingProgram instead.'], + ]), + ); + + expect(result).toContain('* Legacy offer.\n * @deprecated Use SubscriptionOffer instead.'); + expect(result).toContain('/**\n * @deprecated Use DiscountOffer instead.\n */\nexport type OtherLegacy'); + expect(result).toContain('/**\n * @deprecated Use BillingProgram instead.\n */\nexport enum LegacyMode'); + }); + + it('fails closed for duplicate tags and missing declarations', () => { + expect(() => + injectTypeDeprecationJSDoc( + `/** @deprecated Manual reason. */ +export interface Legacy {}`, + new Map([['Legacy', 'Canonical reason.']]), + ), + ).toThrow('manual @deprecated'); + + expect(() => injectTypeDeprecationJSDoc('export interface Present {}', new Map([['Missing', 'Canonical reason.']]))).toThrow('found 0'); + }); + + it('injects canonical operation argument reasons into generated properties', () => { + const source = `export interface QueryValueArgs { + /** Legacy selector. */ + legacy?: string | null; + modern?: string | null; +} + +export interface MutationRunArgs { + oldMode?: string | null; +}`; + const result = injectPropertyDeprecationJSDoc(source, [ + { + ownerName: 'QueryValueArgs', + propertyName: 'legacy', + reason: 'Use modern instead.', + }, + { + ownerName: 'MutationRunArgs', + propertyName: 'oldMode', + reason: 'Use mode instead.', + }, + ]); + + expect(result).toContain('* Legacy selector.\n * @deprecated Use modern instead.'); + expect(result).toContain('/**\n * @deprecated Use mode instead.\n */\n oldMode?'); + expect(result).not.toContain('@deprecated Use modern instead.\n */\n modern'); + }); + + it('resolves graphql-codegen casing for IOS-suffixed operation args', () => { + const ownerNames = operationArgsOwnerNames('Query', 'currentEntitlementIOS'); + expect(ownerNames).toEqual(['QueryCurrentEntitlementIosArgs', 'QueryCurrentEntitlementIOSArgs']); + + const result = injectPropertyDeprecationJSDoc( + `export interface QueryCurrentEntitlementIosArgs { + sku: string; +}`, + [ + { + ownerNames, + propertyName: 'sku', + reason: 'Use productId instead.', + }, + ], + ); + expect(result).toContain('@deprecated Use productId instead.'); + }); + + it('fails closed for ambiguous operation argument ownership', () => { + expect(() => + injectPropertyDeprecationJSDoc('export interface PresentArgs { value?: string }', [ + { + ownerName: 'MissingArgs', + propertyName: 'value', + reason: 'Use modern instead.', + }, + ]), + ).toThrow('must have exactly one generated TypeScript interface; found 0'); + + expect(() => + injectPropertyDeprecationJSDoc('export interface PresentArgs { value?: string }', [ + { + ownerName: 'PresentArgs', + propertyName: 'missing', + reason: 'Use modern instead.', + }, + ]), + ).toThrow('must have exactly one generated TypeScript property; found 0'); }); }); diff --git a/packages/gql/src/generated-gdscript.test.ts b/packages/gql/src/generated-gdscript.test.ts index 3e3963be6..68835fca7 100644 --- a/packages/gql/src/generated-gdscript.test.ts +++ b/packages/gql/src/generated-gdscript.test.ts @@ -33,12 +33,8 @@ describe('generated GDScript list decoding', () => { 'static func create_billing_program_reporting_details_android_args(program: BillingProgramAndroid, developer_billing_type: Variant = null)', ); expect(generated).toContain('if developer_billing_type != null:'); - expect(generated).toContain( - 'args["program"] = BILLING_PROGRAM_ANDROID_VALUES[program]', - ); - expect(generated).toContain( - 'args["developerBillingType"] = DEVELOPER_BILLING_TYPE_ANDROID_VALUES[developer_billing_type]', - ); + expect(generated).toContain('args["program"] = BILLING_PROGRAM_ANDROID_VALUES[program]'); + expect(generated).toContain('args["developerBillingType"] = DEVELOPER_BILLING_TYPE_ANDROID_VALUES[developer_billing_type]'); }); it('builds typed scalar arrays from JSON arrays', () => { @@ -89,6 +85,7 @@ describe('generated GDScript list decoding', () => { fields: [ { name: 'statuses', + description: 'Status values from the schema.\nPreserves every documentation line.\n@see https://openiap.dev/docs/types', type: { kind: 'list', nullable: false, @@ -117,23 +114,15 @@ describe('generated GDScript list decoding', () => { interfaces: [], unions: [], isResultUnion: false, - isSingleFieldArgs: false, }, ], inputs: [], unions: [], operations: [], - metadata: { - unionWrapperNames: new Set(), - futureFieldNames: new Set(), - platformDefaults: new Map(), - singleFieldObjects: new Map(), - unionMembership: new Map(), - inputsWithRequiredFields: new Set(), - }, }; const source = new GDScriptPlugin({ outputPath: 'types.gd' }).generate(schema); + expect(source).toContain('## Status values from the schema. Preserves every documentation line. @see https://openiap.dev/docs/types'); expect(source).toContain('if data["statuses"] is Array:'); expect(source).toContain('arr.append(TEST_STATUS_FROM_STRING.get(item, TestStatus.UNKNOWN))'); expect(source).toContain('if item is String and STRICT_STATUS_FROM_STRING.has(item):'); diff --git a/packages/gql/src/generated-sync-manifest.test.mjs b/packages/gql/src/generated-sync-manifest.test.mjs new file mode 100644 index 000000000..96f2424cc --- /dev/null +++ b/packages/gql/src/generated-sync-manifest.test.mjs @@ -0,0 +1,418 @@ +import { spawnSync } from "node:child_process"; +import { + chmodSync, + existsSync, + mkdirSync, + mkdtempSync, + readdirSync, + readFileSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { describe, expect, it } from "vitest"; +import { + GENERATED_DRIFT_PATHS, + GENERATED_SYNC_EDGES, + GENERATED_SYNC_MANIFEST, + GQL_GENERATED_SOURCE_DIRECTORY, + GQL_GENERATION_INPUT_PATHS, + generatedSourceFileName, + gqlPackageRelativePath, + isGqlGenerationInputPath, +} from "../generated-sync-manifest.mjs"; + +const repositoryRoot = resolve(import.meta.dirname, "../../.."); +const readRepositoryFile = (path) => + readFileSync(resolve(repositoryRoot, path), "utf8"); + +describe("generated sync manifest", () => { + it("owns every source and target path exactly once", () => { + const sourcePaths = Object.values(GENERATED_SYNC_MANIFEST).map( + (definition) => definition.source, + ); + const targetPaths = GENERATED_SYNC_EDGES.map((edge) => edge.path); + + expect(new Set(sourcePaths).size).toBe(sourcePaths.length); + expect(new Set(targetPaths).size).toBe(targetPaths.length); + expect( + targetPaths.some((targetPath) => sourcePaths.includes(targetPath)), + ).toBe(false); + }); + + it("points only to existing canonical files and synchronized copies", () => { + for (const definition of Object.values(GENERATED_SYNC_MANIFEST)) { + expect( + existsSync(resolve(repositoryRoot, definition.source)), + definition.source, + ).toBe(true); + for (const definitionTarget of Object.values(definition.targets)) { + expect( + existsSync(resolve(repositoryRoot, definitionTarget.path)), + definitionTarget.path, + ).toBe(true); + } + } + }); + + it("owns the exact generated source directory inventory", () => { + const expectedFileNames = Object.entries(GENERATED_SYNC_MANIFEST) + .filter(([, definition]) => definition.generated) + .map(([groupName]) => generatedSourceFileName(groupName)) + .sort(); + const entries = readdirSync( + resolve(repositoryRoot, GQL_GENERATED_SOURCE_DIRECTORY), + { + withFileTypes: true, + }, + ); + + expect(entries.every((entry) => entry.isFile())).toBe(true); + expect(entries.map((entry) => entry.name).sort()).toEqual( + expectedFileNames, + ); + expect(gqlPackageRelativePath(GQL_GENERATED_SOURCE_DIRECTORY)).toBe( + "src/generated", + ); + expect(() => generatedSourceFileName("webhookClient")).toThrow( + "Expected a generated manifest group", + ); + }); + + it("covers every generated source and all synchronized targets in drift checks", () => { + const expected = new Set([ + ...Object.values(GENERATED_SYNC_MANIFEST) + .filter((definition) => definition.generated) + .map((definition) => definition.source), + ...GENERATED_SYNC_EDGES.map((edge) => edge.path), + ]); + + expect(new Set(GENERATED_DRIFT_PATHS)).toEqual(expected); + }); + + it("distinguishes canonical generator inputs from generated outputs", () => { + expect(isGqlGenerationInputPath("packages/gql/src/schema.graphql")).toBe( + true, + ); + expect( + isGqlGenerationInputPath("packages/gql/codegen/core/transformer.ts"), + ).toBe(true); + expect(isGqlGenerationInputPath("packages/gql/src/webhook-client.ts")).toBe( + true, + ); + expect(isGqlGenerationInputPath("package.json")).toBe(true); + expect(isGqlGenerationInputPath("bun.lock")).toBe(true); + expect( + isGqlGenerationInputPath("packages/gql/src/generated/types.ts"), + ).toBe(false); + expect( + isGqlGenerationInputPath("libraries/react-native-iap/src/types.ts"), + ).toBe(false); + }); + + it("keeps generated-source drift ownership on each manifest group", () => { + for (const definition of Object.values(GENERATED_SYNC_MANIFEST)) { + expect(typeof definition.generated, definition.source).toBe("boolean"); + expect( + GENERATED_DRIFT_PATHS.includes(definition.source), + definition.source, + ).toBe(definition.generated); + } + }); + + it("owns the public GQL export paths", () => { + const packageJson = JSON.parse( + readRepositoryFile("packages/gql/package.json"), + ); + const definitions = Object.values(GENERATED_SYNC_MANIFEST); + expect(new Set(definitions.map(({ exportKey }) => exportKey)).size).toBe( + definitions.length, + ); + expect(new Set(Object.keys(packageJson.exports))).toEqual( + new Set(definitions.map(({ exportKey }) => exportKey)), + ); + expect(`./${packageJson.main.replace(/^\.\//, "")}`).toBe( + packageJson.exports["."], + ); + + for (const definition of definitions) { + const packageRelativeSource = definition.source.replace( + /^packages\/gql\//, + "./", + ); + expect( + packageJson.exports[definition.exportKey], + definition.exportKey, + ).toBe(packageRelativeSource); + } + }); + + it("keeps executable sync consumers on the manifest", () => { + for (const path of [ + "packages/gql/scripts/sync-to-platforms.mjs", + "packages/gql/scripts/assert-generated-staged.mjs", + "packages/gql/scripts/verify-generated-sync.mjs", + "packages/gql/codegen.ts", + "packages/gql/codegen/index.ts", + "packages/gql/scripts/fix-generated-types.mjs", + "scripts/audit-docs.ts", + "scripts/audit-non-godot-parity.mjs", + ]) { + expect(readRepositoryFile(path), path).toContain( + "generated-sync-manifest.mjs", + ); + } + + const targetPaths = GENERATED_SYNC_EDGES.map((edge) => edge.path); + for (const path of [ + "scripts/audit-docs.ts", + "scripts/audit-non-godot-parity.mjs", + ]) { + const source = readRepositoryFile(path); + for (const targetPath of targetPaths) { + expect(source, `${path} duplicates ${targetPath}`).not.toContain( + targetPath, + ); + } + } + }); + + it("runs parity unconditionally and checks the whole worktree after synchronization", () => { + const workflow = readRepositoryFile(".github/workflows/ci.yml"); + const parityJob = workflow.slice( + workflow.indexOf(" audit-parity:"), + workflow.indexOf("\n test-gql:"), + ); + const syncIndex = parityJob.indexOf("./scripts/sync-versions.sh"); + const driftIndex = parityJob.indexOf( + "node scripts/assert-clean-worktree.mjs", + ); + const parityIndex = parityJob.indexOf( + "node scripts/audit-non-godot-parity.mjs", + ); + + expect(syncIndex).toBeGreaterThanOrEqual(0); + expect(driftIndex).toBeGreaterThan(syncIndex); + expect(parityIndex).toBeGreaterThan(driftIndex); + expect(parityJob).not.toContain("needs: changes"); + expect(parityJob).not.toContain("needs.changes.outputs.parity"); + expect(parityJob).not.toContain("assert-generated-staged.mjs"); + expect(parityJob).not.toContain( + "node packages/gql/scripts/verify-generated-sync.mjs", + ); + expect(parityJob).toContain( + "node --test scripts/assert-clean-worktree.test.mjs", + ); + expect( + workflow.match(/node scripts\/assert-clean-worktree\.mjs/g), + ).toHaveLength(3); + expect(workflow).not.toContain("git status --porcelain"); + const changesJob = workflow.slice( + workflow.indexOf(" changes:"), + workflow.indexOf("\n audit-parity:"), + ); + expect(changesJob).not.toContain("parity:"); + const parityAudit = readRepositoryFile( + "scripts/audit-non-godot-parity.mjs", + ); + expect(parityAudit).toContain("execFileSync("); + expect(parityAudit).toContain("process.execPath"); + expect(parityAudit).toContain('"--test"'); + expect(parityAudit).toContain( + "packages/gql/scripts/standalone-generated-refreshers.test.mjs", + ); + const syncCheck = parityAudit.slice( + parityAudit.indexOf("function checkGeneratedTypeSync()"), + parityAudit.indexOf("\nfunction checkGqlRuntimeExports()"), + ); + expect(syncCheck).toContain("collectGeneratedSyncDrift(root)"); + expect(syncCheck).not.toContain("expectSameFile("); + }); + + it("keeps root generator inputs on CI and the staged-snapshot helper", () => { + const workflow = readRepositoryFile(".github/workflows/ci.yml"); + const gqlFilter = workflow.slice( + workflow.indexOf(" gql:"), + workflow.indexOf(" android:"), + ); + const [sourceRoot, ...externalInputs] = GQL_GENERATION_INPUT_PATHS; + expect(gqlFilter).toContain(`- '${sourceRoot}/**'`); + for (const input of externalInputs) { + expect(gqlFilter).toContain(`- '${input}'`); + } + + const preCommit = readRepositoryFile(".husky/pre-commit"); + expect(preCommit).toContain( + "assert-generation-inputs-staged.mjs has-staged-inputs", + ); + expect(preCommit).toContain( + "assert-generation-inputs-staged.mjs assert-staged-clean", + ); + expect(preCommit).toContain("node scripts/audit-non-godot-parity.mjs"); + const flutterGate = preCommit.slice( + preCommit.indexOf("# Paths-aware Flutter analyze."), + preCommit.indexOf("# Paths-aware GQL generator gate."), + ); + expect(flutterGate).toContain("unset $(git rev-parse --local-env-vars)"); + expect( + flutterGate.indexOf("unset $(git rev-parse --local-env-vars)"), + ).toBeLessThan(flutterGate.lastIndexOf("\n flutter analyze")); + + const harnessRoot = mkdtempSync(join(tmpdir(), "openiap-flutter-hook-")); + const fakeBin = resolve(harnessRoot, "bin"); + const flutterObservedFile = resolve(harnessRoot, "flutter-env"); + const hookAfterFile = resolve(harnessRoot, "hook-env"); + try { + mkdirSync(fakeBin); + const fakeGit = resolve(fakeBin, "git"); + const fakeFlutter = resolve(fakeBin, "flutter"); + writeFileSync( + fakeGit, + `#!/bin/sh +if [ "$1" = "diff" ]; then + printf '%s\\n' 'libraries/flutter_inapp_purchase/lib/types.dart' +elif [ "$1" = "rev-parse" ] && [ "$2" = "--local-env-vars" ]; then + printf '%s\\n' GIT_INDEX_FILE GIT_DIR +else + exit 91 +fi +`, + ); + writeFileSync( + fakeFlutter, + `#!/bin/sh +[ "$1" = "analyze" ] || exit 92 +[ -z "\${GIT_INDEX_FILE+x}" ] || exit 93 +[ -z "\${GIT_DIR+x}" ] || exit 94 +printf clean > "$FLUTTER_OBSERVED_FILE" +`, + ); + chmodSync(fakeGit, 0o755); + chmodSync(fakeFlutter, 0o755); + + const harness = spawnSync( + "sh", + [ + "-c", + `${flutterGate} +printf '%s|%s' "$GIT_INDEX_FILE" "$GIT_DIR" > "$HOOK_AFTER_FILE" +`, + ], + { + cwd: repositoryRoot, + encoding: "utf8", + env: { + ...process.env, + PATH: `${fakeBin}:${process.env.PATH ?? ""}`, + FLUTTER_OBSERVED_FILE: flutterObservedFile, + HOOK_AFTER_FILE: hookAfterFile, + GIT_INDEX_FILE: "sentinel-index", + GIT_DIR: "sentinel-dir", + }, + }, + ); + expect(harness.status, harness.stderr).toBe(0); + expect(readFileSync(flutterObservedFile, "utf8")).toBe("clean"); + expect(readFileSync(hookAfterFile, "utf8")).toBe( + "sentinel-index|sentinel-dir", + ); + } finally { + rmSync(harnessRoot, { recursive: true, force: true }); + } + expect(preCommit).not.toContain( + "node packages/gql/scripts/verify-generated-sync.mjs", + ); + }); + + it("keeps package entry points on the complete canonical pipeline", () => { + const gqlPackageJson = JSON.parse( + readRepositoryFile("packages/gql/package.json"), + ); + const rootPackageJson = JSON.parse(readRepositoryFile("package.json")); + expect(rootPackageJson.scripts.generate).toBe( + "cd packages/gql && bun run generate", + ); + expect(gqlPackageJson.scripts.generate).toBe( + "bun run generate:ts && bun codegen/index.ts && bun run sync", + ); + + for (const path of [ + "packages/apple/package.json", + "packages/google/package.json", + ]) { + const packageJson = JSON.parse(readRepositoryFile(path)); + expect(packageJson.scripts["generate:types"], path).toBe( + "cd ../gql && bun run generate", + ); + } + + const googleCompatibilityScript = readRepositoryFile( + "packages/google/scripts/generate-types.sh", + ); + expect(googleCompatibilityScript).toContain("bun run generate"); + expect(googleCompatibilityScript).not.toContain("bun run generate:"); + expect(googleCompatibilityScript).not.toMatch(/\bcp\s/); + + const appleStandaloneWorkflow = readRepositoryFile( + "packages/apple/.github/workflows/test.yml", + ); + expect(appleStandaloneWorkflow).toContain( + "test -s Sources/Models/Types.swift", + ); + expect(appleStandaloneWorkflow).not.toContain( + "./scripts/generate-types.sh", + ); + expect( + existsSync( + resolve(repositoryRoot, "packages/apple/scripts/generate-types.sh"), + ), + ).toBe(false); + expect( + existsSync( + resolve( + repositoryRoot, + "packages/google/scripts/post-process-types.sh", + ), + ), + ).toBe(false); + expect( + existsSync( + resolve( + repositoryRoot, + "packages/gql/.github/workflows/generate-types.yml", + ), + ), + ).toBe(false); + expect( + existsSync( + resolve( + repositoryRoot, + "packages/gql/.github/workflows/release-types.yml", + ), + ), + ).toBe(false); + }); + + it("makes every manifest target an executable sync edge", () => { + const declaredTargets = Object.entries(GENERATED_SYNC_MANIFEST).flatMap( + ([groupName, definition]) => + Object.entries(definition.targets).map( + ([targetName, definitionTarget]) => ({ + groupName, + targetName, + source: definition.source, + ...definitionTarget, + }), + ), + ); + + expect(GENERATED_SYNC_EDGES).toEqual(declaredTargets); + expect(new Set(GENERATED_SYNC_EDGES.map((edge) => edge.mode))).toEqual( + new Set(["copy", "google-kotlin", "kmp-kotlin"]), + ); + expect( + readRepositoryFile("packages/gql/scripts/sync-to-platforms.mjs"), + ).toContain("for (const edge of GENERATED_SYNC_EDGES)"); + }); +}); diff --git a/packages/gql/src/generated-sync-verifier.test.mjs b/packages/gql/src/generated-sync-verifier.test.mjs new file mode 100644 index 000000000..04e8cb10c --- /dev/null +++ b/packages/gql/src/generated-sync-verifier.test.mjs @@ -0,0 +1,50 @@ +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, resolve } from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import { GENERATED_SYNC_EDGES } from '../generated-sync-manifest.mjs'; +import { materializeGeneratedSyncEdge } from '../scripts/generated-sync-materializer.mjs'; +import { collectGeneratedSyncDrift } from '../scripts/verify-generated-sync.mjs'; + +const temporaryRoots = []; +afterEach(() => { + for (const root of temporaryRoots.splice(0)) { + rmSync(root, { force: true, recursive: true }); + } +}); + +const write = (root, path, contents) => { + const absolutePath = resolve(root, path); + mkdirSync(dirname(absolutePath), { recursive: true }); + writeFileSync(absolutePath, contents); +}; + +describe('generated sync verifier', () => { + it('materializes and compares every manifest edge, including Godot', () => { + const root = mkdtempSync(resolve(tmpdir(), 'openiap-generated-sync-')); + temporaryRoots.push(root); + const sourceContents = new Map(); + + for (const edge of GENERATED_SYNC_EDGES) { + if (!sourceContents.has(edge.source)) { + const source = edge.mode.endsWith('kotlin') ? '// canonical\npackage openiap\n' : `${edge.groupName} canonical\n`; + sourceContents.set(edge.source, source); + write(root, edge.source, source); + } + write(root, edge.path, materializeGeneratedSyncEdge(edge, sourceContents.get(edge.source))); + } + + expect(collectGeneratedSyncDrift(root)).toEqual([]); + + const godotEdge = GENERATED_SYNC_EDGES.find((edge) => edge.groupName === 'gdscript' && edge.targetName === 'godot'); + expect(godotEdge).toBeDefined(); + write(root, godotEdge.path, `${readFileSync(resolve(root, godotEdge.path), 'utf8')}# drift\n`); + expect(collectGeneratedSyncDrift(root)).toContain(`${godotEdge.path} is not the copy materialization of ${godotEdge.source}`); + }); + + it('fails closed for an unknown manifest mode', () => { + expect(() => materializeGeneratedSyncEdge({ groupName: 'test', targetName: 'test', mode: 'mystery' }, 'source')).toThrow( + 'Unknown sync mode "mystery"', + ); + }); +}); diff --git a/packages/gql/src/generated/Types.cs b/packages/gql/src/generated/Types.cs index b3b1db753..390c0759e 100644 --- a/packages/gql/src/generated/Types.cs +++ b/packages/gql/src/generated/Types.cs @@ -1,6 +1,6 @@ // ============================================================================ // AUTO-GENERATED TYPES — DO NOT EDIT DIRECTLY -// Run `bun run generate` after updating any *.graphql schema file. +// Refresh this file with the generated-types workflow documented for your checkout. // ============================================================================ #nullable enable @@ -19,8 +19,8 @@ namespace OpenIap; ///

      Alternative billing mode for Android /// Controls which billing system is used -/// @deprecated Use enableBillingProgramAndroid with BillingProgramAndroid instead. /// Use USER_CHOICE_BILLING for user choice billing, EXTERNAL_OFFER for alternative only. +/// @deprecated Use enableBillingProgramAndroid with BillingProgramAndroid instead. [JsonConverter(typeof(AlternativeBillingModeAndroidJsonConverter))] public enum AlternativeBillingModeAndroid { @@ -28,11 +28,11 @@ public enum AlternativeBillingModeAndroid None, /// User choice billing - user can select between Google Play or alternative /// Requires Google Play Billing Library 7.0+ - /// @deprecated Use BillingProgramAndroid.USER_CHOICE_BILLING instead + /// @deprecated Use BillingProgramAndroid.USER_CHOICE_BILLING instead. UserChoice, /// Alternative billing only - no Google Play billing option /// Requires Google Play Billing Library 6.2+ - /// @deprecated Use BillingProgramAndroid.EXTERNAL_OFFER instead + /// @deprecated Use BillingProgramAndroid.EXTERNAL_OFFER instead. AlternativeOnly } @@ -453,8 +453,11 @@ public enum ErrorCode RemoteError, NetworkError, ServiceError, + /// @deprecated Use PurchaseVerificationFailed instead ReceiptFailed, + /// @deprecated Use PurchaseVerificationFinished instead ReceiptFinished, + /// @deprecated Use PurchaseVerificationFinishFailed instead ReceiptFinishedFailed, PurchaseVerificationFailed, PurchaseVerificationFinished, @@ -2632,6 +2635,7 @@ public interface PurchaseCommon string Id { get; } IReadOnlyList? Ids { get; } bool IsAutoRenewing { get; } + /// @deprecated Use store instead IapPlatform Platform { get; } string ProductId { get; } PurchaseState PurchaseState { get; } @@ -2716,9 +2720,9 @@ public sealed record ActiveSubscription public required double TransactionDate { get; init; } [JsonPropertyName("transactionId")] public required string TransactionId { get; init; } - /// @deprecated iOS only - use daysUntilExpirationIOS instead. /// Whether the subscription will expire soon (within 7 days). /// Consider using daysUntilExpirationIOS for more precise control. + /// @deprecated iOS only - use daysUntilExpirationIOS instead. [JsonPropertyName("willExpireSoon")] public bool? WillExpireSoon { get; init; } } @@ -2943,8 +2947,8 @@ public sealed record DiscountDisplayInfoAndroid } /// Discount information returned from the store. -/// @deprecated Use the standardized SubscriptionOffer type instead for cross-platform compatibility. /// @see https://openiap.dev/docs/types/subscription-offer +/// @deprecated Use the standardized SubscriptionOffer type instead for cross-platform compatibility. public sealed record DiscountIOS { [JsonPropertyName("identifier")] @@ -3028,7 +3032,9 @@ public sealed record DiscountOffer /// [Android] Rental details if this is a rental offer. [JsonPropertyName("rentalDetailsAndroid")] public RentalDetailsAndroid? RentalDetailsAndroid { get; init; } - /// Type of discount offer + /// Offer category. DiscountOffer currently represents Android one-time product + /// offers and is populated as OneTime. Introductory and Promotional are used by + /// SubscriptionOffer. [JsonPropertyName("type")] public required DiscountOfferType Type { get; init; } /// [Android] Valid time window for the offer. @@ -3038,8 +3044,8 @@ public sealed record DiscountOffer } /// iOS DiscountOffer (output type). -/// @deprecated Use the standardized SubscriptionOffer type instead for cross-platform compatibility. /// @see https://openiap.dev/docs/types/subscription-offer +/// @deprecated Use the standardized SubscriptionOffer type instead for cross-platform compatibility. public sealed record DiscountOfferIOS { /// Discount identifier @@ -3070,8 +3076,8 @@ public sealed record EntitlementIOS } /// External offer availability result (Android) -/// @deprecated Use BillingProgramAvailabilityResultAndroid with isBillingProgramAvailableAsync instead /// Available in Google Play Billing Library 6.2.0+, deprecated in 8.2.0 +/// @deprecated Use BillingProgramAvailabilityResultAndroid with isBillingProgramAvailableAsync instead public sealed record ExternalOfferAvailabilityResultAndroid { /// Whether external offers are available for the user @@ -3080,8 +3086,8 @@ public sealed record ExternalOfferAvailabilityResultAndroid } /// External offer reporting details (Android) -/// @deprecated Use BillingProgramReportingDetailsAndroid with createBillingProgramReportingDetailsAsync instead /// Available in Google Play Billing Library 6.2.0+, deprecated in 8.2.0 +/// @deprecated Use BillingProgramReportingDetailsAndroid with createBillingProgramReportingDetailsAsync instead public sealed record ExternalOfferReportingDetailsAndroid { /// External transaction token for reporting external offer transactions @@ -3311,8 +3317,8 @@ public sealed record ProductAndroid : Product, ProductCommon /// One-time purchase offer details (Android). /// Available in Google Play Billing Library 8.0+ -/// @deprecated Use the standardized DiscountOffer type for Android one-time offers. /// @see https://openiap.dev/docs/types/discount-offer +/// @deprecated Use the standardized DiscountOffer type for Android one-time offers. public sealed record ProductAndroidOneTimePurchaseOfferDetail { /// Discount display information @@ -3425,8 +3431,7 @@ public sealed record ProductSubscriptionAndroid : ProductSubscription, ProductCo public required string NameAndroid { get; init; } /// Legacy nullable compatibility field. Google Play does not populate one-time /// purchase offer details for subscription products. - /// @deprecated One-time offers belong to ProductAndroid.discountOffers; - /// subscriptions use subscriptionOffers. + /// @deprecated One-time offers belong to ProductAndroid.discountOffers; subscriptions use subscriptionOffers. [JsonPropertyName("oneTimePurchaseOfferDetailsAndroid")] public IReadOnlyList? OneTimePurchaseOfferDetailsAndroid { get; init; } [JsonPropertyName("platform")] @@ -3455,8 +3460,8 @@ public sealed record ProductSubscriptionAndroid : ProductSubscription, ProductCo } /// Subscription offer details (Android). -/// @deprecated Use the standardized SubscriptionOffer type instead for cross-platform compatibility. /// @see https://openiap.dev/docs/types/subscription-offer +/// @deprecated Use the standardized SubscriptionOffer type instead for cross-platform compatibility. public sealed record ProductSubscriptionAndroidOfferDetails { [JsonPropertyName("basePlanId")] @@ -3577,6 +3582,7 @@ public sealed record PurchaseAndroid : Purchase, PurchaseCommon /// Available in Google Play Billing Library 5.0+ [JsonPropertyName("pendingPurchaseUpdateAndroid")] public PendingPurchaseUpdateAndroid? PendingPurchaseUpdateAndroid { get; init; } + /// @deprecated Use store instead [JsonPropertyName("platform")] public required IapPlatform Platform { get; init; } [JsonPropertyName("productId")] @@ -3666,6 +3672,7 @@ public sealed record PurchaseIOS : Purchase, PurchaseCommon public string? OriginalTransactionIdentifierIOS { get; init; } [JsonPropertyName("ownershipTypeIOS")] public string? OwnershipTypeIOS { get; init; } + /// @deprecated Use store instead [JsonPropertyName("platform")] public required IapPlatform Platform { get; init; } [JsonPropertyName("productId")] @@ -3936,8 +3943,8 @@ public sealed record SubscriptionOffer } /// iOS subscription offer details. -/// @deprecated Use the standardized SubscriptionOffer type instead for cross-platform compatibility. /// @see https://openiap.dev/docs/types/subscription-offer +/// @deprecated Use the standardized SubscriptionOffer type instead for cross-platform compatibility. public sealed record SubscriptionOfferIOS { [JsonPropertyName("displayPrice")] @@ -4306,8 +4313,8 @@ public sealed record InitConnectionConfig { /// Alternative billing mode for Android /// If not specified, defaults to NONE (standard Google Play billing) - /// @deprecated Use enableBillingProgramAndroid instead. /// Use USER_CHOICE_BILLING for user choice billing, EXTERNAL_OFFER for alternative only. + /// @deprecated Use enableBillingProgramAndroid instead. [JsonPropertyName("alternativeBillingModeAndroid")] public AlternativeBillingModeAndroid? AlternativeBillingModeAndroid { get; init; } /// Enable a specific billing program for Android (7.0+) @@ -4464,15 +4471,20 @@ public sealed record RequestPurchaseIosProps public sealed record RequestPurchaseProps : IJsonOnDeserialized { + /// Per-platform purchase request props [JsonPropertyName("requestPurchase")] public RequestPurchasePropsByPlatforms? RequestPurchase { get; init; } + /// Per-platform subscription request props [JsonPropertyName("requestSubscription")] public RequestSubscriptionPropsByPlatforms? RequestSubscription { get; init; } + /// Explicit purchase type hint (defaults to in-app) [JsonPropertyName("type")] public required ProductQueryType Type { get; init; } + /// This flag only logs debug info and has no effect on the purchase flow. + /// @deprecated Use enableBillingProgramAndroid in InitConnectionConfig instead. [JsonPropertyName("useAlternativeBilling")] public bool? UseAlternativeBilling { get; init; } @@ -4538,7 +4550,7 @@ public sealed record RequestSubscriptionAndroidProps [JsonPropertyName("originalExternalTransactionId")] public string? OriginalExternalTransactionId { get; init; } /// Replacement mode for subscription changes - /// @deprecated Use subscriptionProductReplacementParams instead for item-level replacement (8.1.0+) + /// @deprecated Use subscriptionProductReplacementParams instead for item-level replacement (8.1.0+). [JsonPropertyName("replacementMode")] public int? ReplacementMode { get; init; } /// Subscription offers @@ -4908,10 +4920,8 @@ public interface MutationResolver /// Buy the currently promoted product. /// - /// @deprecated Use promotedProductListenerIOS to receive the productId, - /// then call requestPurchase with that SKU instead. In StoreKit 2, - /// promoted products can be purchased directly via the standard purchase flow. /// See: https://openiap.dev/docs/apis/ios/request-purchase-on-promoted-product-ios + /// @deprecated Use promotedProductListenerIOS to receive the productId, then call requestPurchase with that SKU instead. In StoreKit 2, promoted products can be purchased directly via the standard purchase flow. Task RequestPurchaseOnPromotedProductIOSAsync(); /// Restore non-consumable and active subscription purchases. @@ -4936,6 +4946,7 @@ public interface MutationResolver /// Call this after a deliberate customer interaction before linking out to external purchases. /// Reference: https://developer.apple.com/documentation/storekit/externalpurchasecustomlink/shownotice(type:) /// See: https://openiap.dev/docs/apis/ios/show-external-purchase-custom-link-notice-ios + /// Parameter noticeType: Notice type determining the style of disclosure Task ShowExternalPurchaseCustomLinkNoticeIOSAsync(ExternalPurchaseCustomLinkNoticeTypeIOS noticeType); /// Overlay Play billing in-app messages, such as payment issues or subscription price-change confirmations. @@ -4956,6 +4967,7 @@ public interface MutationResolver /// Deprecated. Validate purchase receipts with the configured providers — use verifyPurchase instead. /// See: https://openiap.dev/docs/features/validation#verify-purchase + /// @deprecated Use verifyPurchase Task ValidateReceiptAsync(VerifyPurchaseProps options); /// Verify a purchase against your own backend. Returns a platform-specific @@ -5018,6 +5030,7 @@ public interface QueryResolver /// Use this token to report transactions made through ExternalPurchaseCustomLink. /// Reference: https://developer.apple.com/documentation/storekit/externalpurchasecustomlink/token(for:) /// See: https://openiap.dev/docs/apis/ios/get-external-purchase-custom-link-token-ios + /// Parameter tokenType: Token type: acquisition (new customers) or services (existing customers) Task GetExternalPurchaseCustomLinkTokenIOSAsync(ExternalPurchaseCustomLinkTokenTypeIOS tokenType); /// List unfinished StoreKit transactions in the queue. @@ -5041,6 +5054,7 @@ public interface QueryResolver /// Deprecated. Get the current App Store storefront ISO 3166-1 alpha-3 country /// code — use cross-platform getStorefront instead. /// See: https://openiap.dev/docs/apis/ios/get-storefront-ios + /// @deprecated Use getStorefront Task GetStorefrontIOSAsync(); /// Return the JWS string for a transaction (StoreKit 2). @@ -5075,6 +5089,7 @@ public interface QueryResolver /// Deprecated. Legacy App Store receipt validation — use verifyPurchase instead. /// See: https://openiap.dev/docs/apis/ios/validate-receipt-ios + /// @deprecated Use verifyPurchase Task ValidateReceiptIOSAsync(VerifyPurchaseProps options); } diff --git a/packages/gql/src/generated/Types.kt b/packages/gql/src/generated/Types.kt index 6c94d8e94..2807943bb 100644 --- a/packages/gql/src/generated/Types.kt +++ b/packages/gql/src/generated/Types.kt @@ -1,6 +1,6 @@ // ============================================================================ // AUTO-GENERATED TYPES — DO NOT EDIT DIRECTLY -// Run `bun run generate` after updating any *.graphql schema file. +// Refresh this file with the generated-types workflow documented for your checkout. // ============================================================================ // Suppress unchecked cast warnings for JSON Map parsing - unavoidable due to Kotlin type erasure @@ -11,8 +11,8 @@ /** * Alternative billing mode for Android * Controls which billing system is used - * @deprecated Use enableBillingProgramAndroid with BillingProgramAndroid instead. * Use USER_CHOICE_BILLING for user choice billing, EXTERNAL_OFFER for alternative only. + * @deprecated Use enableBillingProgramAndroid with BillingProgramAndroid instead. */ public enum class AlternativeBillingModeAndroid(val rawValue: String) { /** @@ -22,13 +22,13 @@ public enum class AlternativeBillingModeAndroid(val rawValue: String) { /** * User choice billing - user can select between Google Play or alternative * Requires Google Play Billing Library 7.0+ - * @deprecated Use BillingProgramAndroid.USER_CHOICE_BILLING instead + * @deprecated Use BillingProgramAndroid.USER_CHOICE_BILLING instead. */ UserChoice("user-choice"), /** * Alternative billing only - no Google Play billing option * Requires Google Play Billing Library 6.2+ - * @deprecated Use BillingProgramAndroid.EXTERNAL_OFFER instead + * @deprecated Use BillingProgramAndroid.EXTERNAL_OFFER instead. */ AlternativeOnly("alternative-only") @@ -291,8 +291,17 @@ public enum class ErrorCode(val rawValue: String) { RemoteError("remote-error"), NetworkError("network-error"), ServiceError("service-error"), + /** + * @deprecated Use PurchaseVerificationFailed instead + */ ReceiptFailed("receipt-failed"), + /** + * @deprecated Use PurchaseVerificationFinished instead + */ ReceiptFinished("receipt-finished"), + /** + * @deprecated Use PurchaseVerificationFinishFailed instead + */ ReceiptFinishedFailed("receipt-finished-failed"), PurchaseVerificationFailed("purchase-verification-failed"), PurchaseVerificationFinished("purchase-verification-finished"), @@ -1598,6 +1607,9 @@ public interface PurchaseCommon { val id: String val ids: List? val isAutoRenewing: Boolean + /** + * @deprecated Use store instead + */ val platform: IapPlatform val productId: String val purchaseState: PurchaseState @@ -1649,9 +1661,9 @@ public data class ActiveSubscription( val transactionDate: Double, val transactionId: String, /** - * @deprecated iOS only - use daysUntilExpirationIOS instead. * Whether the subscription will expire soon (within 7 days). * Consider using daysUntilExpirationIOS for more precise control. + * @deprecated iOS only - use daysUntilExpirationIOS instead. */ val willExpireSoon: Boolean? = null ) { @@ -2202,8 +2214,8 @@ public data class DiscountDisplayInfoAndroid( /** * Discount information returned from the store. - * @deprecated Use the standardized SubscriptionOffer type instead for cross-platform compatibility. * @see https://openiap.dev/docs/types/subscription-offer + * @deprecated Use the standardized SubscriptionOffer type instead for cross-platform compatibility. */ public data class DiscountIOS( val identifier: String, @@ -2323,7 +2335,9 @@ public data class DiscountOffer( */ val rentalDetailsAndroid: RentalDetailsAndroid? = null, /** - * Type of discount offer + * Offer category. DiscountOffer currently represents Android one-time product + * offers and is populated as OneTime. Introductory and Promotional are used by + * SubscriptionOffer. */ val type: DiscountOfferType, /** @@ -2379,8 +2393,8 @@ public data class DiscountOffer( /** * iOS DiscountOffer (output type). - * @deprecated Use the standardized SubscriptionOffer type instead for cross-platform compatibility. * @see https://openiap.dev/docs/types/subscription-offer + * @deprecated Use the standardized SubscriptionOffer type instead for cross-platform compatibility. */ public data class DiscountOfferIOS( /** @@ -2453,8 +2467,8 @@ public data class EntitlementIOS( /** * External offer availability result (Android) - * @deprecated Use BillingProgramAvailabilityResultAndroid with isBillingProgramAvailableAsync instead * Available in Google Play Billing Library 6.2.0+, deprecated in 8.2.0 + * @deprecated Use BillingProgramAvailabilityResultAndroid with isBillingProgramAvailableAsync instead */ public data class ExternalOfferAvailabilityResultAndroid( /** @@ -2479,8 +2493,8 @@ public data class ExternalOfferAvailabilityResultAndroid( /** * External offer reporting details (Android) - * @deprecated Use BillingProgramReportingDetailsAndroid with createBillingProgramReportingDetailsAsync instead * Available in Google Play Billing Library 6.2.0+, deprecated in 8.2.0 + * @deprecated Use BillingProgramReportingDetailsAndroid with createBillingProgramReportingDetailsAsync instead */ public data class ExternalOfferReportingDetailsAndroid( /** @@ -2983,8 +2997,8 @@ public data class ProductAndroid( /** * One-time purchase offer details (Android). * Available in Google Play Billing Library 8.0+ - * @deprecated Use the standardized DiscountOffer type for Android one-time offers. * @see https://openiap.dev/docs/types/discount-offer + * @deprecated Use the standardized DiscountOffer type for Android one-time offers. */ public data class ProductAndroidOneTimePurchaseOfferDetail( /** @@ -3170,8 +3184,7 @@ public data class ProductSubscriptionAndroid( /** * Legacy nullable compatibility field. Google Play does not populate one-time * purchase offer details for subscription products. - * @deprecated One-time offers belong to ProductAndroid.discountOffers; - * subscriptions use subscriptionOffers. + * @deprecated One-time offers belong to ProductAndroid.discountOffers; subscriptions use subscriptionOffers. */ val oneTimePurchaseOfferDetailsAndroid: List? = null, override val platform: IapPlatform = IapPlatform.Android, @@ -3244,8 +3257,8 @@ public data class ProductSubscriptionAndroid( /** * Subscription offer details (Android). - * @deprecated Use the standardized SubscriptionOffer type instead for cross-platform compatibility. * @see https://openiap.dev/docs/types/subscription-offer + * @deprecated Use the standardized SubscriptionOffer type instead for cross-platform compatibility. */ public data class ProductSubscriptionAndroidOfferDetails( val basePlanId: String, @@ -3423,6 +3436,9 @@ public data class PurchaseAndroid( * Available in Google Play Billing Library 5.0+ */ val pendingPurchaseUpdateAndroid: PendingPurchaseUpdateAndroid? = null, + /** + * @deprecated Use store instead + */ override val platform: IapPlatform, override val productId: String, override val purchaseState: PurchaseState, @@ -3594,6 +3610,9 @@ public data class PurchaseIOS( val originalTransactionDateIOS: Double? = null, val originalTransactionIdentifierIOS: String? = null, val ownershipTypeIOS: String? = null, + /** + * @deprecated Use store instead + */ override val platform: IapPlatform, override val productId: String, override val purchaseState: PurchaseState, @@ -4188,8 +4207,8 @@ public data class SubscriptionOffer( /** * iOS subscription offer details. - * @deprecated Use the standardized SubscriptionOffer type instead for cross-platform compatibility. * @see https://openiap.dev/docs/types/subscription-offer + * @deprecated Use the standardized SubscriptionOffer type instead for cross-platform compatibility. */ public data class SubscriptionOfferIOS( val displayPrice: String, @@ -5015,8 +5034,8 @@ public data class InitConnectionConfig( /** * Alternative billing mode for Android * If not specified, defaults to NONE (standard Google Play billing) - * @deprecated Use enableBillingProgramAndroid instead. * Use USER_CHOICE_BILLING for user choice billing, EXTERNAL_OFFER for alternative only. + * @deprecated Use enableBillingProgramAndroid instead. */ val alternativeBillingModeAndroid: AlternativeBillingModeAndroid? = null, /** @@ -5353,7 +5372,14 @@ public data class RequestPurchaseIosProps( public data class RequestPurchaseProps( val request: Request, + /** + * Explicit purchase type hint (defaults to in-app) + */ val type: ProductQueryType, + /** + * This flag only logs debug info and has no effect on the purchase flow. + * @deprecated Use enableBillingProgramAndroid in InitConnectionConfig instead. + */ val useAlternativeBilling: Boolean? = null ) { init { @@ -5402,7 +5428,13 @@ public data class RequestPurchaseProps( } sealed class Request { + /** + * Per-platform purchase request props + */ data class Purchase(val value: RequestPurchasePropsByPlatforms) : Request() + /** + * Per-platform subscription request props + */ data class Subscription(val value: RequestSubscriptionPropsByPlatforms) : Request() } } @@ -5485,7 +5517,7 @@ public data class RequestSubscriptionAndroidProps( val purchaseToken: String? = null, /** * Replacement mode for subscription changes - * @deprecated Use subscriptionProductReplacementParams instead for item-level replacement (8.1.0+) + * @deprecated Use subscriptionProductReplacementParams instead for item-level replacement (8.1.0+). */ val replacementMode: Int? = null, /** @@ -6303,10 +6335,8 @@ public interface MutationResolver { /** * Buy the currently promoted product. * - * @deprecated Use promotedProductListenerIOS to receive the productId, - * then call requestPurchase with that SKU instead. In StoreKit 2, - * promoted products can be purchased directly via the standard purchase flow. * See: https://openiap.dev/docs/apis/ios/request-purchase-on-promoted-product-ios + * @deprecated Use promotedProductListenerIOS to receive the productId, then call requestPurchase with that SKU instead. In StoreKit 2, promoted products can be purchased directly via the standard purchase flow. */ suspend fun requestPurchaseOnPromotedProductIOS(): Boolean /** @@ -6335,6 +6365,7 @@ public interface MutationResolver { * Call this after a deliberate customer interaction before linking out to external purchases. * Reference: https://developer.apple.com/documentation/storekit/externalpurchasecustomlink/shownotice(type:) * See: https://openiap.dev/docs/apis/ios/show-external-purchase-custom-link-notice-ios + * Parameter noticeType: Notice type determining the style of disclosure */ suspend fun showExternalPurchaseCustomLinkNoticeIOS(noticeType: ExternalPurchaseCustomLinkNoticeTypeIOS): ExternalPurchaseCustomLinkNoticeResultIOS /** @@ -6359,6 +6390,7 @@ public interface MutationResolver { /** * Deprecated. Validate purchase receipts with the configured providers — use verifyPurchase instead. * See: https://openiap.dev/docs/features/validation#verify-purchase + * @deprecated Use verifyPurchase */ suspend fun validateReceipt(options: VerifyPurchaseProps): VerifyPurchaseResult /** @@ -6434,6 +6466,7 @@ public interface QueryResolver { * Use this token to report transactions made through ExternalPurchaseCustomLink. * Reference: https://developer.apple.com/documentation/storekit/externalpurchasecustomlink/token(for:) * See: https://openiap.dev/docs/apis/ios/get-external-purchase-custom-link-token-ios + * Parameter tokenType: Token type: acquisition (new customers) or services (existing customers) */ suspend fun getExternalPurchaseCustomLinkTokenIOS(tokenType: ExternalPurchaseCustomLinkTokenTypeIOS): ExternalPurchaseCustomLinkTokenResultIOS /** @@ -6462,6 +6495,7 @@ public interface QueryResolver { * Deprecated. Get the current App Store storefront ISO 3166-1 alpha-3 country * code — use cross-platform getStorefront instead. * See: https://openiap.dev/docs/apis/ios/get-storefront-ios + * @deprecated Use getStorefront */ suspend fun getStorefrontIOS(): String /** @@ -6504,6 +6538,7 @@ public interface QueryResolver { /** * Deprecated. Legacy App Store receipt validation — use verifyPurchase instead. * See: https://openiap.dev/docs/apis/ios/validate-receipt-ios + * @deprecated Use verifyPurchase */ suspend fun validateReceiptIOS(options: VerifyPurchaseProps): VerifyPurchaseResultIOS } diff --git a/packages/gql/src/generated/Types.swift b/packages/gql/src/generated/Types.swift index 9db6f0e54..e781e6760 100644 --- a/packages/gql/src/generated/Types.swift +++ b/packages/gql/src/generated/Types.swift @@ -1,6 +1,6 @@ // ============================================================================ // AUTO-GENERATED TYPES — DO NOT EDIT DIRECTLY -// Run `bun run generate` after updating any *.graphql schema file. +// Refresh this file with the generated-types workflow documented for your checkout. // ============================================================================ import Foundation @@ -9,18 +9,18 @@ import Foundation /// Alternative billing mode for Android /// Controls which billing system is used -/// @deprecated Use enableBillingProgramAndroid with BillingProgramAndroid instead. /// Use USER_CHOICE_BILLING for user choice billing, EXTERNAL_OFFER for alternative only. +/// @deprecated Use enableBillingProgramAndroid with BillingProgramAndroid instead. public enum AlternativeBillingModeAndroid: String, Codable, CaseIterable { /// Standard Google Play billing (default) case none = "none" /// User choice billing - user can select between Google Play or alternative /// Requires Google Play Billing Library 7.0+ - /// @deprecated Use BillingProgramAndroid.USER_CHOICE_BILLING instead + /// @deprecated Use BillingProgramAndroid.USER_CHOICE_BILLING instead. case userChoice = "user-choice" /// Alternative billing only - no Google Play billing option /// Requires Google Play Billing Library 6.2+ - /// @deprecated Use BillingProgramAndroid.EXTERNAL_OFFER instead + /// @deprecated Use BillingProgramAndroid.EXTERNAL_OFFER instead. case alternativeOnly = "alternative-only" } @@ -121,8 +121,11 @@ public enum ErrorCode: String, Codable, CaseIterable { case remoteError = "remote-error" case networkError = "network-error" case serviceError = "service-error" + /// @deprecated Use PurchaseVerificationFailed instead case receiptFailed = "receipt-failed" + /// @deprecated Use PurchaseVerificationFinished instead case receiptFinished = "receipt-finished" + /// @deprecated Use PurchaseVerificationFinishFailed instead case receiptFinishedFailed = "receipt-finished-failed" case purchaseVerificationFailed = "purchase-verification-failed" case purchaseVerificationFinished = "purchase-verification-finished" @@ -636,6 +639,7 @@ public protocol PurchaseCommon: Codable { var id: String { get } var ids: [String]? { get } var isAutoRenewing: Bool { get } + /// @deprecated Use store instead var platform: IapPlatform { get } var productId: String { get } var purchaseState: PurchaseState { get } @@ -672,9 +676,9 @@ public struct ActiveSubscription: Codable { /// Unix timestamp in milliseconds since January 1, 1970 UTC. public var transactionDate: Double public var transactionId: String - /// @deprecated iOS only - use daysUntilExpirationIOS instead. /// Whether the subscription will expire soon (within 7 days). /// Consider using daysUntilExpirationIOS for more precise control. + /// @deprecated iOS only - use daysUntilExpirationIOS instead. public var willExpireSoon: Bool? = nil } @@ -837,8 +841,8 @@ public struct DiscountDisplayInfoAndroid: Codable { } /// Discount information returned from the store. -/// @deprecated Use the standardized SubscriptionOffer type instead for cross-platform compatibility. /// @see https://openiap.dev/docs/types/subscription-offer +/// @deprecated Use the standardized SubscriptionOffer type instead for cross-platform compatibility. public struct DiscountIOS: Codable { public var identifier: String public var localizedPrice: String? = nil @@ -898,7 +902,9 @@ public struct DiscountOffer: Codable { public var purchaseOptionIdAndroid: String? = nil /// [Android] Rental details if this is a rental offer. public var rentalDetailsAndroid: RentalDetailsAndroid? = nil - /// Type of discount offer + /// Offer category. DiscountOffer currently represents Android one-time product + /// offers and is populated as OneTime. Introductory and Promotional are used by + /// SubscriptionOffer. public var type: DiscountOfferType /// [Android] Valid time window for the offer. /// Contains startTimeMillis and endTimeMillis. @@ -906,8 +912,8 @@ public struct DiscountOffer: Codable { } /// iOS DiscountOffer (output type). -/// @deprecated Use the standardized SubscriptionOffer type instead for cross-platform compatibility. /// @see https://openiap.dev/docs/types/subscription-offer +/// @deprecated Use the standardized SubscriptionOffer type instead for cross-platform compatibility. public struct DiscountOfferIOS: Codable { /// Discount identifier public var identifier: String @@ -928,16 +934,16 @@ public struct EntitlementIOS: Codable { } /// External offer availability result (Android) -/// @deprecated Use BillingProgramAvailabilityResultAndroid with isBillingProgramAvailableAsync instead /// Available in Google Play Billing Library 6.2.0+, deprecated in 8.2.0 +/// @deprecated Use BillingProgramAvailabilityResultAndroid with isBillingProgramAvailableAsync instead public struct ExternalOfferAvailabilityResultAndroid: Codable { /// Whether external offers are available for the user public var isAvailable: Bool } /// External offer reporting details (Android) -/// @deprecated Use BillingProgramReportingDetailsAndroid with createBillingProgramReportingDetailsAsync instead /// Available in Google Play Billing Library 6.2.0+, deprecated in 8.2.0 +/// @deprecated Use BillingProgramReportingDetailsAndroid with createBillingProgramReportingDetailsAsync instead public struct ExternalOfferReportingDetailsAndroid: Codable { /// External transaction token for reporting external offer transactions public var externalTransactionToken: String @@ -1104,8 +1110,8 @@ public struct ProductAndroid: Codable, ProductCommon { /// One-time purchase offer details (Android). /// Available in Google Play Billing Library 8.0+ -/// @deprecated Use the standardized DiscountOffer type for Android one-time offers. /// @see https://openiap.dev/docs/types/discount-offer +/// @deprecated Use the standardized DiscountOffer type for Android one-time offers. public struct ProductAndroidOneTimePurchaseOfferDetail: Codable { /// Discount display information /// Only available for discounted offers @@ -1177,8 +1183,7 @@ public struct ProductSubscriptionAndroid: Codable, ProductCommon { public var nameAndroid: String /// Legacy nullable compatibility field. Google Play does not populate one-time /// purchase offer details for subscription products. - /// @deprecated One-time offers belong to ProductAndroid.discountOffers; - /// subscriptions use subscriptionOffers. + /// @deprecated One-time offers belong to ProductAndroid.discountOffers; subscriptions use subscriptionOffers. public var oneTimePurchaseOfferDetailsAndroid: [ProductAndroidOneTimePurchaseOfferDetail]? = nil public var platform: IapPlatform = .android public var price: Double? = nil @@ -1199,8 +1204,8 @@ public struct ProductSubscriptionAndroid: Codable, ProductCommon { } /// Subscription offer details (Android). -/// @deprecated Use the standardized SubscriptionOffer type instead for cross-platform compatibility. /// @see https://openiap.dev/docs/types/subscription-offer +/// @deprecated Use the standardized SubscriptionOffer type instead for cross-platform compatibility. public struct ProductSubscriptionAndroidOfferDetails: Codable { public var basePlanId: String /// Installment plan details for this subscription offer. @@ -1273,6 +1278,7 @@ public struct PurchaseAndroid: Codable, PurchaseCommon { /// Returns null if no pending update exists. /// Available in Google Play Billing Library 5.0+ public var pendingPurchaseUpdateAndroid: PendingPurchaseUpdateAndroid? = nil + /// @deprecated Use store instead public var platform: IapPlatform public var productId: String public var purchaseState: PurchaseState @@ -1323,6 +1329,7 @@ public struct PurchaseIOS: Codable, PurchaseCommon { public var originalTransactionDateIOS: Double? = nil public var originalTransactionIdentifierIOS: String? = nil public var ownershipTypeIOS: String? = nil + /// @deprecated Use store instead public var platform: IapPlatform public var productId: String public var purchaseState: PurchaseState @@ -1508,8 +1515,8 @@ public struct SubscriptionOffer: Codable { } /// iOS subscription offer details. -/// @deprecated Use the standardized SubscriptionOffer type instead for cross-platform compatibility. /// @see https://openiap.dev/docs/types/subscription-offer +/// @deprecated Use the standardized SubscriptionOffer type instead for cross-platform compatibility. public struct SubscriptionOfferIOS: Codable { public var displayPrice: String public var id: String @@ -1761,10 +1768,15 @@ public struct DeveloperBillingOptionParamsAndroid: Codable { } public struct DiscountOfferInputIOS: Codable { + /// Discount identifier public var identifier: String + /// Key identifier for validation public var keyIdentifier: String + /// Cryptographic nonce public var nonce: String + /// Signature for validation public var signature: String + /// Timestamp of discount offer public var timestamp: Double public init(identifier: String, keyIdentifier: String, nonce: String, signature: String, timestamp: Double) { @@ -1850,8 +1862,8 @@ public struct InAppMessageParamsAndroid: Codable { public struct InitConnectionConfig: Codable { /// Alternative billing mode for Android /// If not specified, defaults to NONE (standard Google Play billing) - /// @deprecated Use enableBillingProgramAndroid instead. /// Use USER_CHOICE_BILLING for user choice billing, EXTERNAL_OFFER for alternative only. + /// @deprecated Use enableBillingProgramAndroid instead. public var alternativeBillingModeAndroid: AlternativeBillingModeAndroid? /// Billing Choice renderer configured in Play Console. Available in OpenIAP /// Spec 2.1.0 / openiap-google 2.3.0 (requires Play Billing 9.1.0+). @@ -2059,7 +2071,10 @@ public struct RequestPurchaseIosProps: Codable { public struct RequestPurchaseProps: Codable { public var request: Request + /// Explicit purchase type hint (defaults to in-app) public var type: ProductQueryType + /// This flag only logs debug info and has no effect on the purchase flow. + /// @deprecated Use enableBillingProgramAndroid in InitConnectionConfig instead. public var useAlternativeBilling: Bool? public init(request: Request, type: ProductQueryType? = nil, useAlternativeBilling: Bool? = nil) { @@ -2127,7 +2142,9 @@ public struct RequestPurchaseProps: Codable { } public enum Request { + /// Per-platform purchase request props case purchase(RequestPurchasePropsByPlatforms) + /// Per-platform subscription request props case subscription(RequestSubscriptionPropsByPlatforms) } } @@ -2181,7 +2198,7 @@ public struct RequestSubscriptionAndroidProps: Codable { /// Purchase token for upgrades/downgrades public var purchaseToken: String? /// Replacement mode for subscription changes - /// @deprecated Use subscriptionProductReplacementParams instead for item-level replacement (8.1.0+) + /// @deprecated Use subscriptionProductReplacementParams instead for item-level replacement (8.1.0+). public var replacementMode: Int? /// List of subscription SKUs public var skus: [String] @@ -2770,6 +2787,7 @@ public enum Purchase: Codable, PurchaseCommon { } } + /// @deprecated Use store instead public var platform: IapPlatform { switch self { case let .purchaseAndroid(value): @@ -2943,10 +2961,8 @@ public protocol MutationResolver { func requestPurchase(_ params: RequestPurchaseProps) async throws -> RequestPurchaseResult? /// Buy the currently promoted product. /// - /// @deprecated Use promotedProductListenerIOS to receive the productId, - /// then call requestPurchase with that SKU instead. In StoreKit 2, - /// promoted products can be purchased directly via the standard purchase flow. /// See: https://openiap.dev/docs/apis/ios/request-purchase-on-promoted-product-ios + /// @deprecated Use promotedProductListenerIOS to receive the productId, then call requestPurchase with that SKU instead. In StoreKit 2, promoted products can be purchased directly via the standard purchase flow. func requestPurchaseOnPromotedProductIOS() async throws -> Bool /// Restore non-consumable and active subscription purchases. /// See: https://openiap.dev/docs/apis/restore-purchases @@ -2967,6 +2983,7 @@ public protocol MutationResolver { /// Call this after a deliberate customer interaction before linking out to external purchases. /// Reference: https://developer.apple.com/documentation/storekit/externalpurchasecustomlink/shownotice(type:) /// See: https://openiap.dev/docs/apis/ios/show-external-purchase-custom-link-notice-ios + /// Parameter noticeType: Notice type determining the style of disclosure func showExternalPurchaseCustomLinkNoticeIOS(_ noticeType: ExternalPurchaseCustomLinkNoticeTypeIOS) async throws -> ExternalPurchaseCustomLinkNoticeResultIOS /// Overlay Play billing in-app messages, such as payment issues or subscription price-change confirmations. /// OpenIAP availability: Spec 2.1.0 / openiap-google 2.3.0 @@ -2983,6 +3000,7 @@ public protocol MutationResolver { func syncIOS() async throws -> Bool /// Deprecated. Validate purchase receipts with the configured providers — use verifyPurchase instead. /// See: https://openiap.dev/docs/features/validation#verify-purchase + /// @deprecated Use verifyPurchase func validateReceipt(_ options: VerifyPurchaseProps) async throws -> VerifyPurchaseResult /// Verify a purchase against your own backend. Returns a platform-specific /// variant of VerifyPurchaseResult — VerifyPurchaseResultIOS exposes isValid @@ -3034,6 +3052,7 @@ public protocol QueryResolver { /// Use this token to report transactions made through ExternalPurchaseCustomLink. /// Reference: https://developer.apple.com/documentation/storekit/externalpurchasecustomlink/token(for:) /// See: https://openiap.dev/docs/apis/ios/get-external-purchase-custom-link-token-ios + /// Parameter tokenType: Token type: acquisition (new customers) or services (existing customers) func getExternalPurchaseCustomLinkTokenIOS(_ tokenType: ExternalPurchaseCustomLinkTokenTypeIOS) async throws -> ExternalPurchaseCustomLinkTokenResultIOS /// List unfinished StoreKit transactions in the queue. /// See: https://openiap.dev/docs/apis/ios/get-pending-transactions-ios @@ -3052,6 +3071,7 @@ public protocol QueryResolver { /// Deprecated. Get the current App Store storefront ISO 3166-1 alpha-3 country /// code — use cross-platform getStorefront instead. /// See: https://openiap.dev/docs/apis/ios/get-storefront-ios + /// @deprecated Use getStorefront func getStorefrontIOS() async throws -> String /// Return the JWS string for a transaction (StoreKit 2). /// See: https://openiap.dev/docs/apis/ios/get-transaction-jws-ios @@ -3078,6 +3098,7 @@ public protocol QueryResolver { func subscriptionStatusIOS(_ sku: String) async throws -> [SubscriptionStatusIOS] /// Deprecated. Legacy App Store receipt validation — use verifyPurchase instead. /// See: https://openiap.dev/docs/apis/ios/validate-receipt-ios + /// @deprecated Use verifyPurchase func validateReceiptIOS(_ options: VerifyPurchaseProps) async throws -> VerifyPurchaseResultIOS } diff --git a/packages/gql/src/generated/types.dart b/packages/gql/src/generated/types.dart index 6ec1d075f..61e9f9606 100644 --- a/packages/gql/src/generated/types.dart +++ b/packages/gql/src/generated/types.dart @@ -1,6 +1,6 @@ // ============================================================================ // AUTO-GENERATED TYPES — DO NOT EDIT DIRECTLY -// Run `bun run generate` after updating any *.graphql schema file. +// Refresh this file with the generated-types workflow documented for your checkout. // ============================================================================ // ignore_for_file: unused_element, unused_field @@ -11,18 +11,18 @@ import 'dart:async'; /// Alternative billing mode for Android /// Controls which billing system is used -/// @deprecated Use enableBillingProgramAndroid with BillingProgramAndroid instead. /// Use USER_CHOICE_BILLING for user choice billing, EXTERNAL_OFFER for alternative only. +/// @deprecated Use enableBillingProgramAndroid with BillingProgramAndroid instead. enum AlternativeBillingModeAndroid { /// Standard Google Play billing (default) None('none'), /// User choice billing - user can select between Google Play or alternative /// Requires Google Play Billing Library 7.0+ - /// @deprecated Use BillingProgramAndroid.USER_CHOICE_BILLING instead + /// @deprecated Use BillingProgramAndroid.USER_CHOICE_BILLING instead. UserChoice('user-choice'), /// Alternative billing only - no Google Play billing option /// Requires Google Play Billing Library 6.2+ - /// @deprecated Use BillingProgramAndroid.EXTERNAL_OFFER instead + /// @deprecated Use BillingProgramAndroid.EXTERNAL_OFFER instead. AlternativeOnly('alternative-only'); const AlternativeBillingModeAndroid(this.value); @@ -255,8 +255,11 @@ enum ErrorCode { RemoteError('remote-error'), NetworkError('network-error'), ServiceError('service-error'), + /// @deprecated Use PurchaseVerificationFailed instead ReceiptFailed('receipt-failed'), + /// @deprecated Use PurchaseVerificationFinished instead ReceiptFinished('receipt-finished'), + /// @deprecated Use PurchaseVerificationFinishFailed instead ReceiptFinishedFailed('receipt-finished-failed'), PurchaseVerificationFailed('purchase-verification-failed'), PurchaseVerificationFinished('purchase-verification-finished'), @@ -1398,6 +1401,7 @@ abstract class PurchaseCommon { String get id; List? get ids; bool get isAutoRenewing; + /// @deprecated Use store instead IapPlatform get platform; String get productId; PurchaseState get purchaseState; @@ -1451,9 +1455,9 @@ class ActiveSubscription { /// Unix timestamp in milliseconds since January 1, 1970 UTC. final double transactionDate; final String transactionId; - /// @deprecated iOS only - use daysUntilExpirationIOS instead. /// Whether the subscription will expire soon (within 7 days). /// Consider using daysUntilExpirationIOS for more precise control. + /// @deprecated iOS only - use daysUntilExpirationIOS instead. final bool? willExpireSoon; factory ActiveSubscription.fromJson(Map json) { @@ -1981,8 +1985,8 @@ class DiscountDisplayInfoAndroid { } /// Discount information returned from the store. -/// @deprecated Use the standardized SubscriptionOffer type instead for cross-platform compatibility. /// @see https://openiap.dev/docs/types/subscription-offer +/// @deprecated Use the standardized SubscriptionOffer type instead for cross-platform compatibility. class DiscountIOS { const DiscountIOS({ required this.identifier, @@ -2099,7 +2103,9 @@ class DiscountOffer { final String? purchaseOptionIdAndroid; /// [Android] Rental details if this is a rental offer. final RentalDetailsAndroid? rentalDetailsAndroid; - /// Type of discount offer + /// Offer category. DiscountOffer currently represents Android one-time product + /// offers and is populated as OneTime. Introductory and Promotional are used by + /// SubscriptionOffer. final DiscountOfferType type; /// [Android] Valid time window for the offer. /// Contains startTimeMillis and endTimeMillis. @@ -2150,8 +2156,8 @@ class DiscountOffer { } /// iOS DiscountOffer (output type). -/// @deprecated Use the standardized SubscriptionOffer type instead for cross-platform compatibility. /// @see https://openiap.dev/docs/types/subscription-offer +/// @deprecated Use the standardized SubscriptionOffer type instead for cross-platform compatibility. class DiscountOfferIOS { const DiscountOfferIOS({ required this.identifier, @@ -2224,8 +2230,8 @@ class EntitlementIOS { } /// External offer availability result (Android) -/// @deprecated Use BillingProgramAvailabilityResultAndroid with isBillingProgramAvailableAsync instead /// Available in Google Play Billing Library 6.2.0+, deprecated in 8.2.0 +/// @deprecated Use BillingProgramAvailabilityResultAndroid with isBillingProgramAvailableAsync instead class ExternalOfferAvailabilityResultAndroid { const ExternalOfferAvailabilityResultAndroid({ required this.isAvailable, @@ -2249,8 +2255,8 @@ class ExternalOfferAvailabilityResultAndroid { } /// External offer reporting details (Android) -/// @deprecated Use BillingProgramReportingDetailsAndroid with createBillingProgramReportingDetailsAsync instead /// Available in Google Play Billing Library 6.2.0+, deprecated in 8.2.0 +/// @deprecated Use BillingProgramReportingDetailsAndroid with createBillingProgramReportingDetailsAsync instead class ExternalOfferReportingDetailsAndroid { const ExternalOfferReportingDetailsAndroid({ required this.externalTransactionToken, @@ -2768,8 +2774,8 @@ class ProductAndroid extends Product implements ProductCommon { /// One-time purchase offer details (Android). /// Available in Google Play Billing Library 8.0+ -/// @deprecated Use the standardized DiscountOffer type for Android one-time offers. /// @see https://openiap.dev/docs/types/discount-offer +/// @deprecated Use the standardized DiscountOffer type for Android one-time offers. class ProductAndroidOneTimePurchaseOfferDetail { const ProductAndroidOneTimePurchaseOfferDetail({ this.discountDisplayInfo, @@ -2979,8 +2985,7 @@ class ProductSubscriptionAndroid extends ProductSubscription implements ProductC final String nameAndroid; /// Legacy nullable compatibility field. Google Play does not populate one-time /// purchase offer details for subscription products. - /// @deprecated One-time offers belong to ProductAndroid.discountOffers; - /// subscriptions use subscriptionOffers. + /// @deprecated One-time offers belong to ProductAndroid.discountOffers; subscriptions use subscriptionOffers. final List? oneTimePurchaseOfferDetailsAndroid; final IapPlatform platform; final double? price; @@ -3045,8 +3050,8 @@ class ProductSubscriptionAndroid extends ProductSubscription implements ProductC } /// Subscription offer details (Android). -/// @deprecated Use the standardized SubscriptionOffer type instead for cross-platform compatibility. /// @see https://openiap.dev/docs/types/subscription-offer +/// @deprecated Use the standardized SubscriptionOffer type instead for cross-platform compatibility. class ProductSubscriptionAndroidOfferDetails { const ProductSubscriptionAndroidOfferDetails({ required this.basePlanId, @@ -3270,6 +3275,7 @@ class PurchaseAndroid extends Purchase implements PurchaseCommon { /// Returns null if no pending update exists. /// Available in Google Play Billing Library 5.0+ final PendingPurchaseUpdateAndroid? pendingPurchaseUpdateAndroid; + /// @deprecated Use store instead final IapPlatform platform; final String productId; final PurchaseState purchaseState; @@ -3461,6 +3467,7 @@ class PurchaseIOS extends Purchase implements PurchaseCommon { final double? originalTransactionDateIOS; final String? originalTransactionIdentifierIOS; final String? ownershipTypeIOS; + /// @deprecated Use store instead final IapPlatform platform; final String productId; final PurchaseState purchaseState; @@ -4041,8 +4048,8 @@ class SubscriptionOffer { } /// iOS subscription offer details. -/// @deprecated Use the standardized SubscriptionOffer type instead for cross-platform compatibility. /// @see https://openiap.dev/docs/types/subscription-offer +/// @deprecated Use the standardized SubscriptionOffer type instead for cross-platform compatibility. class SubscriptionOfferIOS { const SubscriptionOfferIOS({ required this.displayPrice, @@ -4871,8 +4878,8 @@ class InitConnectionConfig { /// Alternative billing mode for Android /// If not specified, defaults to NONE (standard Google Play billing) - /// @deprecated Use enableBillingProgramAndroid instead. /// Use USER_CHOICE_BILLING for user choice billing, EXTERNAL_OFFER for alternative only. + /// @deprecated Use enableBillingProgramAndroid instead. final AlternativeBillingModeAndroid? alternativeBillingModeAndroid; /// Billing Choice renderer configured in Play Console. Available in OpenIAP /// Spec 2.1.0 / openiap-google 2.3.0 (requires Play Billing 9.1.0+). @@ -5175,15 +5182,21 @@ class RequestPurchaseIosProps { sealed class RequestPurchaseProps { const RequestPurchaseProps._(); + /// Per-platform purchase request props const factory RequestPurchaseProps.inApp(({ RequestPurchaseIosProps? apple, RequestPurchaseAndroidProps? google, + /// This flag only logs debug info and has no effect on the purchase flow. + /// @deprecated Use enableBillingProgramAndroid in InitConnectionConfig instead. bool? useAlternativeBilling, }) props) = _InAppPurchase; + /// Per-platform subscription request props const factory RequestPurchaseProps.subs(({ RequestSubscriptionIosProps? apple, RequestSubscriptionAndroidProps? google, + /// This flag only logs debug info and has no effect on the purchase flow. + /// @deprecated Use enableBillingProgramAndroid in InitConnectionConfig instead. bool? useAlternativeBilling, }) props) = _SubsPurchase; @@ -5307,7 +5320,7 @@ class RequestSubscriptionAndroidProps { /// Purchase token for upgrades/downgrades final String? purchaseToken; /// Replacement mode for subscription changes - /// @deprecated Use subscriptionProductReplacementParams instead for item-level replacement (8.1.0+) + /// @deprecated Use subscriptionProductReplacementParams instead for item-level replacement (8.1.0+). final int? replacementMode; /// List of subscription SKUs final List skus; @@ -5961,6 +5974,7 @@ sealed class Purchase implements PurchaseCommon { List? get ids; @override bool get isAutoRenewing; + /// @deprecated Use store instead @override IapPlatform get platform; @override @@ -6120,10 +6134,8 @@ abstract class MutationResolver { Future requestPurchase(RequestPurchaseProps params); /// Buy the currently promoted product. /// - /// @deprecated Use promotedProductListenerIOS to receive the productId, - /// then call requestPurchase with that SKU instead. In StoreKit 2, - /// promoted products can be purchased directly via the standard purchase flow. /// See: https://openiap.dev/docs/apis/ios/request-purchase-on-promoted-product-ios + /// @deprecated Use promotedProductListenerIOS to receive the productId, then call requestPurchase with that SKU instead. In StoreKit 2, promoted products can be purchased directly via the standard purchase flow. Future requestPurchaseOnPromotedProductIOS(); /// Restore non-consumable and active subscription purchases. /// See: https://openiap.dev/docs/apis/restore-purchases @@ -6147,6 +6159,7 @@ abstract class MutationResolver { /// Call this after a deliberate customer interaction before linking out to external purchases. /// Reference: https://developer.apple.com/documentation/storekit/externalpurchasecustomlink/shownotice(type:) /// See: https://openiap.dev/docs/apis/ios/show-external-purchase-custom-link-notice-ios + /// Parameter noticeType: Notice type determining the style of disclosure Future showExternalPurchaseCustomLinkNoticeIOS(ExternalPurchaseCustomLinkNoticeTypeIOS noticeType); /// Overlay Play billing in-app messages, such as payment issues or subscription price-change confirmations. /// OpenIAP availability: Spec 2.1.0 / openiap-google 2.3.0 @@ -6165,6 +6178,7 @@ abstract class MutationResolver { Future syncIOS(); /// Deprecated. Validate purchase receipts with the configured providers — use verifyPurchase instead. /// See: https://openiap.dev/docs/features/validation#verify-purchase + /// @deprecated Use verifyPurchase Future validateReceipt({ VerifyPurchaseAppleOptions? apple, VerifyPurchaseGoogleOptions? google, @@ -6238,6 +6252,7 @@ abstract class QueryResolver { /// Use this token to report transactions made through ExternalPurchaseCustomLink. /// Reference: https://developer.apple.com/documentation/storekit/externalpurchasecustomlink/token(for:) /// See: https://openiap.dev/docs/apis/ios/get-external-purchase-custom-link-token-ios + /// Parameter tokenType: Token type: acquisition (new customers) or services (existing customers) Future getExternalPurchaseCustomLinkTokenIOS(ExternalPurchaseCustomLinkTokenTypeIOS tokenType); /// List unfinished StoreKit transactions in the queue. /// See: https://openiap.dev/docs/apis/ios/get-pending-transactions-ios @@ -6256,6 +6271,7 @@ abstract class QueryResolver { /// Deprecated. Get the current App Store storefront ISO 3166-1 alpha-3 country /// code — use cross-platform getStorefront instead. /// See: https://openiap.dev/docs/apis/ios/get-storefront-ios + /// @deprecated Use getStorefront Future getStorefrontIOS(); /// Return the JWS string for a transaction (StoreKit 2). /// See: https://openiap.dev/docs/apis/ios/get-transaction-jws-ios @@ -6282,6 +6298,7 @@ abstract class QueryResolver { Future> subscriptionStatusIOS(String sku); /// Deprecated. Legacy App Store receipt validation — use verifyPurchase instead. /// See: https://openiap.dev/docs/apis/ios/validate-receipt-ios + /// @deprecated Use verifyPurchase Future validateReceiptIOS({ VerifyPurchaseAppleOptions? apple, VerifyPurchaseGoogleOptions? google, diff --git a/packages/gql/src/generated/types.gd b/packages/gql/src/generated/types.gd index e61c34f63..82d263531 100644 --- a/packages/gql/src/generated/types.gd +++ b/packages/gql/src/generated/types.gd @@ -1,8 +1,8 @@ # ============================================================================ # AUTO-GENERATED TYPES — DO NOT EDIT DIRECTLY -# Generated from OpenIAP GraphQL schema (https://openiap.dev) -# Run `bun run generate` to regenerate this file. +# Refresh this file with the generated-types workflow documented for your checkout. # ============================================================================ +# Generated from OpenIAP GraphQL schema (https://openiap.dev) # Usage: const Types = preload("types.gd") # var store: Types.IapStore = Types.IapStore.APPLE # ============================================================================ @@ -11,13 +11,13 @@ # Enums # ============================================================================ -## Alternative billing mode for Android Controls which billing system is used @deprecated Use enableBillingProgramAndroid with BillingProgramAndroid instead. Use USER_CHOICE_BILLING for user choice billing, EXTERNAL_OFFER for alternative only. +## Alternative billing mode for Android Controls which billing system is used Use USER_CHOICE_BILLING for user choice billing, EXTERNAL_OFFER for alternative only. @deprecated Use enableBillingProgramAndroid with BillingProgramAndroid instead. enum AlternativeBillingModeAndroid { ## Standard Google Play billing (default) NONE = 0, - ## User choice billing - user can select between Google Play or alternative Requires Google Play Billing Library 7.0+ @deprecated Use BillingProgramAndroid.USER_CHOICE_BILLING instead + ## User choice billing - user can select between Google Play or alternative Requires Google Play Billing Library 7.0+ @deprecated Use BillingProgramAndroid.USER_CHOICE_BILLING instead. USER_CHOICE = 1, - ## Alternative billing only - no Google Play billing option Requires Google Play Billing Library 6.2+ @deprecated Use BillingProgramAndroid.EXTERNAL_OFFER instead + ## Alternative billing only - no Google Play billing option Requires Google Play Billing Library 6.2+ @deprecated Use BillingProgramAndroid.EXTERNAL_OFFER instead. ALTERNATIVE_ONLY = 2, } @@ -95,8 +95,11 @@ enum ErrorCode { REMOTE_ERROR = 4, NETWORK_ERROR = 5, SERVICE_ERROR = 6, + ## @deprecated Use PurchaseVerificationFailed instead RECEIPT_FAILED = 7, + ## @deprecated Use PurchaseVerificationFinished instead RECEIPT_FINISHED = 8, + ## @deprecated Use PurchaseVerificationFinishFailed instead RECEIPT_FINISHED_FAILED = 9, PURCHASE_VERIFICATION_FAILED = 10, PURCHASE_VERIFICATION_FINISHED = 11, @@ -438,7 +441,7 @@ class ActiveSubscription: var expiration_date_ios: Variant = null var auto_renewing_android: Variant = null var environment_ios: Variant = null - ## @deprecated iOS only - use daysUntilExpirationIOS instead. + ## Whether the subscription will expire soon (within 7 days). Consider using daysUntilExpirationIOS for more precise control. @deprecated iOS only - use daysUntilExpirationIOS instead. var will_expire_soon: Variant = null var days_until_expiration_ios: Variant = null var transaction_id: String = "" @@ -448,9 +451,9 @@ class ActiveSubscription: var base_plan_id_android: Variant = null ## Required for subscription upgrade/downgrade on Android var purchase_token_android: Variant = null - ## The current plan identifier. This is: + ## The current plan identifier. This is: - On Android: the basePlanId (e.g., "premium", "premium-year") - On iOS: the productId (e.g., "com.example.premium_monthly", "com.example.premium_yearly") This provides a unified way to identify which specific plan/tier the user is subscribed to. var current_plan_id: Variant = null - ## Renewal information from StoreKit 2 (iOS only). Contains details about subscription renewal status, + ## Renewal information from StoreKit 2 (iOS only). Contains details about subscription renewal status, pending upgrades/downgrades, and auto-renewal preferences. var renewal_info_ios: RenewalInfoIOS static func from_dict(data: Dictionary) -> ActiveSubscription: @@ -768,9 +771,9 @@ class BillingProgramAvailabilityResultAndroid: var is_available: bool = false ## The billing program that was checked var billing_program: BillingProgramAndroid - ## Billing Choice screen renderer. Populated only for available BILLING_CHOICE results. + ## Billing Choice screen renderer. Populated only for available BILLING_CHOICE results. Available in OpenIAP Spec 2.1.0 / openiap-google 2.3.0. var choice_screen_type: Variant = null - ## Whether external-link payment is available for Billing Choice. + ## Whether external-link payment is available for Billing Choice. Populated only for available BILLING_CHOICE results. Available in OpenIAP Spec 2.1.0 / openiap-google 2.3.0. var is_external_link_available: Variant = null static func from_dict(data: Dictionary) -> BillingProgramAvailabilityResultAndroid: @@ -813,7 +816,7 @@ class BillingProgramAvailabilityResultAndroid: class BillingProgramReportingDetailsAndroid: ## The billing program that the reporting details are associated with var billing_program: BillingProgramAndroid - ## External transaction token used to report transactions made outside of Google Play Billing. + ## External transaction token used to report transactions made outside of Google Play Billing. Do not cache it for a later redirect session. For External Offer, the same token may report multiple purchases made during the session that generated it. var external_transaction_token: String = "" static func from_dict(data: Dictionary) -> BillingProgramReportingDetailsAndroid: @@ -843,7 +846,7 @@ class BillingResultAndroid: var response_code: int = 0 ## Debug message from the billing library var debug_message: Variant = null - ## Sub-response code for more granular error information (8.0+). + ## Sub-response code for more granular error information (8.0+). Provides additional context when responseCode indicates an error. var sub_response_code: Variant = null static func from_dict(data: Dictionary) -> BillingResultAndroid: @@ -874,11 +877,11 @@ class BillingResultAndroid: ## Details provided when user selects developer billing option (Android) Received via DeveloperProvidedBillingListener callback Available in Google Play Billing Library 8.3.0+ class DeveloperProvidedBillingDetailsAndroid: - ## External transaction token used to report transactions made through developer billing. + ## External transaction token used to report transactions made through developer billing. Nullable for flows such as external payments where no token is returned. var external_transaction_token: Variant = null - ## URI to launch for an external-link Billing Choice flow, when provided by + ## URI to launch for an external-link Billing Choice flow, when provided by Google Play. var link_uri: Variant = null - ## Original external transaction ID when replacing a subscription that was + ## Original external transaction ID when replacing a subscription that was purchased through developer billing. var original_external_transaction_id: Variant = null ## Products selected for the developer billing flow. var products: Array[DeveloperProvidedBillingProductAndroid] = [] @@ -979,9 +982,9 @@ class DiscountAmountAndroid: ## Discount display information for one-time purchase offers (Android) Available in Google Play Billing Library 8.0+ class DiscountDisplayInfoAndroid: - ## Percentage discount (e.g., 33 for 33% off) + ## Percentage discount (e.g., 33 for 33% off) Only returned for percentage-based discounts var percentage_discount: Variant = null - ## Absolute discount amount details + ## Absolute discount amount details Only returned for fixed amount discounts var discount_amount: DiscountAmountAndroid static func from_dict(data: Dictionary) -> DiscountDisplayInfoAndroid: @@ -1005,7 +1008,7 @@ class DiscountDisplayInfoAndroid: dict["discountAmount"] = discount_amount return dict -## Discount information returned from the store. @deprecated Use the standardized SubscriptionOffer type instead for cross-platform compatibility. @see https://openiap.dev/docs/types/subscription-offer +## Discount information returned from the store. @see https://openiap.dev/docs/types/subscription-offer @deprecated Use the standardized SubscriptionOffer type instead for cross-platform compatibility. class DiscountIOS: var identifier: String = "" var type: String = "" @@ -1058,7 +1061,7 @@ class DiscountIOS: ## Standardized one-time product discount offer. Provides a platform-neutral OpenIAP shape for Google Play one-time product purchase options and offers. Currently populated only on Android (Google Play Billing 8.0+). iOS does not populate this type. @see https://openiap.dev/docs/types/discount-offer class DiscountOffer: - ## Unique identifier for the offer. + ## Unique identifier for the offer. - iOS: Not applicable (one-time discounts not supported) - Android: offerId from ProductAndroidOneTimePurchaseOfferDetail var id: Variant = null ## Formatted display price string (e.g., "$4.99") var display_price: String = "" @@ -1066,29 +1069,29 @@ class DiscountOffer: var price: float = 0.0 ## Currency code (ISO 4217, e.g., "USD") var currency: String = "" - ## Type of discount offer + ## Offer category. DiscountOffer currently represents Android one-time product offers and is populated as OneTime. Introductory and Promotional are used by SubscriptionOffer. var type: DiscountOfferType - ## [Android] Offer token required for purchase. + ## [Android] Offer token required for purchase. Must be passed to requestPurchase() when purchasing with this offer. var offer_token_android: Variant = null ## [Android] List of tags associated with this offer. var offer_tags_android: Array[String] = [] - ## [Android] Original full price in micro-units before discount. + ## [Android] Original full price in micro-units before discount. Divide by 1,000,000 to get the actual price. Use for displaying strikethrough original price. var full_price_micros_android: Variant = null - ## [Android] Percentage discount (e.g., 33 for 33% off). + ## [Android] Percentage discount (e.g., 33 for 33% off). Only present for percentage-based discounts. var percentage_discount_android: Variant = null - ## [Android] Fixed discount amount in micro-units. + ## [Android] Fixed discount amount in micro-units. Only present for fixed amount discounts. var discount_amount_micros_android: Variant = null ## [Android] Formatted discount amount including its currency sign (e.g., "$5.00"). var formatted_discount_amount_android: Variant = null - ## [Android] Valid time window for the offer. + ## [Android] Valid time window for the offer. Contains startTimeMillis and endTimeMillis. var valid_time_window_android: ValidTimeWindowAndroid - ## [Android] Limited quantity information. + ## [Android] Limited quantity information. Contains maximumQuantity and remainingQuantity. var limited_quantity_info_android: LimitedQuantityInfoAndroid - ## [Android] Pre-order details if this is a pre-order offer. + ## [Android] Pre-order details if this is a pre-order offer. Available in Google Play Billing Library 8.1.0+ var preorder_details_android: PreorderDetailsAndroid ## [Android] Rental details if this is a rental offer. var rental_details_android: RentalDetailsAndroid - ## [Android] Purchase option ID for this offer. + ## [Android] Purchase option ID for this offer. Used to identify which purchase option the user selected. Available in Google Play Billing Library 8.0+ var purchase_option_id_android: Variant = null static func from_dict(data: Dictionary) -> DiscountOffer: @@ -1190,7 +1193,7 @@ class DiscountOffer: dict["purchaseOptionIdAndroid"] = purchase_option_id_android return dict -## iOS DiscountOffer (output type). @deprecated Use the standardized SubscriptionOffer type instead for cross-platform compatibility. @see https://openiap.dev/docs/types/subscription-offer +## iOS DiscountOffer (output type). @see https://openiap.dev/docs/types/subscription-offer @deprecated Use the standardized SubscriptionOffer type instead for cross-platform compatibility. class DiscountOfferIOS: ## Discount identifier var identifier: String = "" @@ -1248,7 +1251,7 @@ class EntitlementIOS: dict["jsonRepresentation"] = json_representation return dict -## External offer availability result (Android) @deprecated Use BillingProgramAvailabilityResultAndroid with isBillingProgramAvailableAsync instead Available in Google Play Billing Library 6.2.0+, deprecated in 8.2.0 +## External offer availability result (Android) Available in Google Play Billing Library 6.2.0+, deprecated in 8.2.0 @deprecated Use BillingProgramAvailabilityResultAndroid with isBillingProgramAvailableAsync instead class ExternalOfferAvailabilityResultAndroid: ## Whether external offers are available for the user var is_available: bool = false @@ -1264,7 +1267,7 @@ class ExternalOfferAvailabilityResultAndroid: dict["isAvailable"] = is_available return dict -## External offer reporting details (Android) @deprecated Use BillingProgramReportingDetailsAndroid with createBillingProgramReportingDetailsAsync instead Available in Google Play Billing Library 6.2.0+, deprecated in 8.2.0 +## External offer reporting details (Android) Available in Google Play Billing Library 6.2.0+, deprecated in 8.2.0 @deprecated Use BillingProgramReportingDetailsAndroid with createBillingProgramReportingDetailsAsync instead class ExternalOfferReportingDetailsAndroid: ## External transaction token for reporting external offer transactions var external_transaction_token: String = "" @@ -1304,7 +1307,7 @@ class ExternalPurchaseCustomLinkNoticeResultIOS: ## Result of requesting an ExternalPurchaseCustomLink token (iOS 18.1+). class ExternalPurchaseCustomLinkTokenResultIOS: - ## The external purchase token string. + ## The external purchase token string. Report this token to Apple's External Purchase Server API. var token: Variant = null ## Optional error message if token retrieval failed var error: Variant = null @@ -1353,7 +1356,7 @@ class ExternalPurchaseNoticeResultIOS: var result: ExternalPurchaseNoticeAction ## Optional error message if the presentation failed var error: Variant = null - ## External purchase token returned when user continues (iOS 17.4+). + ## External purchase token returned when user continues (iOS 17.4+). This token should be reported to Apple's External Purchase Server API. Only present when result is Continue. var external_purchase_token: Variant = null static func from_dict(data: Dictionary) -> ExternalPurchaseNoticeResultIOS: @@ -1447,9 +1450,9 @@ class InAppMessageResultAndroid: ## Installment plan details for subscription offers (Android) Contains information about the installment plan commitment. Available in Google Play Billing Library 7.0+ class InstallmentPlanDetailsAndroid: - ## Committed payments count after a user signs up for this subscription plan. + ## Committed payments count after a user signs up for this subscription plan. For example, for a monthly subscription with commitmentPaymentsCount of 12, users will be charged monthly for 12 months after signup. var commitment_payments_count: int = 0 - ## Subsequent committed payments count after the subscription plan renews. + ## Subsequent committed payments count after the subscription plan renews. For example, for a monthly subscription with subsequentCommitmentPaymentsCount of 12, users will be committed to another 12 monthly payments when the plan renews. Returns 0 if the installment plan has no subsequent commitment (reverts to normal plan). var subsequent_commitment_payments_count: int = 0 static func from_dict(data: Dictionary) -> InstallmentPlanDetailsAndroid: @@ -1489,9 +1492,9 @@ class LimitedQuantityInfoAndroid: ## Pending purchase update for subscription upgrades/downgrades (Android) When a user initiates a subscription change (upgrade/downgrade), the new purchase may be pending until the current billing period ends. This type contains the details of the pending change. Available in Google Play Billing Library 5.0+ class PendingPurchaseUpdateAndroid: - ## Product IDs for the pending purchase update. + ## Product IDs for the pending purchase update. These are the new products the user is switching to. var products: Array[String] = [] - ## Purchase token for the pending transaction. + ## Purchase token for the pending transaction. Use this token to track or manage the pending purchase update. var purchase_token: String = "" static func from_dict(data: Dictionary) -> PendingPurchaseUpdateAndroid: @@ -1515,9 +1518,9 @@ class PendingPurchaseUpdateAndroid: ## Pre-order details for one-time purchase products (Android) Available in Google Play Billing Library 8.1.0+ class PreorderDetailsAndroid: - ## Pre-order presale end time in milliseconds since epoch. + ## Pre-order presale end time in milliseconds since epoch. This is when the presale period ends and the product will be released. var preorder_presale_end_time_millis: String = "" - ## Pre-order release time in milliseconds since epoch. + ## Pre-order release time in milliseconds since epoch. This is when the product will be available to users who pre-ordered. var preorder_release_time_millis: String = "" static func from_dict(data: Dictionary) -> PreorderDetailsAndroid: @@ -1602,21 +1605,21 @@ class ProductAndroid: var id: String = "" var title: String = "" var description: String = "" - var type: ProductType + var type: ProductType = ProductType.IN_APP var display_name: Variant = null var display_price: String = "" var currency: String = "" var price: Variant = null var debug_description: Variant = null - var platform: IapPlatform + var platform: IapPlatform = IapPlatform.ANDROID var name_android: String = "" - ## Product-level status code indicating fetch result (Android 8.0+) + ## Product-level status code indicating fetch result (Android 8.0+) OK = product fetched successfully NOT_FOUND = SKU doesn't exist NO_OFFERS_AVAILABLE = user not eligible for any offers Available in Google Play Billing Library 8.0.0+ var product_status_android: Variant = null - ## Standardized Android one-time product purchase options and offers. + ## Standardized Android one-time product purchase options and offers. Native metadata uses Android-suffixed fields. @see https://openiap.dev/docs/types/discount-offer var discount_offers: Array[DiscountOffer] = [] - ## Standardized subscription offers. + ## Standardized subscription offers. Cross-platform type with Android-specific fields using suffix. @see https://openiap.dev/docs/types/subscription-offer var subscription_offers: Array[SubscriptionOffer] = [] - ## One-time purchase offer details including discounts (Android) + ## One-time purchase offer details including discounts (Android) Returns all eligible offers. Available in Google Play Billing Library 8.0+ @deprecated Use the standardized discountOffers field instead. var one_time_purchase_offer_details_android: Array[ProductAndroidOneTimePurchaseOfferDetail] = [] ## @deprecated Use subscriptionOffers instead for cross-platform compatibility. var subscription_offer_details_android: Array[ProductSubscriptionAndroidOfferDetails] = [] @@ -1766,7 +1769,7 @@ class ProductAndroid: dict["subscriptionOfferDetailsAndroid"] = null return dict -## One-time purchase offer details (Android). Available in Google Play Billing Library 8.0+ @deprecated Use the standardized DiscountOffer type for Android one-time offers. @see https://openiap.dev/docs/types/discount-offer +## One-time purchase offer details (Android). Available in Google Play Billing Library 8.0+ @see https://openiap.dev/docs/types/discount-offer @deprecated Use the standardized DiscountOffer type for Android one-time offers. class ProductAndroidOneTimePurchaseOfferDetail: ## Offer ID var offer_id: Variant = null @@ -1777,19 +1780,19 @@ class ProductAndroidOneTimePurchaseOfferDetail: var price_currency_code: String = "" var formatted_price: String = "" var price_amount_micros: String = "" - ## Full (non-discounted) price in micro-units + ## Full (non-discounted) price in micro-units Only available for discounted offers var full_price_micros: Variant = null - ## Discount display information + ## Discount display information Only available for discounted offers var discount_display_info: DiscountDisplayInfoAndroid ## Valid time window for the offer var valid_time_window: ValidTimeWindowAndroid ## Limited quantity information var limited_quantity_info: LimitedQuantityInfoAndroid - ## Pre-order details for products available for pre-order + ## Pre-order details for products available for pre-order Available in Google Play Billing Library 8.1.0+ var preorder_details_android: PreorderDetailsAndroid ## Rental details for rental offers var rental_details_android: RentalDetailsAndroid - ## Purchase option ID for this offer (Android) + ## Purchase option ID for this offer (Android) Used to identify which purchase option the user selected. Available in Google Play Billing Library 8.0+ var purchase_option_id: Variant = null static func from_dict(data: Dictionary) -> ProductAndroidOneTimePurchaseOfferDetail: @@ -1881,20 +1884,20 @@ class ProductIOS: var id: String = "" var title: String = "" var description: String = "" - var type: ProductType + var type: ProductType = ProductType.IN_APP var display_name: Variant = null var display_price: String = "" var currency: String = "" var price: Variant = null var debug_description: Variant = null - var platform: IapPlatform + var platform: IapPlatform = IapPlatform.IOS var display_name_ios: String = "" var is_family_shareable_ios: bool = false var json_representation_ios: String = "" var type_ios: ProductTypeIOS - ## Standardized subscription offers. + ## Standardized subscription offers. Cross-platform type with iOS-specific fields using suffix. Note: iOS does not support one-time product discounts. @see https://openiap.dev/docs/types/subscription-offer var subscription_offers: Array[SubscriptionOffer] = [] - ## iOS 26.4+ subscription pricing terms, including billing plan metadata for + ## iOS 26.4+ subscription pricing terms, including billing plan metadata for monthly subscriptions with a 12-month commitment. var pricing_terms_ios: Array[SubscriptionPricingTermsIOS] = [] ## @deprecated Use subscriptionOffers instead for cross-platform compatibility. var subscription_info_ios: SubscriptionInfoIOS @@ -2024,21 +2027,21 @@ class ProductSubscriptionAndroid: var id: String = "" var title: String = "" var description: String = "" - var type: ProductType + var type: ProductType = ProductType.SUBS var display_name: Variant = null var display_price: String = "" var currency: String = "" var price: Variant = null var debug_description: Variant = null - var platform: IapPlatform + var platform: IapPlatform = IapPlatform.ANDROID var name_android: String = "" - ## Product-level status code indicating fetch result (Android 8.0+) + ## Product-level status code indicating fetch result (Android 8.0+) OK = product fetched successfully NOT_FOUND = SKU doesn't exist NO_OFFERS_AVAILABLE = user not eligible for any offers Available in Google Play Billing Library 8.0.0+ var product_status_android: Variant = null - ## Nullable compatibility field. Google Play does not return one-time purchase + ## Nullable compatibility field. Google Play does not return one-time purchase offer details for subscription products; use subscriptionOffers below. var discount_offers: Array[DiscountOffer] = [] - ## Standardized subscription offers. + ## Standardized subscription offers. Cross-platform type with Android-specific fields using suffix. @see https://openiap.dev/docs/types/subscription-offer var subscription_offers: Array[SubscriptionOffer] = [] - ## Legacy nullable compatibility field. Google Play does not populate one-time + ## Legacy nullable compatibility field. Google Play does not populate one-time purchase offer details for subscription products. @deprecated One-time offers belong to ProductAndroid.discountOffers; subscriptions use subscriptionOffers. var one_time_purchase_offer_details_android: Array[ProductAndroidOneTimePurchaseOfferDetail] = [] ## @deprecated Use subscriptionOffers instead for cross-platform compatibility. var subscription_offer_details_android: Array[ProductSubscriptionAndroidOfferDetails] = [] @@ -2188,14 +2191,14 @@ class ProductSubscriptionAndroid: dict["subscriptionOfferDetailsAndroid"] = null return dict -## Subscription offer details (Android). @deprecated Use the standardized SubscriptionOffer type instead for cross-platform compatibility. @see https://openiap.dev/docs/types/subscription-offer +## Subscription offer details (Android). @see https://openiap.dev/docs/types/subscription-offer @deprecated Use the standardized SubscriptionOffer type instead for cross-platform compatibility. class ProductSubscriptionAndroidOfferDetails: var base_plan_id: String = "" var offer_id: Variant = null var offer_token: String = "" var offer_tags: Array[String] = [] var pricing_phases: PricingPhasesAndroid - ## Installment plan details for this subscription offer. + ## Installment plan details for this subscription offer. Only set for installment subscription plans; null for non-installment plans. Available in Google Play Billing Library 7.0+ var installment_plan_details: InstallmentPlanDetailsAndroid static func from_dict(data: Dictionary) -> ProductSubscriptionAndroidOfferDetails: @@ -2246,20 +2249,20 @@ class ProductSubscriptionIOS: var id: String = "" var title: String = "" var description: String = "" - var type: ProductType + var type: ProductType = ProductType.SUBS var display_name: Variant = null var display_price: String = "" var currency: String = "" var price: Variant = null var debug_description: Variant = null - var platform: IapPlatform + var platform: IapPlatform = IapPlatform.IOS var display_name_ios: String = "" var is_family_shareable_ios: bool = false var json_representation_ios: String = "" var type_ios: ProductTypeIOS - ## Standardized subscription offers. + ## Standardized subscription offers. Cross-platform type with iOS-specific fields using suffix. @see https://openiap.dev/docs/types/subscription-offer var subscription_offers: Array[SubscriptionOffer] = [] - ## iOS 26.4+ subscription pricing terms, including billing plan metadata for + ## iOS 26.4+ subscription pricing terms, including billing plan metadata for monthly subscriptions with a 12-month commitment. var pricing_terms_ios: Array[SubscriptionPricingTermsIOS] = [] ## App Store subscription group identifier for intro-offer eligibility checks. var subscription_group_id_ios: Variant = null @@ -2477,6 +2480,7 @@ class PurchaseAndroid: var purchase_token: Variant = null ## Store where purchase was made var store: IapStore + ## @deprecated Use store instead var platform: IapPlatform var quantity: int = 0 var purchase_state: PurchaseState @@ -2490,9 +2494,9 @@ class PurchaseAndroid: var developer_payload_android: Variant = null var obfuscated_account_id_android: Variant = null var obfuscated_profile_id_android: Variant = null - ## Whether the subscription is suspended (Android) + ## Whether the subscription is suspended (Android) A suspended subscription means the user's payment method failed and they need to fix it. Users should be directed to the subscription center to resolve the issue. Do NOT grant entitlements for suspended subscriptions. Available in Google Play Billing Library 8.1.0+ var is_suspended_android: Variant = null - ## Pending purchase update for uncommitted subscription upgrade/downgrade (Android) + ## Pending purchase update for uncommitted subscription upgrade/downgrade (Android) Contains the new products and purchase token for the pending transaction. Returns null if no pending update exists. Available in Google Play Billing Library 5.0+ var pending_purchase_update_android: PendingPurchaseUpdateAndroid static func from_dict(data: Dictionary) -> PurchaseAndroid: @@ -2693,6 +2697,7 @@ class PurchaseIOS: var purchase_token: Variant = null ## Store where purchase was made var store: IapStore + ## @deprecated Use store instead var platform: IapPlatform var quantity: int = 0 var purchase_state: PurchaseState @@ -2725,7 +2730,7 @@ class PurchaseIOS: var billing_plan_type_ios: Variant = null ## iOS 26.4+ progress information for monthly subscriptions with a 12-month commitment. var commitment_info_ios: TransactionCommitmentInfoIOS - ## Advanced Commerce API metadata (iOS 18.4+). + ## Advanced Commerce API metadata (iOS 18.4+). Present only for transactions that use the Advanced Commerce API. Contains item details, tax information, and refund data for generic SKU purchases. var advanced_commerce_info_ios: AdvancedCommerceInfoIOS static func from_dict(data: Dictionary) -> PurchaseIOS: @@ -3010,25 +3015,25 @@ class RenewalInfoIOS: var json_representation: Variant = null var will_auto_renew: bool = false var auto_renew_preference: Variant = null - ## When subscription expires due to cancellation/billing issue + ## When subscription expires due to cancellation/billing issue Possible values: "VOLUNTARY", "BILLING_ERROR", "DID_NOT_AGREE_TO_PRICE_INCREASE", "PRODUCT_NOT_AVAILABLE", "UNKNOWN" var expiration_reason: Variant = null - ## Grace period expiration date (milliseconds since epoch) + ## Grace period expiration date (milliseconds since epoch) When set, subscription is in grace period (billing issue but still has access) var grace_period_expiration_date: Variant = null - ## True if subscription failed to renew due to billing issue and is retrying + ## True if subscription failed to renew due to billing issue and is retrying StoreKit exposes this directly as RenewalInfo.isInBillingRetry. var is_in_billing_retry: Variant = null - ## Product ID that will be used on next renewal (when user upgrades/downgrades) + ## Product ID that will be used on next renewal (when user upgrades/downgrades) If set and different from current productId, subscription will change on expiration var pending_upgrade_product_id: Variant = null - ## User's response to subscription price increase + ## User's response to subscription price increase Possible values: "AGREED", "PENDING", null (no price increase) var price_increase_status: Variant = null - ## Expected renewal date (milliseconds since epoch) + ## Expected renewal date (milliseconds since epoch) For active subscriptions, when the next renewal/charge will occur var renewal_date: Variant = null ## Offer ID applied to next renewal (promotional offer, subscription offer code, etc.) var renewal_offer_id: Variant = null - ## Type of offer applied to next renewal + ## Type of offer applied to next renewal Possible values: "PROMOTIONAL", "SUBSCRIPTION_OFFER_CODE", "WIN_BACK", etc. var renewal_offer_type: Variant = null ## iOS 26.4+ billing plan that will renew after the current period. var renewal_billing_plan_type: Variant = null - ## iOS 26.4+ renewal commitment metadata for monthly subscriptions with a + ## iOS 26.4+ renewal commitment metadata for monthly subscriptions with a 12-month commitment. var commitment_info: RenewalCommitmentInfoIOS static func from_dict(data: Dictionary) -> RenewalInfoIOS: @@ -3106,7 +3111,7 @@ class RenewalInfoIOS: class RentalDetailsAndroid: ## Rental period in ISO 8601 format (e.g., P7D for 7 days) var rental_period: String = "" - ## Rental expiration period in ISO 8601 format + ## Rental expiration period in ISO 8601 format Time after rental period ends when user can still extend var rental_expiration_period: Variant = null static func from_dict(data: Dictionary) -> RentalDetailsAndroid: @@ -3126,13 +3131,13 @@ class RentalDetailsAndroid: class RequestVerifyPurchaseWithIapkitResult: var store: IapStore - ## True when the purchase is valid and actionable. + ## True when the purchase is valid and actionable. Only entitled, pending-acknowledgment, or ready-to-consume return true. Callers must still match productId and use the platform plus app-owned product type to choose the fulfillment path. var is_valid: bool = false ## The current state of the purchase. var state: IapkitPurchaseState - ## Available in OpenIAP Spec 2.4.0 / openiap-apple 2.4.1 / openiap-google 2.4.1. + ## Available in OpenIAP Spec 2.4.0 / openiap-apple 2.4.1 / openiap-google 2.4.1. Store-verified product identifier when the provider returns one. var product_id: Variant = null - ## Available in OpenIAP Spec 2.4.0 / openiap-apple 2.4.1 / openiap-google 2.4.1. + ## Available in OpenIAP Spec 2.4.0 / openiap-apple 2.4.1 / openiap-google 2.4.1. Public product payload when includeClientPayload was requested, the Apple or Google receipt is valid, and a payload exists for that product. var client_payload: IapkitProductClientPayload static func from_dict(data: Dictionary) -> RequestVerifyPurchaseWithIapkitResult: @@ -3283,7 +3288,7 @@ class SubscriptionInfoIOS: ## Standardized subscription discount/promotional offer. Provides a unified interface for subscription offers across iOS and Android. Both platforms support subscription offers with different implementations: - iOS: Introductory offers, promotional offers with server-side signatures - Android: Offer tokens with pricing phases @see https://openiap.dev/docs/types/subscription-offer class SubscriptionOffer: - ## Unique identifier for the offer. + ## Unique identifier for the offer. - iOS: Discount identifier from App Store Connect - Android: offerId from ProductSubscriptionAndroidOfferDetails var id: String = "" ## Formatted display price string (e.g., "$9.99/month") var display_price: String = "" @@ -3299,27 +3304,27 @@ class SubscriptionOffer: var period_count: Variant = null ## Payment mode during the offer period var payment_mode: Variant = null - ## [iOS] Key identifier for signature validation. + ## [iOS] Key identifier for signature validation. Used with server-side signature generation for promotional offers. var key_identifier_ios: Variant = null - ## [iOS] Cryptographic nonce (UUID) for signature validation. + ## [iOS] Cryptographic nonce (UUID) for signature validation. Must be generated server-side for each purchase attempt. var nonce_ios: Variant = null - ## [iOS] Server-generated signature for promotional offer validation. + ## [iOS] Server-generated signature for promotional offer validation. Required when applying promotional offers on iOS. var signature_ios: Variant = null - ## [iOS] Timestamp when the signature was generated. + ## [iOS] Timestamp when the signature was generated. Used for signature validation. var timestamp_ios: Variant = null ## [iOS] Number of billing periods for this discount. var number_of_periods_ios: Variant = null ## [iOS] Localized price string. var localized_price_ios: Variant = null - ## [Android] Base plan identifier. + ## [Android] Base plan identifier. Identifies which base plan this offer belongs to. var base_plan_id_android: Variant = null - ## [Android] Offer token required for purchase. + ## [Android] Offer token required for purchase. Must be passed to requestPurchase() when purchasing with this offer. var offer_token_android: Variant = null ## [Android] List of tags associated with this offer. var offer_tags_android: Array[String] = [] - ## [Android] Pricing phases for this subscription offer. + ## [Android] Pricing phases for this subscription offer. Contains detailed pricing information for each phase (trial, intro, regular). var pricing_phases_android: PricingPhasesAndroid - ## [Android] Installment plan details for this subscription offer. + ## [Android] Installment plan details for this subscription offer. Only set for installment subscription plans; null for non-installment plans. Available in Google Play Billing Library 7.0+ var installment_plan_details_android: InstallmentPlanDetailsAndroid static func from_dict(data: Dictionary) -> SubscriptionOffer: @@ -3435,7 +3440,7 @@ class SubscriptionOffer: dict["installmentPlanDetailsAndroid"] = installment_plan_details_android return dict -## iOS subscription offer details. @deprecated Use the standardized SubscriptionOffer type instead for cross-platform compatibility. @see https://openiap.dev/docs/types/subscription-offer +## iOS subscription offer details. @see https://openiap.dev/docs/types/subscription-offer @deprecated Use the standardized SubscriptionOffer type instead for cross-platform compatibility. class SubscriptionOfferIOS: var display_price: String = "" var id: String = "" @@ -3670,11 +3675,11 @@ class TransactionCommitmentInfoIOS: class UserChoiceBillingDetails: ## Token that must be reported to Google Play within 24 hours var external_transaction_token: String = "" - ## External transaction ID of the originating subscription when the user is + ## External transaction ID of the originating subscription when the user is upgrading or downgrading a developer-billed subscription. Available in OpenIAP Spec 2.3.0 / openiap-google 2.3.1 (requires Play Billing 9.1+). var original_external_transaction_id: Variant = null ## List of product IDs selected by the user var products: Array[String] = [] - ## Structured product details selected in the user-choice flow, including the + ## Structured product details selected in the user-choice flow, including the product type and offer token. Legacy payloads may omit this field; use products as the product-ID fallback. Available in OpenIAP Spec 2.3.0 / openiap-google 2.3.1 (requires Play Billing 9.1+). var product_details_android: Array[DeveloperProvidedBillingProductAndroid] = [] static func from_dict(data: Dictionary) -> UserChoiceBillingDetails: @@ -3965,7 +3970,7 @@ class VoidResult: return dict class WebhookEvent: - ## Stable identifier suitable for idempotency. Derived from the source notification + ## Stable identifier suitable for idempotency. Derived from the source notification UUID where the store provides one (ASN v2 `notificationUUID`, RTDN message id); otherwise hashed from the canonicalized payload. var id: String = "" var type: WebhookEventType var source: WebhookEventSource @@ -3977,11 +3982,11 @@ class WebhookEvent: ## Time kit ingested and normalized this event. Epoch milliseconds. var received_at: float = 0.0 var environment: WebhookEventEnvironment - ## Cross-platform purchase identity used to correlate this event with an existing + ## Cross-platform purchase identity used to correlate this event with an existing purchase record. iOS: `originalTransactionId`. Android: `purchaseToken`. Null for `TestNotification` events (Apple ASN v2 / Google RTDN test payloads carry no transaction); always present for every other event type. var purchase_token: Variant = null ## Product the event pertains to. May be null for account-level events. var product_id: Variant = null - ## Normalized subscription state at the time of event, when the event refers to + ## Normalized subscription state at the time of event, when the event refers to a subscription. Null for one-time purchase events. var subscription_state: Variant = null ## When the current subscription period ends. Epoch milliseconds. var expires_at: Variant = null @@ -3991,9 +3996,9 @@ class WebhookEvent: var cancellation_reason: Variant = null ## Localized currency code (ISO 4217) at event time, when available. var currency: Variant = null - ## Price in micros (1/1,000,000 of the currency unit) at event time, when available. + ## Price in micros (1/1,000,000 of the currency unit) at event time, when available. Matches Google Play's `priceAmountMicros` convention; iOS values are converted. var price_amount_micros: Variant = null - ## Original signed payload from the store. ASN v2 events expose the JWS string; + ## Original signed payload from the store. ASN v2 events expose the JWS string; RTDN events expose the base64-decoded Pub/Sub message JSON. Provided so that consumers can independently verify or extract platform-specific fields. kit always validates this payload before emitting the event. var raw_signed_payload: Variant = null static func from_dict(data: Dictionary) -> WebhookEvent: @@ -4188,11 +4193,11 @@ class DeepLinkOptions: class DeveloperBillingOptionParamsAndroid: ## The billing program. Use EXTERNAL_PAYMENTS or BILLING_CHOICE. var billing_program: BillingProgramAndroid - ## The URI where the external payment will be processed. + ## The URI where the external payment will be processed. Required only when the selected billing program links outside the app. var link_uri: Variant = null - ## The launch mode for the external payment link. + ## The launch mode for the external payment link. Required only when the selected billing program links outside the app. var launch_mode: Variant = null - ## A pre-generated external transaction token for a Billing Choice external-link + ## A pre-generated external transaction token for a Billing Choice external-link flow. Omit it when Google Play should provide the token in the callback. var external_transaction_token: Variant = null static func from_dict(data: Dictionary) -> DeveloperBillingOptionParamsAndroid: @@ -4348,11 +4353,11 @@ class InAppMessageParamsAndroid: ## Connection initialization configuration class InitConnectionConfig: - ## Alternative billing mode for Android + ## Alternative billing mode for Android If not specified, defaults to NONE (standard Google Play billing) Use USER_CHOICE_BILLING for user choice billing, EXTERNAL_OFFER for alternative only. @deprecated Use enableBillingProgramAndroid instead. var alternative_billing_mode_android: Variant = null - ## Enable a specific billing program for Android (7.0+) + ## Enable a specific billing program for Android (7.0+) When set, enables the specified billing program for external transactions. - USER_CHOICE_BILLING: User can select between Google Play or alternative (7.0+) - EXTERNAL_CONTENT_LINK: Link to external content (introduced in 8.2.0; use 8.2.1+) - EXTERNAL_OFFER: External offers for digital content (introduced in 8.2.0; use 8.2.1+) - EXTERNAL_PAYMENTS: Developer provided billing, Japan only (8.3.0+) - BILLING_CHOICE: Google-rendered or developer-rendered billing choice (OpenIAP Spec 2.1.0 / openiap-google 2.3.0; requires Play Billing 9.1.0+) var enable_billing_program_android: Variant = null - ## Billing Choice renderer configured in Play Console. Available in OpenIAP + ## Billing Choice renderer configured in Play Console. Available in OpenIAP Spec 2.1.0 / openiap-google 2.3.0 (requires Play Billing 9.1.0+). GOOGLE_RENDERED registers the developer-provided billing listener so OpenIAP can emit the selection event. DEVELOPER_RENDERED omits that listener so the app can render its own choice screen and use the reporting/dialog/link APIs. Must match choiceScreenType returned by isBillingProgramAvailableAndroid. Defaults to GOOGLE_RENDERED. var billing_choice_screen_type_android: BillingChoiceScreenTypeAndroid = BillingChoiceScreenTypeAndroid.GOOGLE_RENDERED static func from_dict(data: Dictionary) -> InitConnectionConfig: @@ -4406,7 +4411,7 @@ class LaunchExternalLinkParamsAndroid: var link_type: ExternalLinkTypeAndroid ## The URI where the content will be accessed from var link_uri: String = "" - ## External transaction token for a developer-rendered Billing Choice external-link + ## External transaction token for a developer-rendered Billing Choice external-link flow. Available in OpenIAP Spec 2.1.0 / openiap-google 2.3.0 (requires Play Billing 9.1.0+). Generate it with createBillingProgramReportingDetailsAndroid. var external_transaction_token: Variant = null static func from_dict(data: Dictionary) -> LaunchExternalLinkParamsAndroid: @@ -4494,7 +4499,7 @@ class ProductRequest: class PromotionalOfferJWSInputIOS: ## The promotional offer identifier from App Store Connect var offer_id: String = "" - ## Compact JWS string signed by your server. + ## Compact JWS string signed by your server. The JWS should contain the promotional offer signature data. Format: header.payload.signature (base64url encoded) var jws: String = "" static func from_dict(data: Dictionary) -> PromotionalOfferJWSInputIOS: @@ -4607,7 +4612,7 @@ class PurchaseOptions: var also_publish_to_event_listener_ios: Variant = null ## Limit to currently active items on iOS var only_include_active_items_ios: Variant = null - ## Include suspended subscriptions in the result (Android 8.1+). + ## Include suspended subscriptions in the result (Android 8.1+). Suspended subscriptions have isSuspendedAndroid=true and should NOT be granted entitlements. Users should be directed to the subscription center to resolve payment issues. Default: false (only active subscriptions are returned) var include_suspended_android: Variant = null static func from_dict(data: Dictionary) -> PurchaseOptions: @@ -4631,7 +4636,7 @@ class PurchaseOptions: return dict class PurchaseUpdatedListenerOptions: - ## iOS only. Defaults to true. When false, listener callbacks also receive + ## iOS only. Defaults to true. When false, listener callbacks also receive StoreKit replay events for a transaction ID that was already emitted during the current connection session. Android ignores this option. var dedupe_transaction_ios: Variant = null static func from_dict(data: Dictionary) -> PurchaseUpdatedListenerOptions: @@ -4653,11 +4658,11 @@ class RequestPurchaseAndroidProps: var obfuscated_account_id: Variant = null ## Obfuscated profile ID var obfuscated_profile_id: Variant = null - ## Personalized offer flag. + ## Personalized offer flag. When true, indicates the price was customized for this user. var is_offer_personalized: Variant = null - ## Offer token for one-time purchase discounts (8.0+). + ## Offer token for one-time purchase discounts (8.0+). Pass the offerToken from oneTimePurchaseOfferDetailsAndroid or discountOffers to apply a discount offer to the purchase. var offer_token: Variant = null - ## Developer billing option parameters for external payments and Billing Choice. + ## Developer billing option parameters for external payments and Billing Choice. Billing Choice is available in OpenIAP Spec 2.1.0 / openiap-google 2.3.0 (requires Play Billing 9.1.0+). var developer_billing_option: DeveloperBillingOptionParamsAndroid static func from_dict(data: Dictionary) -> RequestPurchaseAndroidProps: @@ -4712,9 +4717,9 @@ class RequestPurchaseIosProps: var app_account_token: Variant = null ## Purchase quantity var quantity: Variant = null - ## Promotional offer to apply (subscriptions only, ignored for one-time purchases). + ## Promotional offer to apply (subscriptions only, ignored for one-time purchases). iOS only supports promotional offers for auto-renewable subscriptions. var with_offer: DiscountOfferInputIOS - ## Advanced commerce data token (iOS 15+). + ## Advanced commerce data token (iOS 15+). Used with StoreKit 2's Product.PurchaseOption.custom API for passing campaign tokens, affiliate IDs, or other attribution data. The data is formatted as JSON: {"signatureInfo": {"token": ""}} var advanced_commerce_data: Variant = null static func from_dict(data: Dictionary) -> RequestPurchaseIosProps: @@ -4762,7 +4767,7 @@ class RequestPurchaseProps: var request_subscription: RequestSubscriptionPropsByPlatforms ## Explicit purchase type hint (defaults to in-app) var type: ProductQueryType = ProductQueryType.IN_APP - ## @deprecated Use enableBillingProgramAndroid in InitConnectionConfig instead. + ## This flag only logs debug info and has no effect on the purchase flow. @deprecated Use enableBillingProgramAndroid in InitConnectionConfig instead. var use_alternative_billing: Variant = null static func in_app(platforms: RequestPurchasePropsByPlatforms, use_alternative_billing_value: Variant = null) -> RequestPurchaseProps: @@ -4890,19 +4895,19 @@ class RequestSubscriptionAndroidProps: var obfuscated_account_id: Variant = null ## Obfuscated profile ID var obfuscated_profile_id: Variant = null - ## Personalized offer flag. + ## Personalized offer flag. When true, indicates the price was customized for this user. var is_offer_personalized: Variant = null ## Purchase token for upgrades/downgrades var purchase_token: Variant = null - ## Original external transaction ID for replacing a subscription that was + ## Original external transaction ID for replacing a subscription that was purchased through developer billing. Available in OpenIAP Spec 2.1.0 / openiap-google 2.3.0 (requires Play Billing 9.1.0+). var original_external_transaction_id: Variant = null - ## Replacement mode for subscription changes + ## Replacement mode for subscription changes @deprecated Use subscriptionProductReplacementParams instead for item-level replacement (8.1.0+). var replacement_mode: Variant = null ## Subscription offers var subscription_offers: Array[AndroidSubscriptionOfferInput] = [] - ## Product-level replacement parameters (8.1.0+) + ## Product-level replacement parameters (8.1.0+) Use this instead of replacementMode for item-level replacement This singular form requires skus to contain exactly one target product. Multi-item subscription changes need a per-target replacement mapping and are rejected rather than applying one oldProductId to multiple products. var subscription_product_replacement_params: SubscriptionProductReplacementParamsAndroid - ## Developer billing option parameters for external payments and Billing Choice. + ## Developer billing option parameters for external payments and Billing Choice. Billing Choice is available in OpenIAP Spec 2.1.0 / openiap-google 2.3.0 (requires Play Billing 9.1.0+). var developer_billing_option: DeveloperBillingOptionParamsAndroid static func from_dict(data: Dictionary) -> RequestSubscriptionAndroidProps: @@ -4988,17 +4993,17 @@ class RequestSubscriptionIosProps: var and_dangerously_finish_transaction_automatically: Variant = null var app_account_token: Variant = null var quantity: Variant = null - ## Promotional offer to apply for subscription purchases. + ## Promotional offer to apply for subscription purchases. Requires server-signed offer with nonce, timestamp, keyId, and signature. var with_offer: DiscountOfferInputIOS - ## Win-back offer to apply (iOS 18+) + ## Win-back offer to apply (iOS 18+) Used to re-engage churned subscribers with a discount or free trial. The offer is available when the customer is eligible and can be discovered via StoreKit Message (automatic) or subscription offer APIs. var win_back_offer: WinBackOfferInputIOS - ## JWS promotional offer (iOS 15+, WWDC 2025). + ## JWS promotional offer (iOS 15+, WWDC 2025). New signature format using compact JWS string for promotional offers. Back-deployed to iOS 15. var promotional_offer_jws: PromotionalOfferJWSInputIOS - ## Billing plan to use when purchasing an annual subscription that offers + ## Billing plan to use when purchasing an annual subscription that offers monthly billing with a 12-month commitment (iOS 26.4+). var billing_plan_type: Variant = null - ## Compact JWS string for overriding introductory offer eligibility + ## Compact JWS string for overriding introductory offer eligibility (iOS 15+, WWDC 2025). When nil, the system determines eligibility. Generate the JWS on your server and pass it to StoreKit's introductoryOfferEligibility(compactJWS:) purchase option. var compact_jws: Variant = null - ## Advanced commerce data token (iOS 15+). + ## Advanced commerce data token (iOS 15+). Used with StoreKit 2's Product.PurchaseOption.custom API for passing campaign tokens, affiliate IDs, or other attribution data. The data is formatted as JSON: {"signatureInfo": {"token": ""}} var advanced_commerce_data: Variant = null static func from_dict(data: Dictionary) -> RequestSubscriptionIosProps: @@ -5197,9 +5202,9 @@ class RequestVerifyPurchaseWithIapkitGoogleProps: class RequestVerifyPurchaseWithIapkitProps: ## API key used for the Authorization header (Bearer {apiKey}). var api_key: Variant = null - ## Available in OpenIAP Spec 2.3.1 / openiap-apple 2.4.0 / openiap-google 2.4.0. + ## Available in OpenIAP Spec 2.3.1 / openiap-apple 2.4.0 / openiap-google 2.4.0. Base URL for the IAPKit server. Defaults to https://kit.openiap.dev. Set this to a reachable HTTP(S) origin when self-hosting or testing a local IAPKit server. The apiKey must be issued by the same IAPKit/Convex deployment as this server. var base_url: Variant = null - ## Available in OpenIAP Spec 2.4.0 / openiap-apple 2.4.1 / openiap-google 2.4.1. + ## Available in OpenIAP Spec 2.4.0 / openiap-apple 2.4.1 / openiap-google 2.4.1. Include the product's public IAPKit client payload in a valid Apple or Google verification response. Defaults to false so existing response shapes and bandwidth remain unchanged. var include_client_payload: Variant = null ## Apple App Store verification parameters. var apple: RequestVerifyPurchaseWithIapkitAppleProps @@ -5311,9 +5316,9 @@ class VerifyPurchaseGoogleOptions: var sku: String = "" ## Android package name (e.g., com.example.app) var package_name: String = "" - ## Purchase token from the purchase response. + ## Purchase token from the purchase response. ⚠️ Sensitive: Do not log this value. var purchase_token: String = "" - ## Google OAuth2 access token for API authentication. + ## Google OAuth2 access token for API authentication. ⚠️ Sensitive: Do not log this value. var access_token: String = "" ## Whether this is a subscription purchase (affects API endpoint used) var is_sub: Variant = null @@ -5352,7 +5357,7 @@ class VerifyPurchaseHorizonOptions: var sku: String = "" ## The user ID of the user whose purchase you want to verify var user_id: String = "" - ## Access token for Meta API authentication (OC|$APP_ID|$APP_SECRET or User Access Token). + ## Access token for Meta API authentication (OC|$APP_ID|$APP_SECRET or User Access Token). ⚠️ Sensitive: Do not log this value. var access_token: String = "" static func from_dict(data: Dictionary) -> VerifyPurchaseHorizonOptions: @@ -6107,7 +6112,7 @@ class Query: const return_type = "Boolean" const is_array = false - ## Fetch products or subscriptions from the store. + ## Fetch products or subscriptions from the store. See: https://openiap.dev/docs/apis/fetch-products class fetchProductsField: const name = "fetchProducts" const snake_name = "fetch_products" @@ -6127,7 +6132,7 @@ class Query: const return_type = "FetchProductsResult" const is_array = false - ## List active purchases for the current user. + ## List active purchases for the current user. See: https://openiap.dev/docs/apis/get-available-purchases class getAvailablePurchasesField: const name = "getAvailablePurchases" const snake_name = "get_available_purchases" @@ -6148,7 +6153,7 @@ class Query: const return_type = "Purchase" const is_array = true - ## Get details of all currently active subscriptions (filters by subscriptionIds when provided). + ## Get details of all currently active subscriptions (filters by subscriptionIds when provided). See: https://openiap.dev/docs/apis/get-active-subscriptions class getActiveSubscriptionsField: const name = "getActiveSubscriptions" const snake_name = "get_active_subscriptions" @@ -6174,7 +6179,7 @@ class Query: const return_type = "ActiveSubscription" const is_array = true - ## Check whether the user has any active subscription. + ## Check whether the user has any active subscription. See: https://openiap.dev/docs/apis/has-active-subscriptions class hasActiveSubscriptionsField: const name = "hasActiveSubscriptions" const snake_name = "has_active_subscriptions" @@ -6200,7 +6205,7 @@ class Query: const return_type = "Boolean" const is_array = false - ## Return the store-authoritative country code: ISO 3166-1 alpha-3 on Apple + ## Return the store-authoritative country code: ISO 3166-1 alpha-3 on Apple platforms and alpha-2 on Android. The operation fails when the store cannot provide a value; implementations must not synthesize a locale fallback. See: https://openiap.dev/docs/apis/get-storefront class getStorefrontField: const name = "getStorefront" const snake_name = "get_storefront" @@ -6209,7 +6214,7 @@ class Query: const return_type = "String" const is_array = false - ## Deprecated. Get the current App Store storefront ISO 3166-1 alpha-3 country + ## Deprecated. Get the current App Store storefront ISO 3166-1 alpha-3 country code — use cross-platform getStorefront instead. See: https://openiap.dev/docs/apis/ios/get-storefront-ios @deprecated Use getStorefront class getStorefrontIOSField: const name = "getStorefrontIOS" const snake_name = "get_storefront_ios" @@ -6218,7 +6223,7 @@ class Query: const return_type = "String" const is_array = false - ## Read the App Store-promoted product, if any (iOS 11+). + ## Read the App Store-promoted product, if any (iOS 11+). See: https://openiap.dev/docs/apis/ios/get-promoted-product-ios class getPromotedProductIOSField: const name = "getPromotedProductIOS" const snake_name = "get_promoted_product_ios" @@ -6227,7 +6232,7 @@ class Query: const return_type = "ProductIOS" const is_array = false - ## Check eligibility for the external purchase notice sheet (iOS 17.4+). + ## Check eligibility for the external purchase notice sheet (iOS 17.4+). Uses ExternalPurchase.canPresent. See: https://openiap.dev/docs/apis/ios/can-present-external-purchase-notice-ios class canPresentExternalPurchaseNoticeIOSField: const name = "canPresentExternalPurchaseNoticeIOS" const snake_name = "can_present_external_purchase_notice_ios" @@ -6236,7 +6241,7 @@ class Query: const return_type = "Boolean" const is_array = false - ## Check eligibility for the custom-link variant of external purchase (iOS 18.1+). + ## Check eligibility for the custom-link variant of external purchase (iOS 18.1+). Returns true if the app can use custom external purchase links. Reference: https://developer.apple.com/documentation/storekit/externalpurchasecustomlink/iseligible See: https://openiap.dev/docs/apis/ios/is-eligible-for-external-purchase-custom-link-ios class isEligibleForExternalPurchaseCustomLinkIOSField: const name = "isEligibleForExternalPurchaseCustomLinkIOS" const snake_name = "is_eligible_for_external_purchase_custom_link_ios" @@ -6245,7 +6250,7 @@ class Query: const return_type = "Boolean" const is_array = false - ## Fetch a token for Apple's External Purchase Server reporting API (iOS 18.1+). + ## Fetch a token for Apple's External Purchase Server reporting API (iOS 18.1+). Use this token to report transactions made through ExternalPurchaseCustomLink. Reference: https://developer.apple.com/documentation/storekit/externalpurchasecustomlink/token(for:) See: https://openiap.dev/docs/apis/ios/get-external-purchase-custom-link-token-ios class getExternalPurchaseCustomLinkTokenIOSField: const name = "getExternalPurchaseCustomLinkTokenIOS" const snake_name = "get_external_purchase_custom_link_token_ios" @@ -6273,7 +6278,7 @@ class Query: const return_type = "ExternalPurchaseCustomLinkTokenResultIOS" const is_array = false - ## List unfinished StoreKit transactions in the queue. + ## List unfinished StoreKit transactions in the queue. See: https://openiap.dev/docs/apis/ios/get-pending-transactions-ios class getPendingTransactionsIOSField: const name = "getPendingTransactionsIOS" const snake_name = "get_pending_transactions_ios" @@ -6282,7 +6287,7 @@ class Query: const return_type = "PurchaseIOS" const is_array = true - ## Check intro-offer eligibility for a subscription group. + ## Check intro-offer eligibility for a subscription group. See: https://openiap.dev/docs/apis/ios/is-eligible-for-intro-offer-ios class isEligibleForIntroOfferIOSField: const name = "isEligibleForIntroOfferIOS" const snake_name = "is_eligible_for_intro_offer_ios" @@ -6302,7 +6307,7 @@ class Query: const return_type = "Boolean" const is_array = false - ## Get subscription status objects from StoreKit 2 (iOS 15+). + ## Get subscription status objects from StoreKit 2 (iOS 15+). See: https://openiap.dev/docs/apis/ios/subscription-status-ios class subscriptionStatusIOSField: const name = "subscriptionStatusIOS" const snake_name = "subscription_status_ios" @@ -6322,7 +6327,7 @@ class Query: const return_type = "SubscriptionStatusIOS" const is_array = true - ## Get the user's current entitlement for a product, using StoreKit 2 (iOS 15+). + ## Get the user's current entitlement for a product, using StoreKit 2 (iOS 15+). See: https://openiap.dev/docs/apis/ios/current-entitlement-ios class currentEntitlementIOSField: const name = "currentEntitlementIOS" const snake_name = "current_entitlement_ios" @@ -6342,7 +6347,7 @@ class Query: const return_type = "PurchaseIOS" const is_array = false - ## Get the latest verified transaction for a product, using StoreKit 2. + ## Get the latest verified transaction for a product, using StoreKit 2. See: https://openiap.dev/docs/apis/ios/latest-transaction-ios class latestTransactionIOSField: const name = "latestTransactionIOS" const snake_name = "latest_transaction_ios" @@ -6362,7 +6367,7 @@ class Query: const return_type = "PurchaseIOS" const is_array = false - ## Check whether a transaction's JWS verification passed (StoreKit 2). + ## Check whether a transaction's JWS verification passed (StoreKit 2). See: https://openiap.dev/docs/apis/ios/is-transaction-verified-ios class isTransactionVerifiedIOSField: const name = "isTransactionVerifiedIOS" const snake_name = "is_transaction_verified_ios" @@ -6382,7 +6387,7 @@ class Query: const return_type = "Boolean" const is_array = false - ## Return the JWS string for a transaction (StoreKit 2). + ## Return the JWS string for a transaction (StoreKit 2). See: https://openiap.dev/docs/apis/ios/get-transaction-jws-ios class getTransactionJwsIOSField: const name = "getTransactionJwsIOS" const snake_name = "get_transaction_jws_ios" @@ -6402,7 +6407,7 @@ class Query: const return_type = "String" const is_array = false - ## Get base64-encoded receipt data (legacy validation). + ## Get base64-encoded receipt data (legacy validation). See: https://openiap.dev/docs/apis/ios/get-receipt-data-ios class getReceiptDataIOSField: const name = "getReceiptDataIOS" const snake_name = "get_receipt_data_ios" @@ -6411,7 +6416,7 @@ class Query: const return_type = "String" const is_array = false - ## Fetch the app transaction (iOS 16+). + ## Fetch the app transaction (iOS 16+). See: https://openiap.dev/docs/apis/ios/get-app-transaction-ios class getAppTransactionIOSField: const name = "getAppTransactionIOS" const snake_name = "get_app_transaction_ios" @@ -6420,7 +6425,7 @@ class Query: const return_type = "AppTransaction" const is_array = false - ## List every StoreKit transaction (finished + unfinished) for the current user. + ## List every StoreKit transaction (finished + unfinished) for the current user. Requires the SKIncludeConsumableInAppPurchaseHistory Info.plist key in the host app for finished consumables to be included (iOS 18+). Unlike getAvailablePurchases, always returns the iOS-specific PurchaseIOS shape. See: https://openiap.dev/docs/apis/ios/get-all-transactions-ios class getAllTransactionsIOSField: const name = "getAllTransactionsIOS" const snake_name = "get_all_transactions_ios" @@ -6429,7 +6434,7 @@ class Query: const return_type = "PurchaseIOS" const is_array = true - ## Deprecated. Legacy App Store receipt validation — use verifyPurchase instead. + ## Deprecated. Legacy App Store receipt validation — use verifyPurchase instead. See: https://openiap.dev/docs/apis/ios/validate-receipt-ios @deprecated Use verifyPurchase class validateReceiptIOSField: const name = "validateReceiptIOS" const snake_name = "validate_receipt_ios" @@ -6449,7 +6454,7 @@ class Query: const return_type = "VerifyPurchaseResultIOS" const is_array = false - ## Fetch Play Billing assets and loyalty text for developer-rendered Billing Choice screens. + ## Fetch Play Billing assets and loyalty text for developer-rendered Billing Choice screens. OpenIAP availability: Spec 2.1.0 / openiap-google 2.3.0 (requires Play Billing 9.1.0+). Throws OpenIapError.NotPrepared if billing client is not ready. See: https://openiap.dev/docs/apis/android/get-billing-choice-info-android class getBillingChoiceInfoAndroidField: const name = "getBillingChoiceInfoAndroid" const snake_name = "get_billing_choice_info_android" @@ -6483,7 +6488,7 @@ class Mutation: const return_type = "Boolean" const is_array = false - ## Initialize the store connection. Call before any IAP API. + ## Initialize the store connection. Call before any IAP API. See: https://openiap.dev/docs/apis/init-connection class initConnectionField: const name = "initConnection" const snake_name = "init_connection" @@ -6504,7 +6509,7 @@ class Mutation: const return_type = "Boolean" const is_array = false - ## Close the store connection and release resources. + ## Close the store connection and release resources. See: https://openiap.dev/docs/apis/end-connection class endConnectionField: const name = "endConnection" const snake_name = "end_connection" @@ -6513,7 +6518,7 @@ class Mutation: const return_type = "Boolean" const is_array = false - ## Initiate a purchase or subscription flow; rely on events for final state. + ## Initiate a purchase or subscription flow; rely on events for final state. See: https://openiap.dev/docs/apis/request-purchase class requestPurchaseField: const name = "requestPurchase" const snake_name = "request_purchase" @@ -6533,7 +6538,7 @@ class Mutation: const return_type = "RequestPurchaseResult" const is_array = false - ## Complete a transaction after server-side verification. Required on Android within 3 days. + ## Complete a transaction after server-side verification. Required on Android within 3 days. See: https://openiap.dev/docs/apis/finish-transaction class finishTransactionField: const name = "finishTransaction" const snake_name = "finish_transaction" @@ -6558,7 +6563,7 @@ class Mutation: const return_type = "VoidResult" const is_array = false - ## Restore non-consumable and active subscription purchases. + ## Restore non-consumable and active subscription purchases. See: https://openiap.dev/docs/apis/restore-purchases class restorePurchasesField: const name = "restorePurchases" const snake_name = "restore_purchases" @@ -6567,7 +6572,7 @@ class Mutation: const return_type = "VoidResult" const is_array = false - ## Open the platform's subscription management UI. + ## Open the platform's subscription management UI. See: https://openiap.dev/docs/apis/deep-link-to-subscriptions class deepLinkToSubscriptionsField: const name = "deepLinkToSubscriptions" const snake_name = "deep_link_to_subscriptions" @@ -6588,7 +6593,7 @@ class Mutation: const return_type = "VoidResult" const is_array = false - ## Deprecated. Validate purchase receipts with the configured providers — use verifyPurchase instead. + ## Deprecated. Validate purchase receipts with the configured providers — use verifyPurchase instead. See: https://openiap.dev/docs/features/validation#verify-purchase @deprecated Use verifyPurchase class validateReceiptField: const name = "validateReceipt" const snake_name = "validate_receipt" @@ -6608,7 +6613,7 @@ class Mutation: const return_type = "VerifyPurchaseResult" const is_array = false - ## Verify a purchase against your own backend. Returns a platform-specific + ## Verify a purchase against your own backend. Returns a platform-specific variant of VerifyPurchaseResult — VerifyPurchaseResultIOS exposes isValid + receipt/JWS metadata, VerifyPurchaseResultAndroid carries Play Store receipt fields (no isValid), and VerifyPurchaseResultHorizon uses success. Inspect the concrete variant before reading fields. See: https://openiap.dev/docs/features/validation#verify-purchase class verifyPurchaseField: const name = "verifyPurchase" const snake_name = "verify_purchase" @@ -6628,7 +6633,7 @@ class Mutation: const return_type = "VerifyPurchaseResult" const is_array = false - ## Verify via a managed provider without standing up your own server. The + ## Verify via a managed provider without standing up your own server. The PurchaseVerificationProvider enum currently exposes only IAPKit; platform availability may differ by implementation. See: https://openiap.dev/docs/features/validation#verify-purchase-with-provider class verifyPurchaseWithProviderField: const name = "verifyPurchaseWithProvider" const snake_name = "verify_purchase_with_provider" @@ -6648,7 +6653,7 @@ class Mutation: const return_type = "VerifyPurchaseWithProviderResult" const is_array = false - ## Clear pending transactions in the queue (sandbox helper). + ## Clear pending transactions in the queue (sandbox helper). See: https://openiap.dev/docs/apis/ios/clear-transaction-ios class clearTransactionIOSField: const name = "clearTransactionIOS" const snake_name = "clear_transaction_ios" @@ -6657,7 +6662,7 @@ class Mutation: const return_type = "Boolean" const is_array = false - ## Buy the currently promoted product. + ## Buy the currently promoted product. See: https://openiap.dev/docs/apis/ios/request-purchase-on-promoted-product-ios @deprecated Use promotedProductListenerIOS to receive the productId, then call requestPurchase with that SKU instead. In StoreKit 2, promoted products can be purchased directly via the standard purchase flow. class requestPurchaseOnPromotedProductIOSField: const name = "requestPurchaseOnPromotedProductIOS" const snake_name = "request_purchase_on_promoted_product_ios" @@ -6666,7 +6671,7 @@ class Mutation: const return_type = "Boolean" const is_array = false - ## Present the manage-subscriptions sheet and return changed purchases (iOS 15+). + ## Present the manage-subscriptions sheet and return changed purchases (iOS 15+). See: https://openiap.dev/docs/apis/ios/show-manage-subscriptions-ios class showManageSubscriptionsIOSField: const name = "showManageSubscriptionsIOS" const snake_name = "show_manage_subscriptions_ios" @@ -6675,7 +6680,7 @@ class Mutation: const return_type = "PurchaseIOS" const is_array = true - ## Present the refund request sheet (iOS 15+). See also Features → Refund. + ## Present the refund request sheet (iOS 15+). See also Features → Refund. See: https://openiap.dev/docs/apis/ios/begin-refund-request-ios class beginRefundRequestIOSField: const name = "beginRefundRequestIOS" const snake_name = "begin_refund_request_ios" @@ -6695,7 +6700,7 @@ class Mutation: const return_type = "String" const is_array = false - ## Force sync transactions with the App Store (iOS 15+). + ## Force sync transactions with the App Store (iOS 15+). See: https://openiap.dev/docs/apis/ios/sync-ios class syncIOSField: const name = "syncIOS" const snake_name = "sync_ios" @@ -6704,7 +6709,7 @@ class Mutation: const return_type = "Boolean" const is_array = false - ## Show the App Store offer code redemption sheet. + ## Show the App Store offer code redemption sheet. See: https://openiap.dev/docs/apis/ios/present-code-redemption-sheet-ios class presentCodeRedemptionSheetIOSField: const name = "presentCodeRedemptionSheetIOS" const snake_name = "present_code_redemption_sheet_ios" @@ -6713,7 +6718,7 @@ class Mutation: const return_type = "Boolean" const is_array = false - ## Present the external purchase notice sheet (iOS 17.4+). + ## Present the external purchase notice sheet (iOS 17.4+). Uses ExternalPurchase.presentNoticeSheet() which returns a token when the user continues. Reference: https://developer.apple.com/documentation/storekit/externalpurchase/presentnoticesheet() See: https://openiap.dev/docs/apis/ios/present-external-purchase-notice-sheet-ios class presentExternalPurchaseNoticeSheetIOSField: const name = "presentExternalPurchaseNoticeSheetIOS" const snake_name = "present_external_purchase_notice_sheet_ios" @@ -6722,7 +6727,7 @@ class Mutation: const return_type = "ExternalPurchaseNoticeResultIOS" const is_array = false - ## Present an external purchase link, StoreKit External (iOS 16+). + ## Present an external purchase link, StoreKit External (iOS 16+). See: https://openiap.dev/docs/apis/ios/present-external-purchase-link-ios class presentExternalPurchaseLinkIOSField: const name = "presentExternalPurchaseLinkIOS" const snake_name = "present_external_purchase_link_ios" @@ -6742,7 +6747,7 @@ class Mutation: const return_type = "ExternalPurchaseLinkResultIOS" const is_array = false - ## Present the disclosure sheet required before linking out via ExternalPurchaseCustomLink (iOS 18.1+). + ## Present the disclosure sheet required before linking out via ExternalPurchaseCustomLink (iOS 18.1+). Call this after a deliberate customer interaction before linking out to external purchases. Reference: https://developer.apple.com/documentation/storekit/externalpurchasecustomlink/shownotice(type:) See: https://openiap.dev/docs/apis/ios/show-external-purchase-custom-link-notice-ios class showExternalPurchaseCustomLinkNoticeIOSField: const name = "showExternalPurchaseCustomLinkNoticeIOS" const snake_name = "show_external_purchase_custom_link_notice_ios" @@ -6770,7 +6775,7 @@ class Mutation: const return_type = "ExternalPurchaseCustomLinkNoticeResultIOS" const is_array = false - ## Acknowledge a non-consumable purchase. Required within 3 days or Google auto-refunds. + ## Acknowledge a non-consumable purchase. Required within 3 days or Google auto-refunds. See: https://openiap.dev/docs/apis/android/acknowledge-purchase-android class acknowledgePurchaseAndroidField: const name = "acknowledgePurchaseAndroid" const snake_name = "acknowledge_purchase_android" @@ -6790,7 +6795,7 @@ class Mutation: const return_type = "Boolean" const is_array = false - ## Consume a consumable purchase so it can be re-bought. + ## Consume a consumable purchase so it can be re-bought. See: https://openiap.dev/docs/apis/android/consume-purchase-android class consumePurchaseAndroidField: const name = "consumePurchaseAndroid" const snake_name = "consume_purchase_android" @@ -6810,7 +6815,7 @@ class Mutation: const return_type = "Boolean" const is_array = false - ## Check whether alternative billing is available for the user. Step 1 of the alternative billing flow. + ## Check whether alternative billing is available for the user. Step 1 of the alternative billing flow. Returns true if available, false otherwise. Throws OpenIapError.NotPrepared if billing client not ready. See: https://openiap.dev/docs/apis/android/check-alternative-billing-availability-android class checkAlternativeBillingAvailabilityAndroidField: const name = "checkAlternativeBillingAvailabilityAndroid" const snake_name = "check_alternative_billing_availability_android" @@ -6819,7 +6824,7 @@ class Mutation: const return_type = "Boolean" const is_array = false - ## Display Google's alternative billing information dialog. Step 2 of the alternative billing flow. + ## Display Google's alternative billing information dialog. Step 2 of the alternative billing flow. Must be called BEFORE processing payment in your payment system. Returns true if user accepted, false if user canceled. Throws OpenIapError.NotPrepared if billing client not ready. See: https://openiap.dev/docs/apis/android/show-alternative-billing-dialog-android class showAlternativeBillingDialogAndroidField: const name = "showAlternativeBillingDialogAndroid" const snake_name = "show_alternative_billing_dialog_android" @@ -6828,7 +6833,7 @@ class Mutation: const return_type = "Boolean" const is_array = false - ## Create a reporting token for an alternative billing flow. Step 3 of the alternative billing flow. + ## Create a reporting token for an alternative billing flow. Step 3 of the alternative billing flow. Must be called AFTER successful payment in your payment system. Token must be reported to Google Play backend within 24 hours. Returns token string, or null if creation failed. Throws OpenIapError.NotPrepared if billing client not ready. See: https://openiap.dev/docs/apis/android/create-alternative-billing-token-android class createAlternativeBillingTokenAndroidField: const name = "createAlternativeBillingTokenAndroid" const snake_name = "create_alternative_billing_token_android" @@ -6837,7 +6842,7 @@ class Mutation: const return_type = "String" const is_array = false - ## Check whether a billing program (e.g., External Payments) is available for the current user. + ## Check whether a billing program (e.g., External Payments) is available for the current user. Replaces the deprecated isExternalOfferAvailableAsync API. Introduced in Google Play Billing Library 8.2.0. External Offer and External Content Link integrations must use 8.2.1+ because 8.2.1 fixes this API. Returns availability result with isAvailable flag. Throws OpenIapError.NotPrepared if billing client not ready. See: https://openiap.dev/docs/apis/android/is-billing-program-available-android class isBillingProgramAvailableAndroidField: const name = "isBillingProgramAvailableAndroid" const snake_name = "is_billing_program_available_android" @@ -6864,7 +6869,7 @@ class Mutation: const return_type = "BillingProgramAvailabilityResultAndroid" const is_array = false - ## Create the reporting details and external transaction token required by a billing program. + ## Create the reporting details and external transaction token required by a billing program. Introduced in Play Billing 8.2.0. External Offer and External Content Link integrations must use 8.2.1+ and create fresh details immediately before every redirect session; do not cache the token for a later redirect. The same token may report multiple purchases made during one External Offer session. Replaces the deprecated createExternalOfferReportingDetailsAsync API. Returns external transaction token needed for reporting external transactions. developerBillingType is optional. When program is BILLING_CHOICE and developerBillingType is omitted, native Android defaults it to IN_APP. The Billing Choice extension is available in OpenIAP Spec 2.1.0 / openiap-google 2.3.0 (requires Play Billing 9.1.0+). Throws OpenIapError.NotPrepared if billing client not ready. See: https://openiap.dev/docs/apis/android/create-billing-program-reporting-details-android class createBillingProgramReportingDetailsAndroidField: const name = "createBillingProgramReportingDetailsAndroid" const snake_name = "create_billing_program_reporting_details_android" @@ -6903,7 +6908,7 @@ class Mutation: const return_type = "BillingProgramReportingDetailsAndroid" const is_array = false - ## Launch an external content/offer link from inside the Billing Programs flow (introduced in + ## Launch an external content/offer link from inside the Billing Programs flow (introduced in Play Billing 8.2.0; External Offer and External Content Link require 8.2.1+), including developer-rendered Billing Choice external-link flows. Billing Choice availability: OpenIAP Spec 2.1.0 / openiap-google 2.3.0 (requires Play Billing 9.1.0+). Replaces the deprecated showExternalOfferInformationDialog API. Shows Play Store dialog and optionally launches external URL. Throws OpenIapError.NotPrepared if billing client not ready. See: https://openiap.dev/docs/apis/android/launch-external-link-android class launchExternalLinkAndroidField: const name = "launchExternalLinkAndroid" const snake_name = "launch_external_link_android" @@ -6923,7 +6928,7 @@ class Mutation: const return_type = "Boolean" const is_array = false - ## Open the Google Play offer/promo code redemption flow so the user can enter a code. + ## Open the Google Play offer/promo code redemption flow so the user can enter a code. On Google Play builds, launches the Play Store redeem page (https://play.google.com/redeem). A purchase listener can receive the redeemed purchase while the app is running with an active billing connection; always reconcile with getAvailablePurchases when the app resumes. Does not require the billing client to be initialized (no Play Billing version requirement). Planned OpenIAP availability: Spec 2.5.0 / openiap-google 2.5.0. Android counterpart of presentCodeRedemptionSheetIOS. Returns true when the redemption flow was launched, or false when the current store flavor does not provide an equivalent redemption flow. See: https://openiap.dev/docs/apis/android/open-redeem-offer-code-android class openRedeemOfferCodeAndroidField: const name = "openRedeemOfferCodeAndroid" const snake_name = "open_redeem_offer_code_android" @@ -6932,7 +6937,7 @@ class Mutation: const return_type = "Boolean" const is_array = false - ## Show Google's mandatory information dialog before a developer-rendered, + ## Show Google's mandatory information dialog before a developer-rendered, in-app Billing Choice screen. OpenIAP availability: Spec 2.1.0 / openiap-google 2.3.0 (requires Play Billing 9.1.0+). Throws OpenIapError.NotPrepared if billing client not ready. See: https://openiap.dev/docs/apis/android/show-billing-program-information-dialog-android class showBillingProgramInformationDialogAndroidField: const name = "showBillingProgramInformationDialogAndroid" const snake_name = "show_billing_program_information_dialog_android" @@ -6952,7 +6957,7 @@ class Mutation: const return_type = "BillingResultAndroid" const is_array = false - ## Overlay Play billing in-app messages, such as payment issues or subscription price-change confirmations. + ## Overlay Play billing in-app messages, such as payment issues or subscription price-change confirmations. OpenIAP availability: Spec 2.1.0 / openiap-google 2.3.0 (upstream API available since Play Billing 4.1.0). Returns a response code and, when the subscription status changes, the related purchase token. Throws OpenIapError.NotPrepared if billing client not ready. See: https://openiap.dev/docs/apis/android/show-in-app-messages-android class showInAppMessagesAndroidField: const name = "showInAppMessagesAndroid" const snake_name = "show_in_app_messages_android" @@ -6981,7 +6986,7 @@ class Mutation: # Query API helpers -## Fetch products or subscriptions from the store. +## Fetch products or subscriptions from the store. See: https://openiap.dev/docs/apis/fetch-products static func fetch_products_args(params: ProductRequest) -> Dictionary: var args = {} if params != null: @@ -6991,7 +6996,7 @@ static func fetch_products_args(params: ProductRequest) -> Dictionary: args["params"] = params return args -## List active purchases for the current user. +## List active purchases for the current user. See: https://openiap.dev/docs/apis/get-available-purchases static func get_available_purchases_args(options: Variant = null) -> Dictionary: var args = {} if options != null: @@ -7001,41 +7006,41 @@ static func get_available_purchases_args(options: Variant = null) -> Dictionary: args["options"] = options return args -## Get details of all currently active subscriptions (filters by subscriptionIds when provided). +## Get details of all currently active subscriptions (filters by subscriptionIds when provided). See: https://openiap.dev/docs/apis/get-active-subscriptions static func get_active_subscriptions_args(subscription_ids: Variant = null) -> Dictionary: var args = {} if subscription_ids != null: args["subscriptionIds"] = subscription_ids return args -## Check whether the user has any active subscription. +## Check whether the user has any active subscription. See: https://openiap.dev/docs/apis/has-active-subscriptions static func has_active_subscriptions_args(subscription_ids: Variant = null) -> Dictionary: var args = {} if subscription_ids != null: args["subscriptionIds"] = subscription_ids return args -## Return the store-authoritative country code: ISO 3166-1 alpha-3 on Apple +## Return the store-authoritative country code: ISO 3166-1 alpha-3 on Apple platforms and alpha-2 on Android. The operation fails when the store cannot provide a value; implementations must not synthesize a locale fallback. See: https://openiap.dev/docs/apis/get-storefront static func get_storefront_args() -> Dictionary: return {} -## Deprecated. Get the current App Store storefront ISO 3166-1 alpha-3 country +## Deprecated. Get the current App Store storefront ISO 3166-1 alpha-3 country code — use cross-platform getStorefront instead. See: https://openiap.dev/docs/apis/ios/get-storefront-ios @deprecated Use getStorefront static func get_storefront_ios_args() -> Dictionary: return {} -## Read the App Store-promoted product, if any (iOS 11+). +## Read the App Store-promoted product, if any (iOS 11+). See: https://openiap.dev/docs/apis/ios/get-promoted-product-ios static func get_promoted_product_ios_args() -> Dictionary: return {} -## Check eligibility for the external purchase notice sheet (iOS 17.4+). +## Check eligibility for the external purchase notice sheet (iOS 17.4+). Uses ExternalPurchase.canPresent. See: https://openiap.dev/docs/apis/ios/can-present-external-purchase-notice-ios static func can_present_external_purchase_notice_ios_args() -> Dictionary: return {} -## Check eligibility for the custom-link variant of external purchase (iOS 18.1+). +## Check eligibility for the custom-link variant of external purchase (iOS 18.1+). Returns true if the app can use custom external purchase links. Reference: https://developer.apple.com/documentation/storekit/externalpurchasecustomlink/iseligible See: https://openiap.dev/docs/apis/ios/is-eligible-for-external-purchase-custom-link-ios static func is_eligible_for_external_purchase_custom_link_ios_args() -> Dictionary: return {} -## Fetch a token for Apple's External Purchase Server reporting API (iOS 18.1+). +## Fetch a token for Apple's External Purchase Server reporting API (iOS 18.1+). Use this token to report transactions made through ExternalPurchaseCustomLink. Reference: https://developer.apple.com/documentation/storekit/externalpurchasecustomlink/token(for:) See: https://openiap.dev/docs/apis/ios/get-external-purchase-custom-link-token-ios static func get_external_purchase_custom_link_token_ios_args(token_type: ExternalPurchaseCustomLinkTokenTypeIOS) -> Dictionary: var args = {} if EXTERNAL_PURCHASE_CUSTOM_LINK_TOKEN_TYPE_IOS_VALUES.has(token_type): @@ -7044,59 +7049,59 @@ static func get_external_purchase_custom_link_token_ios_args(token_type: Externa args["tokenType"] = token_type return args -## List unfinished StoreKit transactions in the queue. +## List unfinished StoreKit transactions in the queue. See: https://openiap.dev/docs/apis/ios/get-pending-transactions-ios static func get_pending_transactions_ios_args() -> Dictionary: return {} -## Check intro-offer eligibility for a subscription group. +## Check intro-offer eligibility for a subscription group. See: https://openiap.dev/docs/apis/ios/is-eligible-for-intro-offer-ios static func is_eligible_for_intro_offer_ios_args(group_id: String) -> Dictionary: var args = {} args["groupID"] = group_id return args -## Get subscription status objects from StoreKit 2 (iOS 15+). +## Get subscription status objects from StoreKit 2 (iOS 15+). See: https://openiap.dev/docs/apis/ios/subscription-status-ios static func subscription_status_ios_args(sku: String) -> Dictionary: var args = {} args["sku"] = sku return args -## Get the user's current entitlement for a product, using StoreKit 2 (iOS 15+). +## Get the user's current entitlement for a product, using StoreKit 2 (iOS 15+). See: https://openiap.dev/docs/apis/ios/current-entitlement-ios static func current_entitlement_ios_args(sku: String) -> Dictionary: var args = {} args["sku"] = sku return args -## Get the latest verified transaction for a product, using StoreKit 2. +## Get the latest verified transaction for a product, using StoreKit 2. See: https://openiap.dev/docs/apis/ios/latest-transaction-ios static func latest_transaction_ios_args(sku: String) -> Dictionary: var args = {} args["sku"] = sku return args -## Check whether a transaction's JWS verification passed (StoreKit 2). +## Check whether a transaction's JWS verification passed (StoreKit 2). See: https://openiap.dev/docs/apis/ios/is-transaction-verified-ios static func is_transaction_verified_ios_args(sku: String) -> Dictionary: var args = {} args["sku"] = sku return args -## Return the JWS string for a transaction (StoreKit 2). +## Return the JWS string for a transaction (StoreKit 2). See: https://openiap.dev/docs/apis/ios/get-transaction-jws-ios static func get_transaction_jws_ios_args(sku: String) -> Dictionary: var args = {} args["sku"] = sku return args -## Get base64-encoded receipt data (legacy validation). +## Get base64-encoded receipt data (legacy validation). See: https://openiap.dev/docs/apis/ios/get-receipt-data-ios static func get_receipt_data_ios_args() -> Dictionary: return {} -## Fetch the app transaction (iOS 16+). +## Fetch the app transaction (iOS 16+). See: https://openiap.dev/docs/apis/ios/get-app-transaction-ios static func get_app_transaction_ios_args() -> Dictionary: return {} -## List every StoreKit transaction (finished + unfinished) for the current user. +## List every StoreKit transaction (finished + unfinished) for the current user. Requires the SKIncludeConsumableInAppPurchaseHistory Info.plist key in the host app for finished consumables to be included (iOS 18+). Unlike getAvailablePurchases, always returns the iOS-specific PurchaseIOS shape. See: https://openiap.dev/docs/apis/ios/get-all-transactions-ios static func get_all_transactions_ios_args() -> Dictionary: return {} -## Deprecated. Legacy App Store receipt validation — use verifyPurchase instead. +## Deprecated. Legacy App Store receipt validation — use verifyPurchase instead. See: https://openiap.dev/docs/apis/ios/validate-receipt-ios @deprecated Use verifyPurchase static func validate_receipt_ios_args(options: VerifyPurchaseProps) -> Dictionary: var args = {} if options != null: @@ -7106,7 +7111,7 @@ static func validate_receipt_ios_args(options: VerifyPurchaseProps) -> Dictionar args["options"] = options return args -## Fetch Play Billing assets and loyalty text for developer-rendered Billing Choice screens. +## Fetch Play Billing assets and loyalty text for developer-rendered Billing Choice screens. OpenIAP availability: Spec 2.1.0 / openiap-google 2.3.0 (requires Play Billing 9.1.0+). Throws OpenIapError.NotPrepared if billing client is not ready. See: https://openiap.dev/docs/apis/android/get-billing-choice-info-android static func get_billing_choice_info_android_args(params: GetBillingChoiceInfoParamsAndroid) -> Dictionary: var args = {} if params != null: @@ -7118,7 +7123,7 @@ static func get_billing_choice_info_android_args(params: GetBillingChoiceInfoPar # Mutation API helpers -## Initialize the store connection. Call before any IAP API. +## Initialize the store connection. Call before any IAP API. See: https://openiap.dev/docs/apis/init-connection static func init_connection_args(config: Variant = null) -> Dictionary: var args = {} if config != null: @@ -7128,11 +7133,11 @@ static func init_connection_args(config: Variant = null) -> Dictionary: args["config"] = config return args -## Close the store connection and release resources. +## Close the store connection and release resources. See: https://openiap.dev/docs/apis/end-connection static func end_connection_args() -> Dictionary: return {} -## Initiate a purchase or subscription flow; rely on events for final state. +## Initiate a purchase or subscription flow; rely on events for final state. See: https://openiap.dev/docs/apis/request-purchase static func request_purchase_args(params: RequestPurchaseProps) -> Dictionary: var args = {} if params != null: @@ -7142,7 +7147,7 @@ static func request_purchase_args(params: RequestPurchaseProps) -> Dictionary: args["params"] = params return args -## Complete a transaction after server-side verification. Required on Android within 3 days. +## Complete a transaction after server-side verification. Required on Android within 3 days. See: https://openiap.dev/docs/apis/finish-transaction static func finish_transaction_args(purchase: PurchaseInput, is_consumable: Variant = null) -> Dictionary: var args = {} if purchase != null: @@ -7154,11 +7159,11 @@ static func finish_transaction_args(purchase: PurchaseInput, is_consumable: Vari args["isConsumable"] = is_consumable return args -## Restore non-consumable and active subscription purchases. +## Restore non-consumable and active subscription purchases. See: https://openiap.dev/docs/apis/restore-purchases static func restore_purchases_args() -> Dictionary: return {} -## Open the platform's subscription management UI. +## Open the platform's subscription management UI. See: https://openiap.dev/docs/apis/deep-link-to-subscriptions static func deep_link_to_subscriptions_args(options: Variant = null) -> Dictionary: var args = {} if options != null: @@ -7168,7 +7173,7 @@ static func deep_link_to_subscriptions_args(options: Variant = null) -> Dictiona args["options"] = options return args -## Deprecated. Validate purchase receipts with the configured providers — use verifyPurchase instead. +## Deprecated. Validate purchase receipts with the configured providers — use verifyPurchase instead. See: https://openiap.dev/docs/features/validation#verify-purchase @deprecated Use verifyPurchase static func validate_receipt_args(options: VerifyPurchaseProps) -> Dictionary: var args = {} if options != null: @@ -7178,7 +7183,7 @@ static func validate_receipt_args(options: VerifyPurchaseProps) -> Dictionary: args["options"] = options return args -## Verify a purchase against your own backend. Returns a platform-specific +## Verify a purchase against your own backend. Returns a platform-specific variant of VerifyPurchaseResult — VerifyPurchaseResultIOS exposes isValid + receipt/JWS metadata, VerifyPurchaseResultAndroid carries Play Store receipt fields (no isValid), and VerifyPurchaseResultHorizon uses success. Inspect the concrete variant before reading fields. See: https://openiap.dev/docs/features/validation#verify-purchase static func verify_purchase_args(options: VerifyPurchaseProps) -> Dictionary: var args = {} if options != null: @@ -7188,7 +7193,7 @@ static func verify_purchase_args(options: VerifyPurchaseProps) -> Dictionary: args["options"] = options return args -## Verify via a managed provider without standing up your own server. The +## Verify via a managed provider without standing up your own server. The PurchaseVerificationProvider enum currently exposes only IAPKit; platform availability may differ by implementation. See: https://openiap.dev/docs/features/validation#verify-purchase-with-provider static func verify_purchase_with_provider_args(options: VerifyPurchaseWithProviderProps) -> Dictionary: var args = {} if options != null: @@ -7198,43 +7203,43 @@ static func verify_purchase_with_provider_args(options: VerifyPurchaseWithProvid args["options"] = options return args -## Clear pending transactions in the queue (sandbox helper). +## Clear pending transactions in the queue (sandbox helper). See: https://openiap.dev/docs/apis/ios/clear-transaction-ios static func clear_transaction_ios_args() -> Dictionary: return {} -## Buy the currently promoted product. +## Buy the currently promoted product. See: https://openiap.dev/docs/apis/ios/request-purchase-on-promoted-product-ios @deprecated Use promotedProductListenerIOS to receive the productId, then call requestPurchase with that SKU instead. In StoreKit 2, promoted products can be purchased directly via the standard purchase flow. static func request_purchase_on_promoted_product_ios_args() -> Dictionary: return {} -## Present the manage-subscriptions sheet and return changed purchases (iOS 15+). +## Present the manage-subscriptions sheet and return changed purchases (iOS 15+). See: https://openiap.dev/docs/apis/ios/show-manage-subscriptions-ios static func show_manage_subscriptions_ios_args() -> Dictionary: return {} -## Present the refund request sheet (iOS 15+). See also Features → Refund. +## Present the refund request sheet (iOS 15+). See also Features → Refund. See: https://openiap.dev/docs/apis/ios/begin-refund-request-ios static func begin_refund_request_ios_args(sku: String) -> Dictionary: var args = {} args["sku"] = sku return args -## Force sync transactions with the App Store (iOS 15+). +## Force sync transactions with the App Store (iOS 15+). See: https://openiap.dev/docs/apis/ios/sync-ios static func sync_ios_args() -> Dictionary: return {} -## Show the App Store offer code redemption sheet. +## Show the App Store offer code redemption sheet. See: https://openiap.dev/docs/apis/ios/present-code-redemption-sheet-ios static func present_code_redemption_sheet_ios_args() -> Dictionary: return {} -## Present the external purchase notice sheet (iOS 17.4+). +## Present the external purchase notice sheet (iOS 17.4+). Uses ExternalPurchase.presentNoticeSheet() which returns a token when the user continues. Reference: https://developer.apple.com/documentation/storekit/externalpurchase/presentnoticesheet() See: https://openiap.dev/docs/apis/ios/present-external-purchase-notice-sheet-ios static func present_external_purchase_notice_sheet_ios_args() -> Dictionary: return {} -## Present an external purchase link, StoreKit External (iOS 16+). +## Present an external purchase link, StoreKit External (iOS 16+). See: https://openiap.dev/docs/apis/ios/present-external-purchase-link-ios static func present_external_purchase_link_ios_args(url: String) -> Dictionary: var args = {} args["url"] = url return args -## Present the disclosure sheet required before linking out via ExternalPurchaseCustomLink (iOS 18.1+). +## Present the disclosure sheet required before linking out via ExternalPurchaseCustomLink (iOS 18.1+). Call this after a deliberate customer interaction before linking out to external purchases. Reference: https://developer.apple.com/documentation/storekit/externalpurchasecustomlink/shownotice(type:) See: https://openiap.dev/docs/apis/ios/show-external-purchase-custom-link-notice-ios static func show_external_purchase_custom_link_notice_ios_args(notice_type: ExternalPurchaseCustomLinkNoticeTypeIOS) -> Dictionary: var args = {} if EXTERNAL_PURCHASE_CUSTOM_LINK_NOTICE_TYPE_IOS_VALUES.has(notice_type): @@ -7243,31 +7248,31 @@ static func show_external_purchase_custom_link_notice_ios_args(notice_type: Exte args["noticeType"] = notice_type return args -## Acknowledge a non-consumable purchase. Required within 3 days or Google auto-refunds. +## Acknowledge a non-consumable purchase. Required within 3 days or Google auto-refunds. See: https://openiap.dev/docs/apis/android/acknowledge-purchase-android static func acknowledge_purchase_android_args(purchase_token: String) -> Dictionary: var args = {} args["purchaseToken"] = purchase_token return args -## Consume a consumable purchase so it can be re-bought. +## Consume a consumable purchase so it can be re-bought. See: https://openiap.dev/docs/apis/android/consume-purchase-android static func consume_purchase_android_args(purchase_token: String) -> Dictionary: var args = {} args["purchaseToken"] = purchase_token return args -## Check whether alternative billing is available for the user. Step 1 of the alternative billing flow. +## Check whether alternative billing is available for the user. Step 1 of the alternative billing flow. Returns true if available, false otherwise. Throws OpenIapError.NotPrepared if billing client not ready. See: https://openiap.dev/docs/apis/android/check-alternative-billing-availability-android static func check_alternative_billing_availability_android_args() -> Dictionary: return {} -## Display Google's alternative billing information dialog. Step 2 of the alternative billing flow. +## Display Google's alternative billing information dialog. Step 2 of the alternative billing flow. Must be called BEFORE processing payment in your payment system. Returns true if user accepted, false if user canceled. Throws OpenIapError.NotPrepared if billing client not ready. See: https://openiap.dev/docs/apis/android/show-alternative-billing-dialog-android static func show_alternative_billing_dialog_android_args() -> Dictionary: return {} -## Create a reporting token for an alternative billing flow. Step 3 of the alternative billing flow. +## Create a reporting token for an alternative billing flow. Step 3 of the alternative billing flow. Must be called AFTER successful payment in your payment system. Token must be reported to Google Play backend within 24 hours. Returns token string, or null if creation failed. Throws OpenIapError.NotPrepared if billing client not ready. See: https://openiap.dev/docs/apis/android/create-alternative-billing-token-android static func create_alternative_billing_token_android_args() -> Dictionary: return {} -## Check whether a billing program (e.g., External Payments) is available for the current user. +## Check whether a billing program (e.g., External Payments) is available for the current user. Replaces the deprecated isExternalOfferAvailableAsync API. Introduced in Google Play Billing Library 8.2.0. External Offer and External Content Link integrations must use 8.2.1+ because 8.2.1 fixes this API. Returns availability result with isAvailable flag. Throws OpenIapError.NotPrepared if billing client not ready. See: https://openiap.dev/docs/apis/android/is-billing-program-available-android static func is_billing_program_available_android_args(program: BillingProgramAndroid) -> Dictionary: var args = {} if BILLING_PROGRAM_ANDROID_VALUES.has(program): @@ -7276,7 +7281,7 @@ static func is_billing_program_available_android_args(program: BillingProgramAnd args["program"] = program return args -## Create the reporting details and external transaction token required by a billing program. +## Create the reporting details and external transaction token required by a billing program. Introduced in Play Billing 8.2.0. External Offer and External Content Link integrations must use 8.2.1+ and create fresh details immediately before every redirect session; do not cache the token for a later redirect. The same token may report multiple purchases made during one External Offer session. Replaces the deprecated createExternalOfferReportingDetailsAsync API. Returns external transaction token needed for reporting external transactions. developerBillingType is optional. When program is BILLING_CHOICE and developerBillingType is omitted, native Android defaults it to IN_APP. The Billing Choice extension is available in OpenIAP Spec 2.1.0 / openiap-google 2.3.0 (requires Play Billing 9.1.0+). Throws OpenIapError.NotPrepared if billing client not ready. See: https://openiap.dev/docs/apis/android/create-billing-program-reporting-details-android static func create_billing_program_reporting_details_android_args(program: BillingProgramAndroid, developer_billing_type: Variant = null) -> Dictionary: var args = {} if BILLING_PROGRAM_ANDROID_VALUES.has(program): @@ -7290,7 +7295,7 @@ static func create_billing_program_reporting_details_android_args(program: Billi args["developerBillingType"] = developer_billing_type return args -## Launch an external content/offer link from inside the Billing Programs flow (introduced in +## Launch an external content/offer link from inside the Billing Programs flow (introduced in Play Billing 8.2.0; External Offer and External Content Link require 8.2.1+), including developer-rendered Billing Choice external-link flows. Billing Choice availability: OpenIAP Spec 2.1.0 / openiap-google 2.3.0 (requires Play Billing 9.1.0+). Replaces the deprecated showExternalOfferInformationDialog API. Shows Play Store dialog and optionally launches external URL. Throws OpenIapError.NotPrepared if billing client not ready. See: https://openiap.dev/docs/apis/android/launch-external-link-android static func launch_external_link_android_args(params: LaunchExternalLinkParamsAndroid) -> Dictionary: var args = {} if params != null: @@ -7300,11 +7305,11 @@ static func launch_external_link_android_args(params: LaunchExternalLinkParamsAn args["params"] = params return args -## Open the Google Play offer/promo code redemption flow so the user can enter a code. +## Open the Google Play offer/promo code redemption flow so the user can enter a code. On Google Play builds, launches the Play Store redeem page (https://play.google.com/redeem). A purchase listener can receive the redeemed purchase while the app is running with an active billing connection; always reconcile with getAvailablePurchases when the app resumes. Does not require the billing client to be initialized (no Play Billing version requirement). Planned OpenIAP availability: Spec 2.5.0 / openiap-google 2.5.0. Android counterpart of presentCodeRedemptionSheetIOS. Returns true when the redemption flow was launched, or false when the current store flavor does not provide an equivalent redemption flow. See: https://openiap.dev/docs/apis/android/open-redeem-offer-code-android static func open_redeem_offer_code_android_args() -> Dictionary: return {} -## Show Google's mandatory information dialog before a developer-rendered, +## Show Google's mandatory information dialog before a developer-rendered, in-app Billing Choice screen. OpenIAP availability: Spec 2.1.0 / openiap-google 2.3.0 (requires Play Billing 9.1.0+). Throws OpenIapError.NotPrepared if billing client not ready. See: https://openiap.dev/docs/apis/android/show-billing-program-information-dialog-android static func show_billing_program_information_dialog_android_args(params: BillingProgramInformationDialogParamsAndroid) -> Dictionary: var args = {} if params != null: @@ -7314,7 +7319,7 @@ static func show_billing_program_information_dialog_android_args(params: Billing args["params"] = params return args -## Overlay Play billing in-app messages, such as payment issues or subscription price-change confirmations. +## Overlay Play billing in-app messages, such as payment issues or subscription price-change confirmations. OpenIAP availability: Spec 2.1.0 / openiap-google 2.3.0 (upstream API available since Play Billing 4.1.0). Returns a response code and, when the subscription status changes, the related purchase token. Throws OpenIapError.NotPrepared if billing client not ready. See: https://openiap.dev/docs/apis/android/show-in-app-messages-android static func show_in_app_messages_android_args(params: Variant = null) -> Dictionary: var args = {} if params != null: diff --git a/packages/gql/src/generated/types.ts b/packages/gql/src/generated/types.ts index a9e259cf7..abed5a93e 100644 --- a/packages/gql/src/generated/types.ts +++ b/packages/gql/src/generated/types.ts @@ -1,6 +1,6 @@ // ============================================================================ // AUTO-GENERATED TYPES — DO NOT EDIT DIRECTLY -// Run `npm run generate` after updating any *.graphql schema file. +// Refresh this file with the generated-types workflow documented for your checkout. // ============================================================================ export interface ActiveSubscription { @@ -30,9 +30,9 @@ export interface ActiveSubscription { transactionDate: number; transactionId: string; /** - * @deprecated iOS only - use daysUntilExpirationIOS instead. * Whether the subscription will expire soon (within 7 days). * Consider using daysUntilExpirationIOS for more precise control. + * @deprecated iOS only - use daysUntilExpirationIOS instead. */ willExpireSoon?: (boolean | null); } @@ -90,8 +90,8 @@ export interface AdvancedCommerceRefundIOS { /** * Alternative billing mode for Android * Controls which billing system is used - * @deprecated Use enableBillingProgramAndroid with BillingProgramAndroid instead. * Use USER_CHOICE_BILLING for user choice billing, EXTERNAL_OFFER for alternative only. + * @deprecated Use enableBillingProgramAndroid with BillingProgramAndroid instead. */ export type AlternativeBillingModeAndroid = 'none' | 'user-choice' | 'alternative-only'; @@ -327,8 +327,8 @@ export interface DiscountDisplayInfoAndroid { /** * Discount information returned from the store. - * @deprecated Use the standardized SubscriptionOffer type instead for cross-platform compatibility. * @see https://openiap.dev/docs/types/subscription-offer + * @deprecated Use the standardized SubscriptionOffer type instead for cross-platform compatibility. */ export interface DiscountIOS { identifier: string; @@ -407,7 +407,11 @@ export interface DiscountOffer { purchaseOptionIdAndroid?: (string | null); /** [Android] Rental details if this is a rental offer. */ rentalDetailsAndroid?: (RentalDetailsAndroid | null); - /** Type of discount offer */ + /** + * Offer category. DiscountOffer currently represents Android one-time product + * offers and is populated as OneTime. Introductory and Promotional are used by + * SubscriptionOffer. + */ type: DiscountOfferType; /** * [Android] Valid time window for the offer. @@ -418,8 +422,8 @@ export interface DiscountOffer { /** * iOS DiscountOffer (output type). - * @deprecated Use the standardized SubscriptionOffer type instead for cross-platform compatibility. * @see https://openiap.dev/docs/types/subscription-offer + * @deprecated Use the standardized SubscriptionOffer type instead for cross-platform compatibility. */ export interface DiscountOfferIOS { /** Discount identifier */ @@ -485,8 +489,11 @@ export enum ErrorCode { PurchaseVerificationFinishFailed = 'purchase-verification-finish-failed', PurchaseVerificationFinished = 'purchase-verification-finished', QueryProduct = 'query-product', + /** @deprecated Use PurchaseVerificationFailed instead */ ReceiptFailed = 'receipt-failed', + /** @deprecated Use PurchaseVerificationFinished instead */ ReceiptFinished = 'receipt-finished', + /** @deprecated Use PurchaseVerificationFinishFailed instead */ ReceiptFinishedFailed = 'receipt-finished-failed', RemoteError = 'remote-error', ServiceDisconnected = 'service-disconnected', @@ -518,8 +525,8 @@ export type ExternalLinkTypeAndroid = 'unspecified' | 'link-to-digital-content-o /** * External offer availability result (Android) - * @deprecated Use BillingProgramAvailabilityResultAndroid with isBillingProgramAvailableAsync instead * Available in Google Play Billing Library 6.2.0+, deprecated in 8.2.0 + * @deprecated Use BillingProgramAvailabilityResultAndroid with isBillingProgramAvailableAsync instead */ export interface ExternalOfferAvailabilityResultAndroid { /** Whether external offers are available for the user */ @@ -528,8 +535,8 @@ export interface ExternalOfferAvailabilityResultAndroid { /** * External offer reporting details (Android) - * @deprecated Use BillingProgramReportingDetailsAndroid with createBillingProgramReportingDetailsAsync instead * Available in Google Play Billing Library 6.2.0+, deprecated in 8.2.0 + * @deprecated Use BillingProgramReportingDetailsAndroid with createBillingProgramReportingDetailsAsync instead */ export interface ExternalOfferReportingDetailsAndroid { /** External transaction token for reporting external offer transactions */ @@ -676,8 +683,8 @@ export interface InitConnectionConfig { /** * Alternative billing mode for Android * If not specified, defaults to NONE (standard Google Play billing) - * @deprecated Use enableBillingProgramAndroid instead. * Use USER_CHOICE_BILLING for user choice billing, EXTERNAL_OFFER for alternative only. + * @deprecated Use enableBillingProgramAndroid instead. */ alternativeBillingModeAndroid?: (AlternativeBillingModeAndroid | null); /** @@ -892,10 +899,8 @@ export interface Mutation { /** * Buy the currently promoted product. * - * @deprecated Use promotedProductListenerIOS to receive the productId, - * then call requestPurchase with that SKU instead. In StoreKit 2, - * promoted products can be purchased directly via the standard purchase flow. * See: https://openiap.dev/docs/apis/ios/request-purchase-on-promoted-product-ios + * @deprecated Use promotedProductListenerIOS to receive the productId, then call requestPurchase with that SKU instead. In StoreKit 2, promoted products can be purchased directly via the standard purchase flow. */ requestPurchaseOnPromotedProductIOS: Promise; /** @@ -969,8 +974,6 @@ export interface Mutation { verifyPurchaseWithProvider: Promise; } - - export type MutationAcknowledgePurchaseAndroidArgs = string; export type MutationBeginRefundRequestIosArgs = string; @@ -982,7 +985,6 @@ export interface MutationCreateBillingProgramReportingDetailsAndroidArgs { program: BillingProgramAndroid; } - export type MutationDeepLinkToSubscriptionsArgs = (DeepLinkOptions | null) | undefined; export interface MutationFinishTransactionArgs { @@ -990,7 +992,6 @@ export interface MutationFinishTransactionArgs { purchase: PurchaseInput; } - export type MutationInitConnectionArgs = (InitConnectionConfig | null) | undefined; export type MutationIsBillingProgramAvailableAndroidArgs = BillingProgramAndroid; @@ -999,22 +1000,7 @@ export type MutationLaunchExternalLinkAndroidArgs = LaunchExternalLinkParamsAndr export type MutationPresentExternalPurchaseLinkIosArgs = string; -export type MutationRequestPurchaseArgs = - | { - /** Per-platform purchase request props */ - request: RequestPurchasePropsByPlatforms; - type: 'in-app'; - /** Use alternative billing (Google Play alternative billing, Apple external purchase link) */ - useAlternativeBilling?: boolean | null; - } - | { - /** Per-platform subscription request props */ - request: RequestSubscriptionPropsByPlatforms; - type: 'subs'; - /** Use alternative billing (Google Play alternative billing, Apple external purchase link) */ - useAlternativeBilling?: boolean | null; - }; - +export type MutationRequestPurchaseArgs = RequestPurchaseProps; export type MutationShowBillingProgramInformationDialogAndroidArgs = BillingProgramInformationDialogParamsAndroid; @@ -1118,9 +1104,7 @@ export interface ProductAndroid extends ProductCommon { * Available in Google Play Billing Library 8.0.0+ */ productStatusAndroid?: (ProductStatusAndroid | null); - /** - * @deprecated Use subscriptionOffers instead for cross-platform compatibility. - */ + /** @deprecated Use subscriptionOffers instead for cross-platform compatibility. */ subscriptionOfferDetailsAndroid?: (ProductSubscriptionAndroidOfferDetails[] | null); /** * Standardized subscription offers. @@ -1135,8 +1119,8 @@ export interface ProductAndroid extends ProductCommon { /** * One-time purchase offer details (Android). * Available in Google Play Billing Library 8.0+ - * @deprecated Use the standardized DiscountOffer type for Android one-time offers. * @see https://openiap.dev/docs/types/discount-offer + * @deprecated Use the standardized DiscountOffer type for Android one-time offers. */ export interface ProductAndroidOneTimePurchaseOfferDetail { /** @@ -1207,9 +1191,7 @@ export interface ProductIOS extends ProductCommon { * monthly subscriptions with a 12-month commitment. */ pricingTermsIOS?: (SubscriptionPricingTermsIOS[] | null); - /** - * @deprecated Use subscriptionOffers instead for cross-platform compatibility. - */ + /** @deprecated Use subscriptionOffers instead for cross-platform compatibility. */ subscriptionInfoIOS?: (SubscriptionInfoIOS | null); /** * Standardized subscription offers. @@ -1258,8 +1240,7 @@ export interface ProductSubscriptionAndroid extends ProductCommon { /** * Legacy nullable compatibility field. Google Play does not populate one-time * purchase offer details for subscription products. - * @deprecated One-time offers belong to ProductAndroid.discountOffers; - * subscriptions use subscriptionOffers. + * @deprecated One-time offers belong to ProductAndroid.discountOffers; subscriptions use subscriptionOffers. */ oneTimePurchaseOfferDetailsAndroid?: (ProductAndroidOneTimePurchaseOfferDetail[] | null); platform: 'android'; @@ -1272,9 +1253,7 @@ export interface ProductSubscriptionAndroid extends ProductCommon { * Available in Google Play Billing Library 8.0.0+ */ productStatusAndroid?: (ProductStatusAndroid | null); - /** - * @deprecated Use subscriptionOffers instead for cross-platform compatibility. - */ + /** @deprecated Use subscriptionOffers instead for cross-platform compatibility. */ subscriptionOfferDetailsAndroid: ProductSubscriptionAndroidOfferDetails[]; /** * Standardized subscription offers. @@ -1288,8 +1267,8 @@ export interface ProductSubscriptionAndroid extends ProductCommon { /** * Subscription offer details (Android). - * @deprecated Use the standardized SubscriptionOffer type instead for cross-platform compatibility. * @see https://openiap.dev/docs/types/subscription-offer + * @deprecated Use the standardized SubscriptionOffer type instead for cross-platform compatibility. */ export interface ProductSubscriptionAndroidOfferDetails { basePlanId: string; @@ -1309,9 +1288,7 @@ export interface ProductSubscriptionIOS extends ProductCommon { currency: string; debugDescription?: (string | null); description: string; - /** - * @deprecated Use subscriptionOffers instead for cross-platform compatibility. - */ + /** @deprecated Use subscriptionOffers instead for cross-platform compatibility. */ discountsIOS?: (DiscountIOS[] | null); displayName?: (string | null); displayNameIOS: string; @@ -1333,9 +1310,7 @@ export interface ProductSubscriptionIOS extends ProductCommon { pricingTermsIOS?: (SubscriptionPricingTermsIOS[] | null); /** App Store subscription group identifier for intro-offer eligibility checks. */ subscriptionGroupIdIOS?: (string | null); - /** - * @deprecated Use subscriptionOffers for offer metadata and subscriptionGroupIdIOS for the App Store subscription group identifier. - */ + /** @deprecated Use subscriptionOffers for offer metadata and subscriptionGroupIdIOS for the App Store subscription group identifier. */ subscriptionInfoIOS?: (SubscriptionInfoIOS | null); /** * Standardized subscription offers. @@ -1663,8 +1638,6 @@ export interface Query { validateReceiptIOS: Promise; } - - export type QueryCurrentEntitlementIosArgs = string; export type QueryFetchProductsArgs = ProductRequest; @@ -1825,15 +1798,23 @@ export type RequestPurchaseProps = | { /** Per-platform purchase request props */ request: RequestPurchasePropsByPlatforms; + /** Explicit purchase type hint (defaults to in-app) */ type: 'in-app'; - /** Use alternative billing (Google Play alternative billing, Apple external purchase link) */ + /** + * This flag only logs debug info and has no effect on the purchase flow. + * @deprecated Use enableBillingProgramAndroid in InitConnectionConfig instead. + */ useAlternativeBilling?: boolean | null; } | { /** Per-platform subscription request props */ request: RequestSubscriptionPropsByPlatforms; + /** Explicit purchase type hint (defaults to in-app) */ type: 'subs'; - /** Use alternative billing (Google Play alternative billing, Apple external purchase link) */ + /** + * This flag only logs debug info and has no effect on the purchase flow. + * @deprecated Use enableBillingProgramAndroid in InitConnectionConfig instead. + */ useAlternativeBilling?: boolean | null; }; @@ -1885,7 +1866,7 @@ export interface RequestSubscriptionAndroidProps { purchaseToken?: (string | null); /** * Replacement mode for subscription changes - * @deprecated Use subscriptionProductReplacementParams instead for item-level replacement (8.1.0+) + * @deprecated Use subscriptionProductReplacementParams instead for item-level replacement (8.1.0+). */ replacementMode?: (number | null); /** List of subscription SKUs */ @@ -2091,8 +2072,6 @@ export interface Subscription { userChoiceBillingAndroid: UserChoiceBillingDetails; } - - export type SubscriptionPurchaseUpdatedArgs = (PurchaseUpdatedListenerOptions | null) | undefined; export type SubscriptionBillingPlanTypeIOS = 'unknown' | 'monthly' | 'up-front'; @@ -2193,8 +2172,8 @@ export interface SubscriptionOffer { /** * iOS subscription offer details. - * @deprecated Use the standardized SubscriptionOffer type instead for cross-platform compatibility. * @see https://openiap.dev/docs/types/subscription-offer + * @deprecated Use the standardized SubscriptionOffer type instead for cross-platform compatibility. */ export interface SubscriptionOfferIOS { displayPrice: string; @@ -2508,44 +2487,6 @@ export interface WinBackOfferInputIOS { /** The win-back offer ID from App Store Connect */ offerId: string; } -// -- Query helper types (auto-generated) -export type QueryArgsMap = { - canPresentExternalPurchaseNoticeIOS: never; - currentEntitlementIOS: QueryCurrentEntitlementIosArgs; - fetchProducts: QueryFetchProductsArgs; - getActiveSubscriptions: QueryGetActiveSubscriptionsArgs; - getAllTransactionsIOS: never; - getAppTransactionIOS: never; - getAvailablePurchases: QueryGetAvailablePurchasesArgs; - getBillingChoiceInfoAndroid: QueryGetBillingChoiceInfoAndroidArgs; - getExternalPurchaseCustomLinkTokenIOS: QueryGetExternalPurchaseCustomLinkTokenIosArgs; - getPendingTransactionsIOS: never; - getPromotedProductIOS: never; - getReceiptDataIOS: never; - getStorefront: never; - getStorefrontIOS: never; - getTransactionJwsIOS: QueryGetTransactionJwsIosArgs; - hasActiveSubscriptions: QueryHasActiveSubscriptionsArgs; - isEligibleForExternalPurchaseCustomLinkIOS: never; - isEligibleForIntroOfferIOS: QueryIsEligibleForIntroOfferIosArgs; - isTransactionVerifiedIOS: QueryIsTransactionVerifiedIosArgs; - latestTransactionIOS: QueryLatestTransactionIosArgs; - subscriptionStatusIOS: QuerySubscriptionStatusIosArgs; - validateReceiptIOS: QueryValidateReceiptIosArgs; -}; - -export type QueryField = - QueryArgsMap[K] extends never - ? () => NonNullable - : undefined extends QueryArgsMap[K] - ? (args?: QueryArgsMap[K]) => NonNullable - : (args: QueryArgsMap[K]) => NonNullable; - -export type QueryFieldMap = { - [K in keyof Query]?: QueryField; -}; -// -- End query helper types - // -- Mutation helper types (auto-generated) export type MutationArgsMap = { acknowledgePurchaseAndroid: MutationAcknowledgePurchaseAndroidArgs; @@ -2591,6 +2532,44 @@ export type MutationFieldMap = { }; // -- End mutation helper types +// -- Query helper types (auto-generated) +export type QueryArgsMap = { + canPresentExternalPurchaseNoticeIOS: never; + currentEntitlementIOS: QueryCurrentEntitlementIosArgs; + fetchProducts: QueryFetchProductsArgs; + getActiveSubscriptions: QueryGetActiveSubscriptionsArgs; + getAllTransactionsIOS: never; + getAppTransactionIOS: never; + getAvailablePurchases: QueryGetAvailablePurchasesArgs; + getBillingChoiceInfoAndroid: QueryGetBillingChoiceInfoAndroidArgs; + getExternalPurchaseCustomLinkTokenIOS: QueryGetExternalPurchaseCustomLinkTokenIosArgs; + getPendingTransactionsIOS: never; + getPromotedProductIOS: never; + getReceiptDataIOS: never; + getStorefront: never; + getStorefrontIOS: never; + getTransactionJwsIOS: QueryGetTransactionJwsIosArgs; + hasActiveSubscriptions: QueryHasActiveSubscriptionsArgs; + isEligibleForExternalPurchaseCustomLinkIOS: never; + isEligibleForIntroOfferIOS: QueryIsEligibleForIntroOfferIosArgs; + isTransactionVerifiedIOS: QueryIsTransactionVerifiedIosArgs; + latestTransactionIOS: QueryLatestTransactionIosArgs; + subscriptionStatusIOS: QuerySubscriptionStatusIosArgs; + validateReceiptIOS: QueryValidateReceiptIosArgs; +}; + +export type QueryField = + QueryArgsMap[K] extends never + ? () => NonNullable + : undefined extends QueryArgsMap[K] + ? (args?: QueryArgsMap[K]) => NonNullable + : (args: QueryArgsMap[K]) => NonNullable; + +export type QueryFieldMap = { + [K in keyof Query]?: QueryField; +}; +// -- End query helper types + // -- Subscription helper types (auto-generated) export type SubscriptionArgsMap = { developerProvidedBillingAndroid: never; diff --git a/packages/gql/src/kotlin-platform-postprocess.test.mjs b/packages/gql/src/kotlin-platform-postprocess.test.mjs new file mode 100644 index 000000000..cd1be48f8 --- /dev/null +++ b/packages/gql/src/kotlin-platform-postprocess.test.mjs @@ -0,0 +1,121 @@ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { GENERATED_SYNC_MANIFEST } from '../generated-sync-manifest.mjs'; +import { postProcessKotlinSource } from '../scripts/kotlin-platform-postprocess.mjs'; + +const repositoryRoot = resolve(import.meta.dirname, '../../..'); +const read = (path) => readFileSync(resolve(repositoryRoot, path), 'utf8'); +const canonical = read(GENERATED_SYNC_MANIFEST.kotlin.source); + +describe('Kotlin platform post-processing', () => { + it('reproduces the checked-in Google target exactly', () => { + expect(postProcessKotlinSource(canonical, 'google')).toBe(read(GENERATED_SYNC_MANIFEST.kotlin.targets.google.path)); + }); + + it('reproduces the checked-in KMP target exactly', () => { + expect(postProcessKotlinSource(canonical, 'kmp')).toBe(read(GENERATED_SYNC_MANIFEST.kotlin.targets.kmp.path)); + }); + + it('owns package placement, enum semicolons, and published Google aliases', () => { + const fixture = `// generated +@file:Suppress("UNCHECKED_CAST") + +public enum class ExampleValue(val rawValue: String) { + FirstValue("legacy-value"), + LastValue("last-value") + + companion object { + fun fromJson(value: String): ExampleValue = when (value) { + "legacy-value" -> ExampleValue.FirstValue + "LEGACY_VALUE" -> ExampleValue.FirstValue + "last-value" -> ExampleValue.LastValue + else -> throw IllegalArgumentException() + } + } +} +`; + + const google = postProcessKotlinSource(fixture, 'google'); + expect(google).toContain('@file:Suppress("UNCHECKED_CAST")\npackage dev.hyo.openiap'); + expect(google).toContain('LastValue("last-value");'); + expect(google).toContain('"legacy-value" -> ExampleValue.FirstValue'); + expect(google).toContain('"FirstValue" -> ExampleValue.FirstValue'); + expect(google).toContain('"LEGACY_VALUE" -> ExampleValue.FirstValue'); + + const kmp = postProcessKotlinSource(fixture, 'kmp'); + expect(kmp).toContain('@file:Suppress("UNCHECKED_CAST")\n\npackage io.github.hyochan.kmpiap.openiap'); + expect(kmp).toContain('LastValue("last-value");'); + expect(kmp).toContain('"LEGACY_VALUE" -> ExampleValue.FirstValue'); + }); + + it('rejects unknown profiles and multiple package declarations', () => { + expect(() => postProcessKotlinSource(canonical, 'unknown')).toThrow('Unknown Kotlin platform post-process profile'); + expect(() => postProcessKotlinSource('package one\npackage two\n', 'kmp')).toThrow('multiple package declarations'); + }); + + it('rewrites compact when(value) parsers after verifying every raw value', () => { + const fixture = `public enum class ExampleValue(val rawValue: String) { + FirstValue("legacy-value") + + companion object { + fun fromJson(value: String): ExampleValue = when(value) { + "legacy-value" -> ExampleValue.FirstValue + else -> throw IllegalArgumentException() + } + } +} +`; + + const google = postProcessKotlinSource(fixture, 'google'); + expect(google).toContain('FirstValue("legacy-value");'); + expect(google).toContain('"legacy-value" -> ExampleValue.FirstValue'); + expect(google).toContain('"FirstValue" -> ExampleValue.FirstValue'); + }); + + it('fails closed when a Google enum parser cannot be verified', () => { + const fixture = `public enum class ExampleValue(val rawValue: String) { + FirstValue("legacy-value") + + companion object { + fun fromJson(value: String): ExampleValue = parseLegacy(value) + } +} +`; + + expect(() => postProcessKotlinSource(fixture, 'google')).toThrow('ExampleValue is missing fromJson when(value) parsing'); + }); + + it('fails closed when a Google enum raw value does not round-trip', () => { + const fixture = `public enum class ExampleValue(val rawValue: String) { + FirstValue("first-value") + + companion object { + fun fromJson(value: String): ExampleValue = when(value) { + "legacy-value" -> ExampleValue.FirstValue + else -> throw IllegalArgumentException() + } + } +} +`; + + expect(() => postProcessKotlinSource(fixture, 'google')).toThrow('ExampleValue.FirstValue raw value "first-value" does not round-trip'); + }); + + it('fails closed when a Google enum parser references an unknown constant', () => { + const fixture = `public enum class ExampleValue(val rawValue: String) { + FirstValue("first-value") + + companion object { + fun fromJson(value: String): ExampleValue = when(value) { + "first-value" -> ExampleValue.FirstValue + "legacy-value" -> ExampleValue.MissingValue + else -> throw IllegalArgumentException() + } + } +} +`; + + expect(() => postProcessKotlinSource(fixture, 'google')).toThrow('maps alias "legacy-value" to unknown constant MissingValue'); + }); +}); diff --git a/packages/gql/src/schema-contract.test.ts b/packages/gql/src/schema-contract.test.ts new file mode 100644 index 000000000..c5b0d9661 --- /dev/null +++ b/packages/gql/src/schema-contract.test.ts @@ -0,0 +1,55 @@ +import { GraphQLDeprecatedDirective, isInputObjectType, isObjectType, printSchema, validateSchema } from 'graphql'; +import { describe, expect, it } from 'vitest'; +import { parseSchema } from '../codegen/core/parser.js'; +import { GENERATOR_INPUT_CONTRACTS } from '../custom-input-contracts.js'; + +describe('OpenIAP schema contract', () => { + it('keeps standard and type-level deprecation directives distinct', () => { + const schema = parseSchema().schema; + const typeDirective = schema.getDirective('openiapDeprecated'); + + expect(schema.getDirective('deprecated')).toBe(GraphQLDeprecatedDirective); + expect(typeDirective?.locations).toEqual(['OBJECT', 'INTERFACE', 'UNION', 'ENUM', 'INPUT_OBJECT']); + expect( + typeDirective?.args.map((argument) => ({ + defaultValue: argument.defaultValue, + name: argument.name, + type: argument.type.toString(), + })), + ).toEqual([ + { + defaultValue: undefined, + name: 'reason', + type: 'String!', + }, + ]); + expect(printSchema(schema)).toContain( + 'directive @openiapDeprecated(reason: String!) on OBJECT | INTERFACE | UNION | ENUM | INPUT_OBJECT', + ); + }); + + it('allowlists only the intentional nested-union codegen extension', () => { + expect(validateSchema(parseSchema().schema).map((error) => error.message)).toEqual([ + 'Union type ProductOrSubscription can only include Object types, it cannot include Product.', + 'Union type ProductOrSubscription can only include Object types, it cannot include ProductSubscription.', + ]); + }); + + it('keeps every generator-owned input contract present in the production schema', () => { + const schema = parseSchema().schema; + for (const inputName of Object.keys(GENERATOR_INPUT_CONTRACTS)) { + expect(isInputObjectType(schema.getType(inputName)), inputName).toBe(true); + } + }); + + it('projects interface deprecations onto concrete purchase fields', () => { + const schema = parseSchema().schema; + for (const typeName of ['PurchaseAndroid', 'PurchaseIOS']) { + const purchaseType = schema.getType(typeName); + expect(isObjectType(purchaseType), typeName).toBe(true); + if (!isObjectType(purchaseType)) continue; + + expect(purchaseType.getFields().platform.deprecationReason, `${typeName}.platform`).toBe('Use store instead'); + } + }); +}); diff --git a/packages/gql/src/schema-deprecations.test.mjs b/packages/gql/src/schema-deprecations.test.mjs new file mode 100644 index 000000000..c9bedd18c --- /dev/null +++ b/packages/gql/src/schema-deprecations.test.mjs @@ -0,0 +1,178 @@ +import { describe, expect, it } from 'vitest'; +import { assertValidSchemaDeprecations, extractSchemaDeprecations } from '../schema-deprecations.mjs'; + +describe('canonical schema deprecations', () => { + it('extracts type, field, and operation-argument metadata once', () => { + const deprecations = extractSchemaDeprecations([ + { + sourceId: 'schema.graphql', + sdl: ` +directive @openiapDeprecated(reason: String!) on OBJECT | INTERFACE | UNION | ENUM | INPUT_OBJECT + +type Legacy @openiapDeprecated(reason: "Use Modern instead.") { + old: String @deprecated(reason: "Use modern instead.") +} + +type Query { + value( + legacy: String @deprecated(reason: "Use current instead.") + ): String +} +`, + }, + ]); + + expect(deprecations.issues).toEqual([]); + expect(deprecations.typeReasons).toEqual(new Map([['Legacy', 'Use Modern instead.']])); + expect(deprecations.operationArguments).toEqual([ + { + rootName: 'Query', + fieldName: 'value', + argumentName: 'legacy', + reason: 'Use current instead.', + }, + ]); + expect(deprecations.entries.map((entry) => entry.ownerPath)).toEqual(['Legacy', 'Legacy.old', 'Query.value.legacy']); + }); + + it('rejects wrong directive locations and invalid canonical reasons', () => { + const deprecations = extractSchemaDeprecations([ + { + sourceId: 'invalid.graphql', + sdl: ` +type Legacy @deprecated(reason: "Wrong directive.") { + old: String @openiapDeprecated(reason: "Wrong directive.") +} + +type Empty @openiapDeprecated(reason: "") { + value: String +} +`, + }, + ]); + + expect(deprecations.issues.map((issue) => issue.rule)).toEqual([ + 'deprecated-directive-location', + 'deprecated-directive-location', + 'deprecated-reason-invalid', + ]); + expect(() => assertValidSchemaDeprecations(deprecations)).toThrow('Invalid GraphQL deprecation metadata'); + }); + + it('rejects duplicate type ownership across definitions and extensions', () => { + const deprecations = extractSchemaDeprecations([ + { + sourceId: 'base.graphql', + sdl: `type Legacy @openiapDeprecated(reason: "Use Modern.") { + value: String +}`, + }, + { + sourceId: 'extension.graphql', + sdl: `extend type Legacy @openiapDeprecated(reason: "Duplicate.") { + other: String +}`, + }, + ]); + + expect(deprecations.issues).toEqual([ + expect.objectContaining({ + file: 'extension.graphql', + line: 1, + rule: 'deprecated-directive-duplicate', + message: 'ObjectTypeExtension "Legacy" duplicates @openiapDeprecated ownership from base.graphql:1', + }), + ]); + }); + + it('rejects duplicate field and argument ownership across sources', () => { + const deprecations = extractSchemaDeprecations([ + { + sourceId: 'base.graphql', + sdl: `type Legacy { + old: String @deprecated(reason: "Use current.") +} +type Query { + value(legacy: String @deprecated(reason: "Use current.")): String +}`, + }, + { + sourceId: 'extension.graphql', + sdl: `extend type Legacy { + old: String @deprecated(reason: "Duplicate field.") +} +extend type Query { + value(legacy: String @deprecated(reason: "Duplicate argument.")): String +}`, + }, + ]); + + expect(deprecations.issues).toEqual([ + expect.objectContaining({ + file: 'extension.graphql', + message: 'FieldDefinition "Legacy.old" duplicates @deprecated ownership from base.graphql:2', + rule: 'deprecated-directive-duplicate', + }), + expect.objectContaining({ + file: 'extension.graphql', + message: 'InputValueDefinition "Query.value.legacy" duplicates @deprecated ownership from base.graphql:5', + rule: 'deprecated-directive-duplicate', + }), + ]); + expect(deprecations.operationArguments).toEqual([ + { + rootName: 'Query', + fieldName: 'value', + argumentName: 'legacy', + reason: 'Use current.', + }, + ]); + }); + + it('ignores marker-shaped block-string prose but rejects real legacy comments', () => { + const deprecations = extractSchemaDeprecations([ + { + sourceId: 'comments.graphql', + sdl: `""" +# @deprecated This is only an example. +""" +type Current { + value: String +} + +# @deprecated Use Current instead. +type Legacy { + value: String +} +`, + }, + ]); + + expect(deprecations.issues).toEqual([ + expect.objectContaining({ + file: 'comments.graphql', + line: 8, + rule: 'deprecated-comment-legacy', + }), + ]); + }); + + it('rejects trailing legacy deprecation comments outside strings', () => { + const deprecations = extractSchemaDeprecations([ + { + sourceId: 'trailing.graphql', + sdl: `type Legacy { value: String } # @deprecated Use Current. +input Current { note: String = "# @deprecated only string data" } +`, + }, + ]); + + expect(deprecations.issues).toEqual([ + expect.objectContaining({ + file: 'trailing.graphql', + line: 1, + rule: 'deprecated-comment-legacy', + }), + ]); + }); +}); diff --git a/packages/gql/src/schema-files.test.mjs b/packages/gql/src/schema-files.test.mjs new file mode 100644 index 000000000..0d8eff37c --- /dev/null +++ b/packages/gql/src/schema-files.test.mjs @@ -0,0 +1,14 @@ +import { readdirSync } from 'node:fs'; +import { describe, expect, it } from 'vitest'; +import { SCHEMA_FILE_NAMES } from '../schema-files.mjs'; + +describe('GraphQL schema file inventory', () => { + it('contains every root schema exactly once', () => { + const actualFiles = readdirSync(new URL('.', import.meta.url)) + .filter((fileName) => fileName.endsWith('.graphql')) + .sort(); + + expect([...new Set(SCHEMA_FILE_NAMES)].sort()).toEqual(actualFiles); + expect(SCHEMA_FILE_NAMES).toHaveLength(actualFiles.length); + }); +}); diff --git a/packages/gql/src/schema-linter.test.ts b/packages/gql/src/schema-linter.test.ts index cc2d0ec61..a82f0480e 100644 --- a/packages/gql/src/schema-linter.test.ts +++ b/packages/gql/src/schema-linter.test.ts @@ -8,17 +8,24 @@ import type { LintResult } from '../codegen/core/schema-linter.js'; const temporaryDirectories: string[] = []; -function lintSchemaSource( - source: string, - fileName = 'schema.graphql', -): LintResult[] { +function lintSchemaSource(source: string, fileName = 'schema.graphql'): LintResult[] { + return lintSchemaSources({ [fileName]: source }); +} + +function lintSchemaSources(sources: Record): LintResult[] { const directory = mkdtempSync(join(tmpdir(), 'openiap-schema-linter-')); - const schemaPath = join(directory, fileName); temporaryDirectories.push(directory); - writeFileSync(schemaPath, source); + const schemaPaths = Object.entries(sources).map(([fileName, source], index) => { + const schemaPath = join(directory, fileName); + writeFileSync( + schemaPath, + `${source}${index === 0 ? '\ndirective @openiapDeprecated(reason: String!) on OBJECT | INTERFACE | UNION | ENUM | INPUT_OBJECT\n' : ''}`, + ); + return schemaPath; + }); const parsedSchema = new SchemaParser({ - schemaPaths: [schemaPath], + schemaPaths, }).parse(); return lintSchema(parsedSchema); } @@ -31,9 +38,7 @@ afterEach(() => { describe('GraphQL Future marker lint', () => { test('keeps the repository schema free of lint errors', () => { - expect( - lintSchema(parseSchema()).filter((finding) => finding.level === 'error'), - ).toEqual([]); + expect(lintSchema(parseSchema()).filter((finding) => finding.level === 'error')).toEqual([]); }); test('requires Future markers on Query and Mutation fields', () => { @@ -49,22 +54,18 @@ type Mutation { } `); - expect( - findings.filter((finding) => finding.rule === 'future-marker-required'), - ).toEqual([ + expect(findings.filter((finding) => finding.rule === 'future-marker-required')).toEqual([ expect.objectContaining({ level: 'error', file: 'schema.graphql', line: 3, - message: - 'Async operation "Query.missingQuery" must be preceded by "# Future"', + message: 'Async operation "Query.missingQuery" must be preceded by "# Future"', }), expect.objectContaining({ level: 'error', file: 'schema.graphql', line: 7, - message: - 'Async operation "Mutation.missingMutation" must be preceded by "# Future"', + message: 'Async operation "Mutation.missingMutation" must be preceded by "# Future"', }), ]); }); @@ -84,9 +85,7 @@ type Mutation { } `); - expect( - findings.filter((finding) => finding.rule === 'future-marker-required'), - ).toEqual([]); + expect(findings.filter((finding) => finding.rule === 'future-marker-required')).toEqual([]); }); test('does not require Future markers on Subscription fields', () => { @@ -104,9 +103,193 @@ type Subscription { } `); - expect( - findings.filter((finding) => finding.rule === 'future-marker-required'), - ).toEqual([]); + expect(findings.filter((finding) => finding.rule === 'future-marker-required')).toEqual([]); + }); + + test('rejects Future markers on root placeholders', () => { + const findings = lintSchemaSource(` +type Query { + # Future + _placeholder: Boolean +} +`); + + expect(findings.filter((finding) => finding.rule === 'future-marker-target')).toEqual([ + expect.objectContaining({ + level: 'error', + line: 3, + message: '"# Future" targets Query._placeholder; placeholder fields cannot carry generation markers', + }), + ]); + }); + + test('reports invalid marker targets through the shared parser', () => { + const findings = lintSchemaSource(` +type Query { + _placeholder: Boolean +} + +input Filter { + # Future + value: String +} + +# => Union +enum ResultMode { + SUCCESS +} +`); + + expect(findings.filter((finding) => ['future-marker-target', 'union-marker-target'].includes(finding.rule))).toEqual([ + expect.objectContaining({ + level: 'error', + line: 7, + message: '"# Future" targets Filter.value; only Query and Mutation fields may be asynchronous', + rule: 'future-marker-target', + }), + expect.objectContaining({ + level: 'error', + line: 11, + message: '"# => Union" is not followed by a valid object type definition', + rule: 'union-marker-target', + }), + ]); + }); + + test('strict parsing rejects duplicate operation fields across files', () => { + expect(() => + lintSchemaSources({ + 'base.graphql': ` +type Query { + # Future + value: String +} +`, + 'extension.graphql': ` +extend type Query { + value: String +} +`, + }), + ).toThrow('Field "Query.value" can only be defined once'); + }); +}); + +describe('GraphQL deprecation documentation lint', () => { + test('rejects legacy comment-only deprecation markers', () => { + const findings = lintSchemaSource(` +enum LegacyMode { + # @deprecated Use MODERN instead. + LEGACY + MODERN +} +`); + + expect(findings.filter((finding) => finding.rule === 'deprecated-comment-legacy')).toEqual([ + expect.objectContaining({ + level: 'error', + line: 3, + message: 'Legacy "# @deprecated" comments are not canonical; use a GraphQL deprecation directive', + }), + ]); + }); + + test('requires a directive for canonical deprecation guidance', () => { + const findings = lintSchemaSource(` +""" +Legacy offer. +@deprecated Use DiscountOffer instead. +""" +type LegacyOffer { + id: String +} +`); + + expect(findings.filter((finding) => finding.rule === 'deprecated-directive-missing')).toEqual([ + expect.objectContaining({ + level: 'error', + line: 2, + message: + 'ObjectTypeDefinition "LegacyOffer" declares @deprecated guidance only in its description; move the canonical reason to a directive', + }), + ]); + }); + + test('rejects descriptions that duplicate directive-owned guidance', () => { + const findings = lintSchemaSource(` +""" +Legacy offer. +@deprecated Manual duplicate. +""" +type LegacyOffer @openiapDeprecated(reason: "Use DiscountOffer instead.") { + """ + Legacy identifier. + @deprecated Manual duplicate. + """ + legacyId: String @deprecated(reason: "Use id instead.") +} +`); + + expect(findings.filter((finding) => finding.rule === 'deprecated-description-duplicate')).toEqual([ + expect.objectContaining({ + level: 'error', + line: 2, + message: 'ObjectTypeDefinition "LegacyOffer" duplicates directive-owned @deprecated guidance in its description', + }), + expect.objectContaining({ + level: 'error', + line: 7, + message: 'FieldDefinition "LegacyOffer.legacyId" duplicates directive-owned @deprecated guidance in its description', + }), + ]); + }); + + test('rejects empty canonical reasons', () => { + const findings = lintSchemaSource(` +type LegacyOffer @openiapDeprecated(reason: "") { + legacyId: String @deprecated(reason: "") +} +`); + + expect(findings.filter((finding) => finding.rule === 'deprecated-reason-invalid')).toEqual([ + expect.objectContaining({ + level: 'error', + message: + 'ObjectTypeDefinition "LegacyOffer" must declare exactly one non-empty string @openiapDeprecated reason and no other arguments', + }), + expect.objectContaining({ + level: 'error', + message: + 'FieldDefinition "LegacyOffer.legacyId" must declare exactly one non-empty string @deprecated reason and no other arguments', + }), + ]); + }); + + test('strict parsing rejects missing and unknown directive arguments', () => { + expect(() => + lintSchemaSource(` +type LegacyOffer @openiapDeprecated { + legacyId: String @deprecated(foo: "Use id instead.") +} +`), + ).toThrow(); + }); + + test('strict parsing rejects type-level directive ownership split across files', () => { + expect(() => + lintSchemaSources({ + 'base.graphql': ` +type LegacyOffer @openiapDeprecated(reason: "Use DiscountOffer instead.") { + id: String +} +`, + 'extension.graphql': ` +extend type LegacyOffer @openiapDeprecated(reason: "Duplicate ownership.") { + legacyId: String +} +`, + }), + ).toThrow('The directive "@openiapDeprecated" can only be used once at this location'); }); }); @@ -129,9 +312,7 @@ input WrongIos { 'type-ios.graphql', ); - expect( - findings.filter((finding) => finding.rule === 'ios-type-suffix'), - ).toEqual([ + expect(findings.filter((finding) => finding.rule === 'ios-type-suffix')).toEqual([ expect.objectContaining({ level: 'error', line: 2, @@ -160,14 +341,11 @@ input WrongInput { 'type-android.graphql', ); - expect( - findings.filter((finding) => finding.rule === 'android-type-suffix'), - ).toEqual([ + expect(findings.filter((finding) => finding.rule === 'android-type-suffix')).toEqual([ expect.objectContaining({ level: 'error', line: 2, - message: - 'Type "WrongInput" in Android file should end with "Android" suffix', + message: 'Type "WrongInput" in Android file should end with "Android" suffix', }), ]); }); @@ -185,12 +363,10 @@ enum ExternalPurchaseNoticeAction { Continue } 'type-ios.graphql', ); - expect( - findings.filter((finding) => finding.rule === 'ios-type-suffix'), - ).toEqual([]); + expect(findings.filter((finding) => finding.rule === 'ios-type-suffix')).toEqual([]); }); - test('accepts a union marker before an extended type', () => { + test('rejects a union marker before an operation root extension', () => { const findings = lintSchemaSource(` type Query { _placeholder: Boolean } @@ -201,9 +377,14 @@ extend type Query { } `); - expect( - findings.filter((finding) => finding.rule === 'union-marker-target'), - ).toEqual([]); + expect(findings.filter((finding) => finding.rule === 'union-marker-target')).toEqual([ + expect.objectContaining({ + level: 'error', + line: 4, + message: '"# => Union" targets Query; operation root types cannot be union wrappers', + rule: 'union-marker-target', + }), + ]); }); test('requires a suffix when a common type references an Android type', () => { @@ -217,9 +398,7 @@ type PurchaseError { } `); - expect( - findings.filter((finding) => finding.rule === 'platform-field-suffix'), - ).toEqual([ + expect(findings.filter((finding) => finding.rule === 'platform-field-suffix')).toEqual([ expect.objectContaining({ level: 'error', line: 7, @@ -244,9 +423,7 @@ type BillingResultAndroid { } `); - expect( - findings.filter((finding) => finding.rule === 'platform-field-suffix'), - ).toEqual([]); + expect(findings.filter((finding) => finding.rule === 'platform-field-suffix')).toEqual([]); }); test('classifies platform type-name exceptions consistently', () => { @@ -265,9 +442,7 @@ type Container { } `); - expect( - findings.filter((finding) => finding.rule === 'platform-field-suffix'), - ).toEqual([ + expect(findings.filter((finding) => finding.rule === 'platform-field-suffix')).toEqual([ expect.objectContaining({ level: 'error', line: 11, @@ -277,8 +452,7 @@ type Container { expect.objectContaining({ level: 'error', line: 12, - message: - 'Field "Container.appTransaction" references platform-specific type "AppTransaction" and must end with "IOS"', + message: 'Field "Container.appTransaction" references platform-specific type "AppTransaction" and must end with "IOS"', }), ]); }); @@ -295,9 +469,7 @@ type Mutation { } `); - expect( - findings.filter((finding) => finding.rule === 'platform-field-suffix'), - ).toEqual([ + expect(findings.filter((finding) => finding.rule === 'platform-field-suffix')).toEqual([ expect.objectContaining({ level: 'error', line: 8, diff --git a/packages/gql/src/schema-markers.test.mjs b/packages/gql/src/schema-markers.test.mjs new file mode 100644 index 000000000..843906a5a --- /dev/null +++ b/packages/gql/src/schema-markers.test.mjs @@ -0,0 +1,323 @@ +import { describe, expect, it } from 'vitest'; +import { assertValidSchemaMarkers, extractSchemaMarkers } from '../schema-markers.mjs'; + +describe('schema generation markers', () => { + it('shares union and future marker semantics across generators', () => { + const markers = extractSchemaMarkers([ + `# => Union +# explanatory comment + +type Result { + value: String +} + +extend type Query { + # Future + # explanatory comment + + currentValue(id: String!): Result +} +`, + ]); + + expect([...markers.unionWrappers]).toEqual(['Result']); + expect([...markers.futureFields]).toEqual(['Query.currentValue']); + }); + + it('uses parsed declaration ownership across valid multiline SDL', () => { + const markers = extractSchemaMarkers([ + `# => Union +type +Result { + value: String +} + +extend type Query { + # Future + currentValue + (id: String) + : String +} +`, + ]); + + expect([...markers.unionWrappers]).toEqual(['Result']); + expect([...markers.futureFields]).toEqual(['Query.currentValue']); + expect(markers.issues).toEqual([]); + }); + + it('fails closed on an intervening declaration after a union marker', () => { + const markers = extractSchemaMarkers([ + `# => Union +enum Intervening { + VALUE +} +type NotMarked { + value: String +} +`, + ]); + + expect([...markers.unionWrappers]).toEqual([]); + expect(markers.issues).toEqual([ + { + kind: 'union', + reason: 'invalid-target', + sourceId: '', + markerLine: 1, + targetLine: 2, + }, + ]); + }); + + it('does not attach a Future marker to a stale object owner', () => { + const markers = extractSchemaMarkers([ + `type Query { + currentValue: String +} + +input Filter { + # Future + value: String +} +`, + ]); + + expect([...markers.futureFields]).toEqual([]); + expect(markers.issues).toEqual([ + { + kind: 'future', + reason: 'invalid-owner', + sourceId: '', + markerLine: 6, + targetLine: 7, + target: 'Filter.value', + }, + ]); + }); + + it('rejects union markers owned by operation root types', () => { + const markers = extractSchemaMarkers([ + { + sourceId: 'root.graphql', + sdl: ` +# => Union +extend type Mutation { + noop: Boolean +} +`, + }, + ]); + + expect([...markers.unionWrappers]).toEqual([]); + expect(markers.issues).toEqual([ + { + kind: 'union', + reason: 'invalid-owner', + sourceId: 'root.graphql', + markerLine: 2, + targetLine: 3, + target: 'Mutation', + }, + ]); + }); + + it('rejects Future markers on no-effect root placeholders', () => { + const markers = extractSchemaMarkers([ + `type Query { + # Future + _placeholder: Boolean +} +`, + ]); + + expect([...markers.futureFields]).toEqual([]); + expect(markers.issues).toEqual([ + { + kind: 'future', + reason: 'no-effect', + sourceId: '', + markerLine: 2, + targetLine: 3, + target: 'Query._placeholder', + }, + ]); + }); + + it('does not treat a compact type declaration as a Future field target', () => { + const markers = extractSchemaMarkers([ + `# Future +extend type Query { currentValue: String } +`, + ]); + + expect([...markers.futureFields]).toEqual([]); + expect(markers.issues).toEqual([ + { + kind: 'future', + reason: 'invalid-target', + sourceId: '', + markerLine: 1, + targetLine: 2, + }, + ]); + }); + + it('ignores comments that only mention marker text', () => { + const markers = extractSchemaMarkers([ + `# This example is not a # => Union marker. +type PlainResult { + value: String +} + +extend type Query { + # Future work may make this asynchronous. + currentValue: String +} +`, + ]); + + expect([...markers.unionWrappers]).toEqual([]); + expect([...markers.futureFields]).toEqual([]); + expect(markers.issues).toEqual([]); + }); + + it('ignores marker-shaped text inside block string descriptions', () => { + const markers = extractSchemaMarkers([ + String.raw`""" +# => Union +Escaped block delimiter: \""" +# Future +""" +type PlainResult { + value: String +} + +extend type Query { + """ + # Future + """ + currentValue: String +} +`, + ]); + + expect([...markers.unionWrappers]).toEqual([]); + expect([...markers.futureFields]).toEqual([]); + expect(markers.issues).toEqual([]); + }); + + it('rejects marker comments placed after GraphQL declarations', () => { + const markers = extractSchemaMarkers([ + `type Result { value: String } # => Union +type Query { currentValue: String } # Future +`, + ]); + + expect([...markers.unionWrappers]).toEqual([]); + expect([...markers.futureFields]).toEqual([]); + expect(markers.issues).toEqual([ + { + kind: 'union', + reason: 'invalid-placement', + sourceId: '', + markerLine: 1, + targetLine: null, + }, + { + kind: 'future', + reason: 'invalid-placement', + sourceId: '', + markerLine: 2, + targetLine: null, + }, + ]); + expect(() => assertValidSchemaMarkers(markers)).toThrow('must be a standalone comment immediately before its target'); + }); + + it('reports markers that have no target', () => { + const markers = extractSchemaMarkers([ + `extend type Query { + value: String +} + +# Future +`, + ]); + + expect(markers.issues).toEqual([ + { + kind: 'future', + reason: 'invalid-target', + sourceId: '', + markerLine: 5, + targetLine: null, + }, + ]); + }); + + it('rejects duplicate marker ownership within and across sources', () => { + const markers = extractSchemaMarkers([ + { + sourceId: 'base.graphql', + sdl: `# => Union +# => Union +type Result { + value: String +} + +type Query { + # Future + # Future + value: String +} +`, + }, + { + sourceId: 'extension.graphql', + sdl: `# => Union +extend type Result { + error: String +} + +extend type Query { + # Future + value: String +} +`, + }, + ]); + + expect(markers.issues).toEqual([ + expect.objectContaining({ + kind: 'union', + reason: 'duplicate-marker', + sourceId: 'base.graphql', + target: 'Result', + previous: { sourceId: 'base.graphql', markerLine: 1 }, + }), + expect.objectContaining({ + kind: 'future', + reason: 'duplicate-marker', + sourceId: 'base.graphql', + target: 'Query.value', + previous: { sourceId: 'base.graphql', markerLine: 8 }, + }), + expect.objectContaining({ + kind: 'union', + reason: 'duplicate-marker', + sourceId: 'extension.graphql', + target: 'Result', + previous: { sourceId: 'base.graphql', markerLine: 1 }, + }), + expect.objectContaining({ + kind: 'future', + reason: 'duplicate-marker', + sourceId: 'extension.graphql', + target: 'Query.value', + previous: { sourceId: 'base.graphql', markerLine: 8 }, + }), + ]); + expect(() => assertValidSchemaMarkers(markers)).toThrow('Invalid GraphQL generation marker ownership'); + }); +}); diff --git a/packages/gql/src/schema-source-utils.test.mjs b/packages/gql/src/schema-source-utils.test.mjs new file mode 100644 index 000000000..65e16fad8 --- /dev/null +++ b/packages/gql/src/schema-source-utils.test.mjs @@ -0,0 +1,45 @@ +import { describe, expect, it } from 'vitest'; +import { collectGraphQLComments } from '../schema-source-utils.mjs'; + +describe('GraphQL source comment scanner', () => { + it('ignores comment-shaped text inside quoted and block-string values', () => { + const source = String.raw`"# => Union with an escaped quote: \" and # Future" +type Plain { + value: String +} + +""" +Escaped block delimiter: \""" +# @deprecated This remains description text. +""" +type AlsoPlain { + value: String +} + +# Future +extend type Query { + currentValue: String +} +`; + + expect(collectGraphQLComments(source)).toEqual([ + { + column: 1, + line: 14, + standalone: true, + text: '# Future', + }, + ]); + }); + + it('reports trailing comments with exact placement metadata', () => { + expect(collectGraphQLComments('type Result { value: String } # => Union\n')).toEqual([ + { + column: 31, + line: 1, + standalone: false, + text: '# => Union', + }, + ]); + }); +}); diff --git a/packages/gql/src/schema.graphql b/packages/gql/src/schema.graphql index c30e03d74..ed28df623 100644 --- a/packages/gql/src/schema.graphql +++ b/packages/gql/src/schema.graphql @@ -1,5 +1,14 @@ # Root GraphQL types +""" +OpenIAP code-generation metadata for deprecating named schema types. +Standard GraphQL @deprecated remains reserved for fields, arguments, input +fields, and enum values. +""" +directive @openiapDeprecated( + reason: String! +) on OBJECT | INTERFACE | UNION | ENUM | INPUT_OBJECT + type Query { _placeholder: Boolean } diff --git a/packages/gql/src/type-android.graphql b/packages/gql/src/type-android.graphql index fc2ad015c..e64a13e44 100644 --- a/packages/gql/src/type-android.graphql +++ b/packages/gql/src/type-android.graphql @@ -138,11 +138,12 @@ type DiscountDisplayInfoAndroid { """ One-time purchase offer details (Android). Available in Google Play Billing Library 8.0+ -@deprecated Use the standardized DiscountOffer type for Android one-time offers. @see https://openiap.dev/docs/types/discount-offer """ type ProductAndroidOneTimePurchaseOfferDetail - @deprecated(reason: "Use DiscountOffer type instead") { + @openiapDeprecated( + reason: "Use the standardized DiscountOffer type for Android one-time offers." + ) { """ Offer ID """ @@ -216,11 +217,12 @@ type InstallmentPlanDetailsAndroid { """ Subscription offer details (Android). -@deprecated Use the standardized SubscriptionOffer type instead for cross-platform compatibility. @see https://openiap.dev/docs/types/subscription-offer """ type ProductSubscriptionAndroidOfferDetails - @deprecated(reason: "Use SubscriptionOffer type instead") { + @openiapDeprecated( + reason: "Use the standardized SubscriptionOffer type instead for cross-platform compatibility." + ) { basePlanId: String! offerId: String offerToken: String! @@ -277,15 +279,13 @@ type ProductAndroid implements ProductCommon { """ One-time purchase offer details including discounts (Android) Returns all eligible offers. Available in Google Play Billing Library 8.0+ - @deprecated Use the standardized discountOffers field instead. """ oneTimePurchaseOfferDetailsAndroid: [ProductAndroidOneTimePurchaseOfferDetail!] - @deprecated(reason: "Use discountOffers instead") - """ - @deprecated Use subscriptionOffers instead for cross-platform compatibility. - """ + @deprecated(reason: "Use the standardized discountOffers field instead.") subscriptionOfferDetailsAndroid: [ProductSubscriptionAndroidOfferDetails!] - @deprecated(reason: "Use subscriptionOffers instead") + @deprecated( + reason: "Use subscriptionOffers instead for cross-platform compatibility." + ) } type ProductSubscriptionAndroid implements ProductCommon { @@ -330,16 +330,15 @@ type ProductSubscriptionAndroid implements ProductCommon { """ Legacy nullable compatibility field. Google Play does not populate one-time purchase offer details for subscription products. - @deprecated One-time offers belong to ProductAndroid.discountOffers; - subscriptions use subscriptionOffers. """ oneTimePurchaseOfferDetailsAndroid: [ProductAndroidOneTimePurchaseOfferDetail!] - @deprecated(reason: "Use subscriptionOffers instead") - """ - @deprecated Use subscriptionOffers instead for cross-platform compatibility. - """ + @deprecated( + reason: "One-time offers belong to ProductAndroid.discountOffers; subscriptions use subscriptionOffers." + ) subscriptionOfferDetailsAndroid: [ProductSubscriptionAndroidOfferDetails!]! - @deprecated(reason: "Use subscriptionOffers instead") + @deprecated( + reason: "Use subscriptionOffers instead for cross-platform compatibility." + ) } type PurchaseAndroid implements PurchaseCommon { @@ -472,9 +471,11 @@ input RequestSubscriptionAndroidProps { originalExternalTransactionId: String """ Replacement mode for subscription changes - @deprecated Use subscriptionProductReplacementParams instead for item-level replacement (8.1.0+) """ replacementMode: Int + @deprecated( + reason: "Use subscriptionProductReplacementParams instead for item-level replacement (8.1.0+)." + ) """ Subscription offers """ @@ -653,10 +654,12 @@ type VerifyPurchaseResultAndroid { """ Alternative billing mode for Android Controls which billing system is used -@deprecated Use enableBillingProgramAndroid with BillingProgramAndroid instead. Use USER_CHOICE_BILLING for user choice billing, EXTERNAL_OFFER for alternative only. """ -enum AlternativeBillingModeAndroid { +enum AlternativeBillingModeAndroid + @openiapDeprecated( + reason: "Use enableBillingProgramAndroid with BillingProgramAndroid instead." + ) { """ Standard Google Play billing (default) """ @@ -665,16 +668,18 @@ enum AlternativeBillingModeAndroid { """ User choice billing - user can select between Google Play or alternative Requires Google Play Billing Library 7.0+ - @deprecated Use BillingProgramAndroid.USER_CHOICE_BILLING instead """ USER_CHOICE + @deprecated( + reason: "Use BillingProgramAndroid.USER_CHOICE_BILLING instead." + ) """ Alternative billing only - no Google Play billing option Requires Google Play Billing Library 6.2+ - @deprecated Use BillingProgramAndroid.EXTERNAL_OFFER instead """ ALTERNATIVE_ONLY + @deprecated(reason: "Use BillingProgramAndroid.EXTERNAL_OFFER instead.") } # User Choice Billing @@ -1138,11 +1143,10 @@ type DeveloperProvidedBillingProductAndroid { """ External offer reporting details (Android) -@deprecated Use BillingProgramReportingDetailsAndroid with createBillingProgramReportingDetailsAsync instead Available in Google Play Billing Library 6.2.0+, deprecated in 8.2.0 """ type ExternalOfferReportingDetailsAndroid - @deprecated( + @openiapDeprecated( reason: "Use BillingProgramReportingDetailsAndroid with createBillingProgramReportingDetailsAsync instead" ) { """ @@ -1153,11 +1157,10 @@ type ExternalOfferReportingDetailsAndroid """ External offer availability result (Android) -@deprecated Use BillingProgramAvailabilityResultAndroid with isBillingProgramAvailableAsync instead Available in Google Play Billing Library 6.2.0+, deprecated in 8.2.0 """ type ExternalOfferAvailabilityResultAndroid - @deprecated( + @openiapDeprecated( reason: "Use BillingProgramAvailabilityResultAndroid with isBillingProgramAvailableAsync instead" ) { """ diff --git a/packages/gql/src/type-ios.graphql b/packages/gql/src/type-ios.graphql index 52a081a46..dc48a739e 100644 --- a/packages/gql/src/type-ios.graphql +++ b/packages/gql/src/type-ios.graphql @@ -60,11 +60,12 @@ type SubscriptionPeriodValueIOS { """ iOS subscription offer details. -@deprecated Use the standardized SubscriptionOffer type instead for cross-platform compatibility. @see https://openiap.dev/docs/types/subscription-offer """ type SubscriptionOfferIOS - @deprecated(reason: "Use SubscriptionOffer type instead") { + @openiapDeprecated( + reason: "Use the standardized SubscriptionOffer type instead for cross-platform compatibility." + ) { displayPrice: String! id: ID! paymentMode: PaymentModeIOS! @@ -133,11 +134,10 @@ type ProductIOS implements ProductCommon { pricingTermsIOS: [SubscriptionPricingTermsIOS!] # Deprecated platform-specific field - """ - @deprecated Use subscriptionOffers instead for cross-platform compatibility. - """ subscriptionInfoIOS: SubscriptionInfoIOS - @deprecated(reason: "Use subscriptionOffers instead") + @deprecated( + reason: "Use subscriptionOffers instead for cross-platform compatibility." + ) } # iOS subscription product @@ -180,18 +180,15 @@ type ProductSubscriptionIOS implements ProductCommon { subscriptionGroupIdIOS: String # Deprecated legacy iOS fields - """ - @deprecated Use subscriptionOffers for offer metadata and subscriptionGroupIdIOS for the App Store subscription group identifier. - """ subscriptionInfoIOS: SubscriptionInfoIOS @deprecated( - reason: "Use subscriptionOffers for offers and subscriptionGroupIdIOS for group ID" + reason: "Use subscriptionOffers for offer metadata and subscriptionGroupIdIOS for the App Store subscription group identifier." ) - """ - @deprecated Use subscriptionOffers instead for cross-platform compatibility. - """ - discountsIOS: [DiscountIOS!] @deprecated(reason: "Use subscriptionOffers instead") + discountsIOS: [DiscountIOS!] + @deprecated( + reason: "Use subscriptionOffers instead for cross-platform compatibility." + ) introductoryPriceIOS: String introductoryPriceAsAmountIOS: String @@ -204,10 +201,12 @@ type ProductSubscriptionIOS implements ProductCommon { """ Discount information returned from the store. -@deprecated Use the standardized SubscriptionOffer type instead for cross-platform compatibility. @see https://openiap.dev/docs/types/subscription-offer """ -type DiscountIOS @deprecated(reason: "Use SubscriptionOffer type instead") { +type DiscountIOS + @openiapDeprecated( + reason: "Use the standardized SubscriptionOffer type instead for cross-platform compatibility." + ) { identifier: String! type: String! numberOfPeriods: Int! @@ -224,7 +223,9 @@ type PurchaseIOS implements PurchaseCommon { id: ID! productId: String! ids: [String!] - """Unix timestamp in milliseconds since January 1, 1970 UTC.""" + """ + Unix timestamp in milliseconds since January 1, 1970 UTC. + """ transactionDate: Float! purchaseToken: String """ @@ -535,11 +536,12 @@ type SubscriptionStatusIOS { """ iOS DiscountOffer (output type). -@deprecated Use the standardized SubscriptionOffer type instead for cross-platform compatibility. @see https://openiap.dev/docs/types/subscription-offer """ type DiscountOfferIOS - @deprecated(reason: "Use SubscriptionOffer type instead") { + @openiapDeprecated( + reason: "Use the standardized SubscriptionOffer type instead for cross-platform compatibility." + ) { """ Discount identifier """ diff --git a/packages/gql/src/type.graphql b/packages/gql/src/type.graphql index ae3dd6fed..fa0374e7c 100644 --- a/packages/gql/src/type.graphql +++ b/packages/gql/src/type.graphql @@ -183,10 +183,12 @@ input RequestPurchaseProps { """ type: ProductQueryType = InApp """ - @deprecated Use enableBillingProgramAndroid in InitConnectionConfig instead. This flag only logs debug info and has no effect on the purchase flow. """ useAlternativeBilling: Boolean + @deprecated( + reason: "Use enableBillingProgramAndroid in InitConnectionConfig instead." + ) } # Minimal purchase information required to finish transactions @@ -203,10 +205,7 @@ input PurchaseInput { Store where purchase was made """ store: IapStore - """ - @deprecated Use store instead - """ - platform: IapPlatform + platform: IapPlatform @deprecated(reason: "Use store instead") quantity: Int! purchaseState: PurchaseState! isAutoRenewing: Boolean! @@ -242,14 +241,8 @@ input RequestPurchasePropsByPlatforms { Google-specific purchase parameters """ google: RequestPurchaseAndroidProps - """ - @deprecated Use apple instead - """ - ios: RequestPurchaseIosProps - """ - @deprecated Use google instead - """ - android: RequestPurchaseAndroidProps + ios: RequestPurchaseIosProps @deprecated(reason: "Use apple instead") + android: RequestPurchaseAndroidProps @deprecated(reason: "Use google instead") } """ @@ -270,14 +263,9 @@ input RequestSubscriptionPropsByPlatforms { Google-specific subscription parameters """ google: RequestSubscriptionAndroidProps - """ - @deprecated Use apple instead - """ - ios: RequestSubscriptionIosProps - """ - @deprecated Use google instead - """ + ios: RequestSubscriptionIosProps @deprecated(reason: "Use apple instead") android: RequestSubscriptionAndroidProps + @deprecated(reason: "Use google instead") } # Receipt validation inputs and results @@ -495,11 +483,11 @@ type ActiveSubscription { autoRenewingAndroid: Boolean environmentIOS: String """ - @deprecated iOS only - use daysUntilExpirationIOS instead. Whether the subscription will expire soon (within 7 days). Consider using daysUntilExpirationIOS for more precise control. """ willExpireSoon: Boolean + @deprecated(reason: "iOS only - use daysUntilExpirationIOS instead.") daysUntilExpirationIOS: Float transactionId: String! purchaseToken: String @@ -634,7 +622,9 @@ type DiscountOffer { currency: String! """ - Type of discount offer + Offer category. DiscountOffer currently represents Android one-time product + offers and is populated as OneTime. Introductory and Promotional are used by + SubscriptionOffer. """ type: DiscountOfferType! @@ -842,10 +832,10 @@ input InitConnectionConfig { """ Alternative billing mode for Android If not specified, defaults to NONE (standard Google Play billing) - @deprecated Use enableBillingProgramAndroid instead. Use USER_CHOICE_BILLING for user choice billing, EXTERNAL_OFFER for alternative only. """ alternativeBillingModeAndroid: AlternativeBillingModeAndroid + @deprecated(reason: "Use enableBillingProgramAndroid instead.") """ Enable a specific billing program for Android (7.0+) When set, enables the specified billing program for external transactions. diff --git a/scripts/agent/compile-context.ts b/scripts/agent/compile-context.ts index b9d1bc6d3..23e7a17b0 100644 --- a/scripts/agent/compile-context.ts +++ b/scripts/agent/compile-context.ts @@ -18,6 +18,11 @@ import * as fs from "fs"; import * as path from "path"; import { glob } from "glob"; import chalk from "chalk"; +import { + CONTEXT_OUTPUTS, + CONTEXT_SOURCES, + ROOT_LLMS_SYMLINKS, +} from "./context-files.js"; // ============================================================================ // Configuration @@ -30,15 +35,16 @@ const scriptDir = path.dirname(fileURLToPath(import.meta.url)); const CONFIG = { projectRoot: path.resolve(scriptDir, "../.."), - knowledgeRoot: path.resolve(scriptDir, "../../knowledge"), - outputDir: path.resolve(scriptDir, "../../knowledge/_claude-context"), - outputFile: "context.md", + knowledgeRoot: path.resolve( + scriptDir, + "../..", + CONTEXT_SOURCES.knowledgeRoot, + ), + outputPath: path.resolve(scriptDir, "../..", CONTEXT_OUTPUTS.context), // LLMs.txt output (for AI assistants on web) - llmsOutputDir: path.resolve(scriptDir, "../../packages/docs/public"), - rootLlmsSymlinks: { - "llms.txt": "packages/docs/public/llms.txt", - "llms-full.txt": "packages/docs/public/llms-full.txt", - }, + llmsQuickPath: path.resolve(scriptDir, "../..", CONTEXT_OUTPUTS.llmsQuick), + llmsFullPath: path.resolve(scriptDir, "../..", CONTEXT_OUTPUTS.llmsFull), + rootLlmsSymlinks: ROOT_LLMS_SYMLINKS, }; type LlmsVersions = { @@ -75,34 +81,34 @@ function readRegexVersion( function readInstallationVersions(): LlmsVersions { const openiapVersions = readJsonFile<{ apple: string; google: string }>( - "openiap-versions.json", + CONTEXT_SOURCES.openiapVersions, ); return { apple: openiapVersions.apple, google: openiapVersions.google, flutter: readRegexVersion( - "libraries/flutter_inapp_purchase/pubspec.yaml", + CONTEXT_SOURCES.flutterPackage, /^version:\s*([^\s]+)/m, "flutter_inapp_purchase", ), godot: readRegexVersion( - "libraries/godot-iap/addons/godot-iap/plugin.cfg", + CONTEXT_SOURCES.godotPackage, /^version="([^"]+)"$/m, "godot-iap", ), kmp: readRegexVersion( - "libraries/kmp-iap/gradle.properties", + CONTEXT_SOURCES.kmpPackage, /^libraryVersion=(.+)$/m, "kmp-iap", ), maui: readRegexVersion( - "libraries/maui-iap/src/OpenIap.Maui/OpenIap.Maui.csproj", + CONTEXT_SOURCES.mauiPackage, /([^<]+)<\/PackageVersion>/, "OpenIap.Maui", ), mauiPackageId: readRegexVersion( - "libraries/maui-iap/src/OpenIap.Maui/OpenIap.Maui.csproj", + CONTEXT_SOURCES.mauiPackage, /([^<]+)<\/PackageId>/, "OpenIap.Maui package id", ), @@ -170,7 +176,7 @@ async function generateLlmsTxt(): Promise<{ quick: number; full: number }> { // Read all external API docs const externalFiles = await glob( - path.join(CONFIG.knowledgeRoot, "external/**/*.md"), + path.join(CONFIG.projectRoot, CONTEXT_SOURCES.externalKnowledgeGlob), { absolute: true }, ); @@ -500,7 +506,7 @@ await ((QueryResolver)iap).FetchProductsAsync(new ProductRequest } const kitQuickReference = fs.readFileSync( - path.join(CONFIG.projectRoot, "packages/kit/public/llms.txt"), + path.join(CONFIG.projectRoot, CONTEXT_SOURCES.kitQuickReference), "utf-8", ); fullContent += kitQuickReference.trimEnd(); @@ -753,6 +759,7 @@ interface PurchaseError { ### Android - acknowledgePurchaseAndroid() - Acknowledge purchase - consumePurchaseAndroid() - Consume for re-purchase +- openRedeemOfferCodeAndroid() - Open Play offer-code redemption page ## Purchase Flow Summary @@ -774,15 +781,9 @@ interface PurchaseError { // The website serves packages/docs/public. Root files are symlinks to avoid // drift between local repository readers and deployed docs. - fs.mkdirSync(CONFIG.llmsOutputDir, { recursive: true }); - writeGeneratedFileIfChanged( - path.join(CONFIG.llmsOutputDir, "llms.txt"), - quickContent, - ); - writeGeneratedFileIfChanged( - path.join(CONFIG.llmsOutputDir, "llms-full.txt"), - fullContent, - ); + fs.mkdirSync(path.dirname(CONFIG.llmsQuickPath), { recursive: true }); + writeGeneratedFileIfChanged(CONFIG.llmsQuickPath, quickContent); + writeGeneratedFileIfChanged(CONFIG.llmsFullPath, fullContent); for (const [filename, targetPath] of Object.entries( CONFIG.rootLlmsSymlinks, )) { @@ -812,8 +813,8 @@ export async function compileContext(): Promise { console.log(chalk.gray(`\nKnowledge Root: ${CONFIG.knowledgeRoot}`)); // Ensure output directory exists - if (!fs.existsSync(CONFIG.outputDir)) { - fs.mkdirSync(CONFIG.outputDir, { recursive: true }); + if (!fs.existsSync(path.dirname(CONFIG.outputPath))) { + fs.mkdirSync(path.dirname(CONFIG.outputPath), { recursive: true }); } let output = `# OpenIAP Project Context @@ -843,7 +844,7 @@ These rules define OpenIAP's development philosophy. `; const internalFiles = await glob( - path.join(CONFIG.knowledgeRoot, "internal/**/*.md"), + path.join(CONFIG.projectRoot, CONTEXT_SOURCES.internalKnowledgeGlob), { absolute: true }, ); @@ -877,7 +878,7 @@ Use this documentation for API details, but **ALWAYS adapt patterns to match Int `; const externalFiles = await glob( - path.join(CONFIG.knowledgeRoot, "external/**/*.md"), + path.join(CONFIG.projectRoot, CONTEXT_SOURCES.externalKnowledgeGlob), { absolute: true }, ); @@ -937,7 +938,7 @@ openiap/ // Write Output // ========================================================================= - const outputPath = path.join(CONFIG.outputDir, CONFIG.outputFile); + const outputPath = CONFIG.outputPath; writeGeneratedFileIfChanged(outputPath, output); // ========================================================================= @@ -965,14 +966,8 @@ openiap/ chalk.white(` llms-full.txt: ${(llmsStats.full / 1024).toFixed(1)} KB`), ); console.log(chalk.green(`\n ✓ Output: ${outputPath}`)); - console.log( - chalk.green(` ✓ Output: ${path.join(CONFIG.llmsOutputDir, "llms.txt")}`), - ); - console.log( - chalk.green( - ` ✓ Output: ${path.join(CONFIG.llmsOutputDir, "llms-full.txt")}`, - ), - ); + console.log(chalk.green(` ✓ Output: ${CONFIG.llmsQuickPath}`)); + console.log(chalk.green(` ✓ Output: ${CONFIG.llmsFullPath}`)); for (const [filename, targetPath] of Object.entries( CONFIG.rootLlmsSymlinks, )) { diff --git a/scripts/agent/context-files.ts b/scripts/agent/context-files.ts new file mode 100644 index 000000000..f376af24c --- /dev/null +++ b/scripts/agent/context-files.ts @@ -0,0 +1,154 @@ +import { execFileSync } from "node:child_process"; +import * as path from "node:path"; +import { fileURLToPath } from "node:url"; + +/** + * Source and output paths for the generated agent context. + * + * The compiler, pre-commit hook, and tests consume this contract directly. + * CI runs the freshness check unconditionally, so it does not maintain a + * second path-filter inventory that can drift when a new compiler input is + * introduced. + */ +export const CONTEXT_DIRECT_INPUTS = Object.freeze({ + compilerRoot: "scripts/agent", + rootPackage: "package.json", + rootLock: "bun.lock", + openiapVersions: "openiap-versions.json", + flutterPackage: "libraries/flutter_inapp_purchase/pubspec.yaml", + godotPackage: "libraries/godot-iap/addons/godot-iap/plugin.cfg", + kmpPackage: "libraries/kmp-iap/gradle.properties", + mauiPackage: "libraries/maui-iap/src/OpenIap.Maui/OpenIap.Maui.csproj", + kitQuickReference: "packages/kit/public/llms.txt", +}); + +const knowledgeRoot = "knowledge"; +export const CONTEXT_KNOWLEDGE_INPUT_ROOTS = Object.freeze({ + internal: `${knowledgeRoot}/internal`, + external: `${knowledgeRoot}/external`, +}); + +export const CONTEXT_SOURCES = Object.freeze({ + ...CONTEXT_DIRECT_INPUTS, + knowledgeRoot, + internalKnowledgeGlob: `${CONTEXT_KNOWLEDGE_INPUT_ROOTS.internal}/**/*.md`, + externalKnowledgeGlob: `${CONTEXT_KNOWLEDGE_INPUT_ROOTS.external}/**/*.md`, +}); + +export const CONTEXT_INPUT_PATHS = Object.freeze([ + ...Object.values(CONTEXT_DIRECT_INPUTS), + ...Object.values(CONTEXT_KNOWLEDGE_INPUT_ROOTS), +]); + +export const CONTEXT_OUTPUTS = Object.freeze({ + context: "knowledge/_claude-context/context.md", + llmsQuick: "packages/docs/public/llms.txt", + llmsFull: "packages/docs/public/llms-full.txt", + rootLlmsQuick: "llms.txt", + rootLlmsFull: "llms-full.txt", +}); + +export const CONTEXT_OUTPUT_PATHS = Object.freeze( + Object.values(CONTEXT_OUTPUTS), +); + +export const ROOT_LLMS_SYMLINKS = Object.freeze({ + [CONTEXT_OUTPUTS.rootLlmsQuick]: CONTEXT_OUTPUTS.llmsQuick, + [CONTEXT_OUTPUTS.rootLlmsFull]: CONTEXT_OUTPUTS.llmsFull, +}); + +const repositoryRoot = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + "../..", +); + +const gitLines = (...args: string[]): string[] => { + const output = execFileSync("git", args, { + cwd: repositoryRoot, + encoding: "utf8", + }).trim(); + return output ? output.split("\n") : []; +}; + +const stagedContextInputs = (): string[] => + gitLines( + "diff", + "--cached", + "--name-only", + "--diff-filter=ACMRD", + "--", + ...CONTEXT_INPUT_PATHS, + ); + +const unstagedContextInputs = (): string[] => + [ + ...gitLines("diff", "--name-only", "--", ...CONTEXT_INPUT_PATHS), + ...gitLines( + "ls-files", + "--others", + "--exclude-standard", + "--", + ...CONTEXT_INPUT_PATHS, + ), + ] + .filter((entry, index, entries) => entries.indexOf(entry) === index) + .sort(); + +const unstagedContextOutputs = (): string[] => + [ + ...gitLines("diff", "--name-only", "--", ...CONTEXT_OUTPUT_PATHS), + ...gitLines( + "ls-files", + "--others", + "--exclude-standard", + "--", + ...CONTEXT_OUTPUT_PATHS, + ), + ] + .filter((entry, index, entries) => entries.indexOf(entry) === index) + .sort(); + +const printPaths = (entries: readonly string[]): void => { + for (const entry of entries) { + console.error(`- ${entry}`); + } +}; + +const runCli = (command: string | undefined): void => { + if (command === "has-staged-inputs") { + process.exitCode = stagedContextInputs().length > 0 ? 0 : 1; + return; + } + if (command === "assert-inputs-staged-clean") { + const drift = unstagedContextInputs(); + if (drift.length > 0) { + console.error( + "Generated-context inputs contain unstaged or untracked changes. Stage the complete source snapshot:", + ); + printPaths(drift); + process.exitCode = 1; + } + return; + } + if (command === "assert-outputs-clean") { + const drift = unstagedContextOutputs(); + if (drift.length > 0) { + console.error( + "Compiled agent context differs from the checked-in snapshot. Regenerate and stage the outputs when committing:", + ); + printPaths(drift); + process.exitCode = 1; + } + return; + } + throw new Error( + `Unknown context-files command "${command ?? ""}". Expected has-staged-inputs, assert-inputs-staged-clean, or assert-outputs-clean.`, + ); +}; + +if ( + process.argv[1] && + path.resolve(process.argv[1]) === fileURLToPath(import.meta.url) +) { + runCli(process.argv[2]); +} diff --git a/scripts/agent/tests/compile-context.test.ts b/scripts/agent/tests/compile-context.test.ts index db70ce951..f8a75ed48 100644 --- a/scripts/agent/tests/compile-context.test.ts +++ b/scripts/agent/tests/compile-context.test.ts @@ -3,6 +3,13 @@ import * as fs from "fs"; import * as os from "os"; import * as path from "path"; import { writeGeneratedFileIfChanged } from "../compile-context.js"; +import { + CONTEXT_DIRECT_INPUTS, + CONTEXT_INPUT_PATHS, + CONTEXT_KNOWLEDGE_INPUT_ROOTS, + CONTEXT_OUTPUT_PATHS, + CONTEXT_SOURCES, +} from "../context-files.js"; const temporaryDirectories: string[] = []; @@ -14,10 +21,13 @@ afterEach(() => { describe("writeGeneratedFileIfChanged", () => { test("preserves timestamps when generated content is otherwise unchanged", () => { - const directory = fs.mkdtempSync(path.join(os.tmpdir(), "openiap-context-")); + const directory = fs.mkdtempSync( + path.join(os.tmpdir(), "openiap-context-"), + ); temporaryDirectories.push(directory); const outputPath = path.join(directory, "llms.txt"); - const first = "# Reference\n\n> Generated: 2026-07-11T00:00:00.000Z\n\nBody"; + const first = + "# Reference\n\n> Generated: 2026-07-11T00:00:00.000Z\n\nBody"; const timestampOnlyChange = "# Reference\n\n> Generated: 2026-07-11T01:00:00.000Z\n\nBody"; @@ -31,10 +41,13 @@ describe("writeGeneratedFileIfChanged", () => { }); test("writes a new timestamp when substantive content changes", () => { - const directory = fs.mkdtempSync(path.join(os.tmpdir(), "openiap-context-")); + const directory = fs.mkdtempSync( + path.join(os.tmpdir(), "openiap-context-"), + ); temporaryDirectories.push(directory); const outputPath = path.join(directory, "context.md"); - const first = "# Context\n\n> Last updated: 2026-07-11T00:00:00.000Z\n\nOld"; + const first = + "# Context\n\n> Last updated: 2026-07-11T00:00:00.000Z\n\nOld"; const changed = "# Context\n\n> Last updated: 2026-07-11T01:00:00.000Z\n\nNew"; @@ -45,3 +58,53 @@ describe("writeGeneratedFileIfChanged", () => { expect(written).toContain("New"); }); }); + +describe("generated context path contract", () => { + test("keeps compiler inputs and generated outputs disjoint", () => { + expect(new Set(CONTEXT_INPUT_PATHS).size).toBe(CONTEXT_INPUT_PATHS.length); + expect(new Set(CONTEXT_OUTPUT_PATHS).size).toBe( + CONTEXT_OUTPUT_PATHS.length, + ); + const inputPaths: readonly string[] = CONTEXT_INPUT_PATHS; + for (const output of CONTEXT_OUTPUT_PATHS) { + expect( + inputPaths.some( + (input) => output === input || output.startsWith(`${input}/`), + ), + ).toBe(false); + } + }); + + test("derives knowledge inputs from the compiler source contract", () => { + expect(CONTEXT_SOURCES.internalKnowledgeGlob).toBe( + `${CONTEXT_SOURCES.knowledgeRoot}/internal/**/*.md`, + ); + expect(CONTEXT_SOURCES.externalKnowledgeGlob).toBe( + `${CONTEXT_SOURCES.knowledgeRoot}/external/**/*.md`, + ); + expect(new Set(CONTEXT_INPUT_PATHS)).toEqual( + new Set([ + ...Object.values(CONTEXT_DIRECT_INPUTS), + ...Object.values(CONTEXT_KNOWLEDGE_INPUT_ROOTS), + ]), + ); + }); + + test("keeps hooks and CI on the shared input/output helper", () => { + const repositoryRoot = path.resolve(import.meta.dir, "../../.."); + const preCommit = fs.readFileSync( + path.join(repositoryRoot, ".husky/pre-commit"), + "utf8", + ); + const workflow = fs.readFileSync( + path.join(repositoryRoot, ".github/workflows/ci.yml"), + "utf8", + ); + + expect(preCommit).toContain("context-files.ts assert-inputs-staged-clean"); + expect(preCommit).toContain("context-files.ts assert-outputs-clean"); + expect(workflow).toContain("node scripts/assert-clean-worktree.mjs"); + expect(workflow).not.toContain("context-files.ts assert-outputs-clean"); + expect(workflow).not.toContain("needs.changes.outputs.agent"); + }); +}); diff --git a/scripts/agent/tests/llms-content.test.ts b/scripts/agent/tests/llms-content.test.ts index e5118e900..098a5b84d 100644 --- a/scripts/agent/tests/llms-content.test.ts +++ b/scripts/agent/tests/llms-content.test.ts @@ -1,22 +1,23 @@ import { describe, expect, test } from "bun:test"; import * as fs from "fs"; import * as path from "path"; +import { CONTEXT_OUTPUTS, CONTEXT_SOURCES } from "../context-files.js"; const projectRoot = path.resolve(import.meta.dir, "../../.."); const quickReference = fs.readFileSync( - path.join(projectRoot, "packages/docs/public/llms.txt"), + path.join(projectRoot, CONTEXT_OUTPUTS.llmsQuick), "utf-8", ); const fullReference = fs.readFileSync( - path.join(projectRoot, "packages/docs/public/llms-full.txt"), + path.join(projectRoot, CONTEXT_OUTPUTS.llmsFull), "utf-8", ); const kitQuickReference = fs.readFileSync( - path.join(projectRoot, "packages/kit/public/llms.txt"), + path.join(projectRoot, CONTEXT_SOURCES.kitQuickReference), "utf-8", ); const compiledContext = fs.readFileSync( - path.join(projectRoot, "knowledge/_claude-context/context.md"), + path.join(projectRoot, CONTEXT_OUTPUTS.context), "utf-8", ); @@ -43,6 +44,9 @@ describe("generated LLM references", () => { "type PurchaseState = 'pending' | 'purchased' | 'unknown';", ); expect(quickReference).not.toContain("'restored'"); + expect(quickReference).toContain( + "openRedeemOfferCodeAndroid() - Open Play offer-code redemption page", + ); }); test("uses canonical platform keys and excludes legacy API references", () => { diff --git a/scripts/assert-clean-worktree.mjs b/scripts/assert-clean-worktree.mjs new file mode 100644 index 000000000..19ebb80ff --- /dev/null +++ b/scripts/assert-clean-worktree.mjs @@ -0,0 +1,47 @@ +#!/usr/bin/env node + +import { execFileSync } from "node:child_process"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); + +export const collectWorktreeStatus = (root = repositoryRoot) => + execFileSync("git", ["status", "--porcelain=v1", "--untracked-files=all"], { + cwd: root, + encoding: "utf8", + }).trim(); + +export const assertCleanWorktree = (root = repositoryRoot) => { + const status = collectWorktreeStatus(root); + if (status) { + const error = new Error( + "Generated synchronization changed the checked-out worktree.", + ); + error.status = status; + throw error; + } +}; + +const isMain = + process.argv[1] && + fileURLToPath(import.meta.url) === resolve(process.argv[1]); +if (isMain) { + try { + assertCleanWorktree(); + } catch (error) { + console.error( + "::error::Generated files differ from their checked-in copies.", + ); + console.error( + "Run the corresponding generator locally and commit every reported path.", + ); + if (error && typeof error === "object" && "status" in error) { + console.error("\nUntracked or modified paths:"); + console.error(error.status); + } else { + console.error(error); + } + process.exit(1); + } +} diff --git a/scripts/assert-clean-worktree.test.mjs b/scripts/assert-clean-worktree.test.mjs new file mode 100644 index 000000000..0122faaa0 --- /dev/null +++ b/scripts/assert-clean-worktree.test.mjs @@ -0,0 +1,46 @@ +import { execFileSync } from "node:child_process"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { assertCleanWorktree } from "./assert-clean-worktree.mjs"; + +const runGit = (root, args) => + execFileSync("git", args, { cwd: root, stdio: "ignore" }); + +describe("clean worktree guard", () => { + let repository; + + beforeEach(() => { + repository = mkdtempSync(join(tmpdir(), "openiap-clean-worktree-")); + runGit(repository, ["init"]); + runGit(repository, ["config", "user.email", "ci@openiap.dev"]); + runGit(repository, ["config", "user.name", "OpenIAP CI"]); + writeFileSync(join(repository, "tracked.txt"), "initial\n"); + runGit(repository, ["add", "tracked.txt"]); + runGit(repository, ["commit", "-m", "test fixture"]); + }); + + afterEach(() => { + rmSync(repository, { force: true, recursive: true }); + }); + + it("accepts a clean checkout", () => { + assert.doesNotThrow(() => assertCleanWorktree(repository)); + }); + + it("rejects tracked and untracked drift", () => { + writeFileSync(join(repository, "tracked.txt"), "changed\n"); + writeFileSync(join(repository, "untracked.txt"), "new\n"); + + assert.throws( + () => assertCleanWorktree(repository), + (error) => + error instanceof Error && + typeof error.status === "string" && + error.status.includes("tracked.txt") && + error.status.includes("untracked.txt"), + ); + }); +}); diff --git a/scripts/audit-docs.test.ts b/scripts/audit-docs.test.ts index 00c867299..2f3ca5094 100644 --- a/scripts/audit-docs.test.ts +++ b/scripts/audit-docs.test.ts @@ -1,36 +1,52 @@ import { describe, expect, test } from 'bun:test'; -import { - auditActiveCodeExampleSource, - auditCanonicalOfferDocs, - extractBraceBlock, - type CanonicalOfferDocsSources, -} from './audit-docs'; - -const VALID_DISCOUNT_OFFER_TYPE_BLOCKS = `{\` -type DiscountOfferType = 'introductory' | 'promotional' | 'one-time'; -\`} -{\` +import { auditActiveCodeExampleSource, auditCanonicalOfferDocs, type CanonicalOfferDocsSources } from './audit-docs'; + +const VALID_GENERATED_OFFER_TYPES = { + typescript: "export type DiscountOfferType = 'introductory' | 'promotional' | 'one-time';", + swift: ` enum DiscountOfferType: String { case introductory = "introductory" case promotional = "promotional" case oneTime = "one-time" -} -\`} -{\` +}`.trim(), + kotlin: ` enum class DiscountOfferType(val rawValue: String) { Introductory("introductory"), Promotional("promotional"), OneTime("one-time") -} -\`} -{\` +}`.trim(), + dart: ` enum DiscountOfferType { Introductory('introductory'), Promotional('promotional'), OneTime('one-time'); -} +}`.trim(), +} as const; + +type OfferTypeLanguage = keyof typeof VALID_GENERATED_OFFER_TYPES; + +const offerTypeBlock = (language: OfferTypeLanguage, source: string): string => + `{\` +${source} \`}`; +const offerTypeBlockPattern = (language: OfferTypeLanguage): RegExp => + new RegExp(`\\{\\\`[\\s\\S]*?\\\`\\}`); + +const replaceRequired = (source: string, search: string | RegExp, replacement: string): string => { + const replaced = source.replace(search, replacement); + if (replaced === source) { + throw new Error(`Required fixture replacement did not match: ${search}`); + } + return replaced; +}; + +const VALID_DISCOUNT_OFFER_TYPE_BLOCKS = (Object.entries(VALID_GENERATED_OFFER_TYPES) as [OfferTypeLanguage, string][]) + .map(([language, source]) => offerTypeBlock(language, source)) + .join('\n'); + +const renderPage = (name: string, body: string): string => `const ${name} = () => (<>${body}); export default ${name};`; + describe('active docs code-example audit', () => { test('flags recurring cross-language phantom patterns', () => { const source = [ @@ -42,16 +58,7 @@ describe('active docs code-example audit', () => { ].join('\n'); const drifts = auditActiveCodeExampleSource('/tmp/active.tsx', source); - expect(drifts.map((drift) => drift.rule)).toEqual([ - 'R11', - 'R11', - 'R11', - 'R11', - 'R11', - 'R11', - 'R11', - 'R11', - ]); + expect(drifts.map((drift) => drift.rule)).toEqual(['R11', 'R11', 'R11', 'R11', 'R11', 'R11', 'R11', 'R11']); }); test('accepts the current listener and purchase shapes', () => { @@ -68,9 +75,7 @@ describe('active docs code-example audit', () => { {\`iap.purchaseUpdatedStream.listen(onPurchase);\`} `; - expect(auditActiveCodeExampleSource('/tmp/active.tsx', source)).toEqual([ - expect.objectContaining({ rule: 'R11', line: 2 }), - ]); + expect(auditActiveCodeExampleSource('/tmp/active.tsx', source)).toEqual([expect.objectContaining({ rule: 'R11', line: 2 })]); }); test('flags offer-token logging across formatted lines', () => { @@ -78,18 +83,14 @@ describe('active docs code-example audit', () => { offer.offerToken )\`}`; - expect(auditActiveCodeExampleSource('/tmp/active.tsx', source)).toEqual([ - expect.objectContaining({ rule: 'R11', line: 1 }), - ]); + expect(auditActiveCodeExampleSource('/tmp/active.tsx', source)).toEqual([expect.objectContaining({ rule: 'R11', line: 1 })]); }); test('flags a top-level Godot purchase sku', () => { const source = `{\`var props = Types.RequestPurchaseProps.new() props.sku = "premium"\`}`; - expect(auditActiveCodeExampleSource('/tmp/active.tsx', source)).toEqual([ - expect.objectContaining({ rule: 'R11', line: 1 }), - ]); + expect(auditActiveCodeExampleSource('/tmp/active.tsx', source)).toEqual([expect.objectContaining({ rule: 'R11', line: 1 })]); }); test('flags obsolete Kotlin and KMP requestPurchase named arguments', () => { @@ -123,31 +124,30 @@ props.sku = "premium"\`}`; }); }); -describe('brace-block parsing', () => { - test('handles nested template literals without closing the object early', () => { - const source = '{ description: `outer ${`}`}`, path: true }'; - - expect(extractBraceBlock(source, 0)).toBe( - ' description: `outer ${`}`}`, path: true ' - ); - }); -}); - const validOfferDocsSources = ( - overrides: Partial> = {} + overrides: { + discountOffer?: string; + subscriptionOffer?: string; + searchData?: string; + generatedOfferTypes?: Partial>; + } = {}, ): CanonicalOfferDocsSources => ({ discountOffer: { file: '/tmp/discount-offer.tsx', - source: + source: renderPage( + 'DiscountOfferPage', overrides.discountOffer ?? - `

      DiscountOffer represents one-time products on Android via ProductDetails.OneTimePurchaseOfferDetails.

      + `

      DiscountOffer represents one-time products on Android via ProductDetails.OneTimePurchaseOfferDetails.

      ${VALID_DISCOUNT_OFFER_TYPE_BLOCKS}`, + ), }, subscriptionOffer: { file: '/tmp/subscription-offer.tsx', - source: + source: renderPage( + 'SubscriptionOfferPage', overrides.subscriptionOffer ?? - '

      SubscriptionOffer maps to Product.SubscriptionOffer and ProductDetails.SubscriptionOfferDetails.

      ', + '

      SubscriptionOffer maps to Product.SubscriptionOffer and ProductDetails.SubscriptionOfferDetails.

      ', + ), }, searchData: { file: '/tmp/searchData.ts', @@ -168,6 +168,15 @@ ${VALID_DISCOUNT_OFFER_TYPE_BLOCKS}`, }, ];`, }, + generatedOfferTypes: Object.fromEntries( + (Object.entries(VALID_GENERATED_OFFER_TYPES) as [OfferTypeLanguage, string][]).map(([language, source]) => [ + language, + { + file: `/tmp/generated-${language}-types`, + source: overrides.generatedOfferTypes?.[language] ?? source, + }, + ]), + ) as CanonicalOfferDocsSources['generatedOfferTypes'], }); describe('canonical offer docs audit', () => { @@ -175,12 +184,83 @@ describe('canonical offer docs audit', () => { expect(auditCanonicalOfferDocs(validOfferDocsSources())).toEqual([]); }); + test('derives TypeScript wire values from the generated SSOT', () => { + const drifts = auditCanonicalOfferDocs( + validOfferDocsSources({ + generatedOfferTypes: { + typescript: "export type DiscountOfferType = 'introductory' | 'promotional' | 'one-time' | 'seasonal';", + }, + }), + ); + + expect(drifts).toEqual([ + expect.objectContaining({ + rule: 'R12', + message: expect.stringContaining("'introductory', 'promotional', 'one-time', and 'seasonal'"), + }), + ]); + }); + + test.each([ + [ + 'swift', + replaceRequired( + VALID_GENERATED_OFFER_TYPES.swift, + ' case oneTime = "one-time"', + ' case oneTime = "one-time"\n case seasonal = "seasonal"', + ), + ], + [ + 'kotlin', + replaceRequired(VALID_GENERATED_OFFER_TYPES.kotlin, ' OneTime("one-time")', ' OneTime("one-time"),\n Seasonal("seasonal")'), + ], + [ + 'dart', + replaceRequired(VALID_GENERATED_OFFER_TYPES.dart, " OneTime('one-time');", " OneTime('one-time'),\n Seasonal('seasonal');"), + ], + ] as const)('derives %s members from the generated SSOT', (language, generatedSource) => { + const drifts = auditCanonicalOfferDocs( + validOfferDocsSources({ + generatedOfferTypes: { + [language]: generatedSource, + }, + }), + ); + + expect(drifts).toEqual([ + expect.objectContaining({ + rule: 'R12', + message: expect.stringContaining( + `The canonical DiscountOffer ${language} snippet must declare exactly the generated DiscountOfferType members`, + ), + }), + ]); + }); + + test('does not cascade docs errors when the TypeScript SSOT is invalid', () => { + const drifts = auditCanonicalOfferDocs( + validOfferDocsSources({ + generatedOfferTypes: { + typescript: 'export interface NotDiscountOfferType {}', + }, + }), + ); + + expect(drifts).toEqual([ + expect.objectContaining({ + file: '/tmp/generated-typescript-types', + rule: 'R12', + message: 'The generated TypeScript SSOT must declare DiscountOfferType as a string-literal union.', + }), + ]); + }); + test('flags missing one-time Android native semantics', () => { const drifts = auditCanonicalOfferDocs( validOfferDocsSources({ discountOffer: `

      A generic cross-platform discount.

      ${VALID_DISCOUNT_OFFER_TYPE_BLOCKS}`, - }) + }), ); expect(drifts).toEqual([ @@ -195,6 +275,107 @@ ${VALID_DISCOUNT_OFFER_TYPE_BLOCKS}`, ]); }); + test('does not accept comments or CodeBlocks as native semantic evidence', () => { + const decoyBlock = `{\` +ProductDetails.OneTimePurchaseOfferDetails +Product.SubscriptionOffer +ProductDetails.SubscriptionOfferDetails +Android one-time +\`}`; + const drifts = auditCanonicalOfferDocs( + validOfferDocsSources({ + discountOffer: `
      + {/* ProductDetails.OneTimePurchaseOfferDetails; Android one-time */} + ${decoyBlock} + ${VALID_DISCOUNT_OFFER_TYPE_BLOCKS} +
      `, + subscriptionOffer: `
      + {/* Product.SubscriptionOffer and ProductDetails.SubscriptionOfferDetails */} + ${decoyBlock} +
      `, + }), + ); + + expect(drifts).toEqual([ + expect.objectContaining({ + file: '/tmp/discount-offer.tsx', + message: expect.stringContaining('OneTimePurchaseOfferDetails'), + }), + expect.objectContaining({ + file: '/tmp/discount-offer.tsx', + message: expect.stringContaining('one-time product offers'), + }), + expect.objectContaining({ + file: '/tmp/subscription-offer.tsx', + message: expect.stringContaining('Product.SubscriptionOffer'), + }), + expect.objectContaining({ + file: '/tmp/subscription-offer.tsx', + message: expect.stringContaining('ProductDetails.SubscriptionOfferDetails'), + }), + ]); + }); + + test('does not accept unused JSX declarations as rendered semantic evidence', () => { + const sources = validOfferDocsSources({ + discountOffer: `

      A generic cross-platform discount.

      +${VALID_DISCOUNT_OFFER_TYPE_BLOCKS}`, + }); + sources.discountOffer.source += + '\nconst UNUSED_DECOY =
      ProductDetails.OneTimePurchaseOfferDetails Android one-time product offers
      ;'; + + expect(auditCanonicalOfferDocs(sources)).toEqual([ + expect.objectContaining({ + file: '/tmp/discount-offer.tsx', + message: expect.stringContaining('OneTimePurchaseOfferDetails'), + }), + expect.objectContaining({ + file: '/tmp/discount-offer.tsx', + message: expect.stringContaining('one-time product offers'), + }), + ]); + }); + + test('audits prose rendered by local JSX components', () => { + const sources = validOfferDocsSources({ + discountOffer: ` +

      DiscountOffer represents one-time products on Android via ProductDetails.OneTimePurchaseOfferDetails.

      +${VALID_DISCOUNT_OFFER_TYPE_BLOCKS}`, + }); + sources.discountOffer.source = `const LocalClaim = () =>

      WinBack is supported.

      ; +${sources.discountOffer.source}`; + + expect(auditCanonicalOfferDocs(sources)).toEqual([ + expect.objectContaining({ + file: '/tmp/discount-offer.tsx', + rule: 'R12', + message: expect.stringContaining('WinBack'), + }), + ]); + }); + + test('ignores forbidden claims that occur only in comments or CodeBlocks', () => { + const commentsAndExamples = `
      + {/* Product.SubscriptionOffer SubscriptionOfferDetails WinBack */} + {\`Product.SubscriptionOffer SubscriptionOfferDetails WinBack\`} +
      `; + const sources = validOfferDocsSources(); + + expect( + auditCanonicalOfferDocs({ + ...sources, + discountOffer: { + ...sources.discountOffer, + source: `${sources.discountOffer.source}\n${commentsAndExamples}`, + }, + subscriptionOffer: { + ...sources.subscriptionOffer, + source: `${sources.subscriptionOffer.source}\n${commentsAndExamples}`, + }, + }), + ).toEqual([]); + }); + test('flags subscription mappings and invented WinBack claims on DiscountOffer', () => { const drifts = auditCanonicalOfferDocs( validOfferDocsSources({ @@ -202,7 +383,7 @@ ${VALID_DISCOUNT_OFFER_TYPE_BLOCKS}`,

      Maps to Product.SubscriptionOffer and SubscriptionOfferDetails.

      WinBack is supported.

      ${VALID_DISCOUNT_OFFER_TYPE_BLOCKS}`, - }) + }), ); expect(drifts).toEqual([ @@ -226,7 +407,7 @@ ${VALID_DISCOUNT_OFFER_TYPE_BLOCKS}`, validOfferDocsSources({ subscriptionOffer: '

      SubscriptionOffer maps to Product.SubscriptionOffer and ProductDetails.SubscriptionOfferDetails, and includes WinBack.

      ', - }) + }), ); expect(drifts).toEqual([ @@ -240,25 +421,17 @@ ${VALID_DISCOUNT_OFFER_TYPE_BLOCKS}`, test('requires both native subscription offer mappings', () => { for (const [subscriptionOffer, missingType] of [ - [ - '

      SubscriptionOffer maps to ProductDetails.SubscriptionOfferDetails.

      ', - 'Product.SubscriptionOffer', - ], - [ - '

      SubscriptionOffer maps to Product.SubscriptionOffer.

      ', - 'ProductDetails.SubscriptionOfferDetails', - ], + ['

      SubscriptionOffer maps to ProductDetails.SubscriptionOfferDetails.

      ', 'Product.SubscriptionOffer'], + ['

      SubscriptionOffer maps to Product.SubscriptionOffer.

      ', 'ProductDetails.SubscriptionOfferDetails'], ['

      A generic subscription offer.

      ', 'Product.SubscriptionOffer'], ] as const) { - const drifts = auditCanonicalOfferDocs( - validOfferDocsSources({ subscriptionOffer }) - ); + const drifts = auditCanonicalOfferDocs(validOfferDocsSources({ subscriptionOffer })); expect(drifts).toContainEqual( expect.objectContaining({ rule: 'R12', message: expect.stringContaining(missingType), - }) + }), ); } }); @@ -268,40 +441,38 @@ ${VALID_DISCOUNT_OFFER_TYPE_BLOCKS}`, "type DiscountOfferType = 'Introductory' | 'Promotional' | 'OneTime';", "type DiscountOfferType = 'introductory' | 'promotional' | 'one-time' | 'legacy';", ]) { - const discountOffer = VALID_DISCOUNT_OFFER_TYPE_BLOCKS.replace( - /\{`[\s\S]*?`}<\/CodeBlock>/, - `{\` -${declaration} -\`}` + const discountOffer = replaceRequired( + VALID_DISCOUNT_OFFER_TYPE_BLOCKS, + offerTypeBlockPattern('typescript'), + offerTypeBlock('typescript', declaration), ); const drifts = auditCanonicalOfferDocs( validOfferDocsSources({ discountOffer: `

      DiscountOffer represents one-time products on Android via ProductDetails.OneTimePurchaseOfferDetails.

      ${discountOffer}`, - }) + }), ); expect(drifts).toEqual([ expect.objectContaining({ rule: 'R12', line: 3, - message: expect.stringContaining( - "exactly the generated wire values 'introductory', 'promotional', and 'one-time'" - ), + message: expect.stringContaining("exactly the generated wire values 'introductory', 'promotional', and 'one-time'"), }), ]); } }); test('accepts a multiline TypeScript union with leading delimiters', () => { - const discountOffer = VALID_DISCOUNT_OFFER_TYPE_BLOCKS.replace( - /\{`[\s\S]*?`}<\/CodeBlock>/, + const discountOffer = replaceRequired( + VALID_DISCOUNT_OFFER_TYPE_BLOCKS, + offerTypeBlockPattern('typescript'), `{\` type DiscountOfferType = | 'introductory' | 'promotional' | 'one-time'; -\`}` +\`}`, ); expect( @@ -309,21 +480,22 @@ type DiscountOfferType = validOfferDocsSources({ discountOffer: `

      DiscountOffer represents one-time products on Android via ProductDetails.OneTimePurchaseOfferDetails.

      ${discountOffer}`, - }) - ) + }), + ), ).toEqual([]); }); test('accepts a parenthesized TypeScript union', () => { - const discountOffer = VALID_DISCOUNT_OFFER_TYPE_BLOCKS.replace( - /\{`[\s\S]*?`}<\/CodeBlock>/, + const discountOffer = replaceRequired( + VALID_DISCOUNT_OFFER_TYPE_BLOCKS, + offerTypeBlockPattern('typescript'), `{\` type DiscountOfferType = ( | 'introductory' | 'promotional' | 'one-time' ); -\`}` +\`}`, ); expect( @@ -331,23 +503,24 @@ type DiscountOfferType = ( validOfferDocsSources({ discountOffer: `

      DiscountOffer represents one-time products on Android via ProductDetails.OneTimePurchaseOfferDetails.

      ${discountOffer}`, - }) - ) + }), + ), ).toEqual([]); }); test('ignores commented TypeScript declarations', () => { - const discountOffer = VALID_DISCOUNT_OFFER_TYPE_BLOCKS.replace( - /\{`[\s\S]*?`}<\/CodeBlock>/, + const discountOffer = replaceRequired( + VALID_DISCOUNT_OFFER_TYPE_BLOCKS, + offerTypeBlockPattern('typescript'), `{\` // type DiscountOfferType = 'introductory' | 'promotional' | 'one-time'; -\`}` +\`}`, ); const drifts = auditCanonicalOfferDocs( validOfferDocsSources({ discountOffer: `

      DiscountOffer represents one-time products on Android via ProductDetails.OneTimePurchaseOfferDetails.

      ${discountOffer}`, - }) + }), ); expect(drifts).toEqual([ @@ -358,6 +531,75 @@ ${discountOffer}`, ]); }); + test('ignores generated-language enum declarations inside comments and strings', () => { + for (const language of ['swift', 'kotlin', 'dart'] as const) { + const canonical = VALID_GENERATED_OFFER_TYPES[language]; + const stringDecoy = + language === 'swift' + ? `let decoy = """ +${canonical} +"""` + : language === 'kotlin' + ? `val decoy = """ +${canonical} +"""` + : `const decoy = r''' +${canonical} +''';`; + const wrongDeclaration = replaceRequired(canonical, 'one-time', 'OneTime'); + const brokenBlock = offerTypeBlock( + language, + `/* +${canonical} +*/ +${stringDecoy} +${wrongDeclaration}`, + ); + const discountOffer = replaceRequired(VALID_DISCOUNT_OFFER_TYPE_BLOCKS, offerTypeBlockPattern(language), brokenBlock); + + expect( + auditCanonicalOfferDocs( + validOfferDocsSources({ + discountOffer: `

      DiscountOffer represents one-time products on Android via ProductDetails.OneTimePurchaseOfferDetails.

      +${discountOffer}`, + }), + ), + ).toEqual([ + expect.objectContaining({ + rule: 'R12', + message: expect.stringContaining(`${language} snippet`), + }), + ]); + } + }); + + test('ignores nested Swift enum declarations when selecting the canonical declaration', () => { + const canonical = VALID_GENERATED_OFFER_TYPES.swift; + const wrongDeclaration = replaceRequired(canonical, 'one-time', 'OneTime'); + const brokenBlock = offerTypeBlock( + 'swift', + `struct Decoy { +${canonical} +} +${wrongDeclaration}`, + ); + const discountOffer = replaceRequired(VALID_DISCOUNT_OFFER_TYPE_BLOCKS, offerTypeBlockPattern('swift'), brokenBlock); + + expect( + auditCanonicalOfferDocs( + validOfferDocsSources({ + discountOffer: `

      DiscountOffer represents one-time products on Android via ProductDetails.OneTimePurchaseOfferDetails.

      +${discountOffer}`, + }), + ), + ).toEqual([ + expect.objectContaining({ + rule: 'R12', + message: expect.stringContaining('swift snippet'), + }), + ]); + }); + test('flags incorrect generated-language DiscountOfferType wire values', () => { for (const [language, brokenBlock] of [ [ @@ -391,17 +633,12 @@ enum DiscountOfferType { \`}
      `, ], ] as const) { - const discountOffer = VALID_DISCOUNT_OFFER_TYPE_BLOCKS.replace( - new RegExp( - `\\{\\\`[\\s\\S]*?\\\`\\}` - ), - brokenBlock - ); + const discountOffer = replaceRequired(VALID_DISCOUNT_OFFER_TYPE_BLOCKS, offerTypeBlockPattern(language), brokenBlock); const drifts = auditCanonicalOfferDocs( validOfferDocsSources({ discountOffer: `

      DiscountOffer represents one-time products on Android via ProductDetails.OneTimePurchaseOfferDetails.

      ${discountOffer}`, - }) + }), ); expect(drifts).toEqual([ @@ -449,17 +686,12 @@ enum DiscountOfferType { \`}`, ], ] as const) { - const discountOffer = VALID_DISCOUNT_OFFER_TYPE_BLOCKS.replace( - new RegExp( - `\\{\\\`[\\s\\S]*?\\\`\\}` - ), - brokenBlock - ); + const discountOffer = replaceRequired(VALID_DISCOUNT_OFFER_TYPE_BLOCKS, offerTypeBlockPattern(language), brokenBlock); const drifts = auditCanonicalOfferDocs( validOfferDocsSources({ discountOffer: `

      DiscountOffer represents one-time products on Android via ProductDetails.OneTimePurchaseOfferDetails.

      ${discountOffer}`, - }) + }), ); expect(drifts).toEqual([ @@ -486,12 +718,10 @@ enum DiscountOfferType { OneTime("one-time"); } \`}`; - const discountOffer = VALID_DISCOUNT_OFFER_TYPE_BLOCKS.replace( - /\{`[\s\S]*?`}<\/CodeBlock>/, - swiftCombined - ).replace( - /\{`[\s\S]*?`}<\/CodeBlock>/, - dartDoubleQuoted + const discountOffer = replaceRequired( + replaceRequired(VALID_DISCOUNT_OFFER_TYPE_BLOCKS, offerTypeBlockPattern('swift'), swiftCombined), + offerTypeBlockPattern('dart'), + dartDoubleQuoted, ); expect( @@ -499,8 +729,8 @@ enum DiscountOfferType { validOfferDocsSources({ discountOffer: `

      DiscountOffer represents one-time products on Android via ProductDetails.OneTimePurchaseOfferDetails.

      ${discountOffer}`, - }) - ) + }), + ), ).toEqual([]); }); @@ -515,9 +745,7 @@ ${discountOffer}`, path: '/docs/types/android/subscription-offer-android', }, ];`; - const wrongRouteDrifts = auditCanonicalOfferDocs( - validOfferDocsSources({ searchData: legacySearchData }) - ); + const wrongRouteDrifts = auditCanonicalOfferDocs(validOfferDocsSources({ searchData: legacySearchData })); expect(wrongRouteDrifts).toEqual([ expect.objectContaining({ @@ -530,9 +758,7 @@ ${discountOffer}`, }), ]); - const missingEntryDrifts = auditCanonicalOfferDocs( - validOfferDocsSources({ searchData: 'export const apiData = [];' }) - ); + const missingEntryDrifts = auditCanonicalOfferDocs(validOfferDocsSources({ searchData: 'export const apiData = [];' })); expect(missingEntryDrifts).toEqual([ expect.objectContaining({ message: expect.stringContaining('canonical DiscountOffer entry'), @@ -547,9 +773,7 @@ ${discountOffer}`, const commentedEntries = `export const apiData = []; // { title: 'DiscountOffer', path: '/docs/types/discount-offer' } /* { title: 'SubscriptionOffer', path: '/docs/types/subscription-offer' } */`; - const commentedDrifts = auditCanonicalOfferDocs( - validOfferDocsSources({ searchData: commentedEntries }) - ); + const commentedDrifts = auditCanonicalOfferDocs(validOfferDocsSources({ searchData: commentedEntries })); expect(commentedDrifts).toEqual([ expect.objectContaining({ message: expect.stringContaining('canonical DiscountOffer entry'), @@ -571,9 +795,7 @@ ${discountOffer}`, path: '/wrong-subscription-path', }, ];`; - const pathDrifts = auditCanonicalOfferDocs( - validOfferDocsSources({ searchData: misleadingDescriptions }) - ); + const pathDrifts = auditCanonicalOfferDocs(validOfferDocsSources({ searchData: misleadingDescriptions })); expect(pathDrifts).toEqual([ expect.objectContaining({ line: 5, @@ -598,11 +820,7 @@ ${discountOffer}`, { metadata: { path: '/internal/subscription-offer-metadata' }, title: 'SubscriptionOffer', path: '/docs/types/subscription-offer' }, ];`; - expect( - auditCanonicalOfferDocs( - validOfferDocsSources({ searchData: reformattedSearchData }) - ) - ).toEqual([]); + expect(auditCanonicalOfferDocs(validOfferDocsSources({ searchData: reformattedSearchData }))).toEqual([]); }); test('accepts parenthesized and typed apiData array initializers', () => { @@ -615,9 +833,7 @@ ${discountOffer}`, `export const apiData = ${entries} as const;`, `export const apiData = ${entries} satisfies readonly SearchItem[];`, ]) { - expect( - auditCanonicalOfferDocs(validOfferDocsSources({ searchData })) - ).toEqual([]); + expect(auditCanonicalOfferDocs(validOfferDocsSources({ searchData }))).toEqual([]); } }); @@ -633,9 +849,7 @@ ${discountOffer}`, } as const), ];`; - expect( - auditCanonicalOfferDocs(validOfferDocsSources({ searchData })) - ).toEqual([]); + expect(auditCanonicalOfferDocs(validOfferDocsSources({ searchData }))).toEqual([]); }); test('ignores nested apiData shadow declarations', () => { @@ -648,9 +862,7 @@ function shadow() { return apiData; }`; - expect( - auditCanonicalOfferDocs(validOfferDocsSources({ searchData })) - ).toEqual([]); + expect(auditCanonicalOfferDocs(validOfferDocsSources({ searchData }))).toEqual([]); }); test('parses search entries after nested template literals', () => { @@ -669,9 +881,7 @@ function shadow() { '];', ].join('\n'); - expect( - auditCanonicalOfferDocs(validOfferDocsSources({ searchData })) - ).toEqual([]); + expect(auditCanonicalOfferDocs(validOfferDocsSources({ searchData }))).toEqual([]); }); test('ignores braces inside search strings and comments', () => { @@ -715,9 +925,7 @@ function shadow() { ]; for (const searchData of edgeCases) { - expect( - auditCanonicalOfferDocs(validOfferDocsSources({ searchData })) - ).toEqual([]); + expect(auditCanonicalOfferDocs(validOfferDocsSources({ searchData }))).toEqual([]); } }); }); diff --git a/scripts/audit-docs.ts b/scripts/audit-docs.ts index d87e593d2..b451d1f32 100644 --- a/scripts/audit-docs.ts +++ b/scripts/audit-docs.ts @@ -5,15 +5,13 @@ * What it does * 1. Walks every `packages/docs/src/pages/docs/apis/**\/*.tsx` and * `packages/docs/src/pages/docs/types/**\/*.tsx` page. - * 2. Loads the generated TypeScript types from - * `libraries/expo-iap/src/types.ts` and indexes every `interface`, - * `type` alias, and `enum` / string-literal union shape. + * 2. Loads the generated TypeScript SSOT from + * `packages/gql/src/generated/types.ts` and indexes every exported + * `interface` and object-shaped `type` alias field. * 3. For each doc page, extracts: * - `` targets * - `fieldName` mentions inside `
    ` rows or * `
      ` lists - * - Enum-style `'literal'` mentions - * - `@see {@link …}` URLs * and cross-checks each against the type index. * 4. Reports drift as a punch-list (file:line — what's wrong). * 5. Checks release-note `Package Releases` lists so published package @@ -34,46 +32,29 @@ */ import { readFileSync, statSync } from 'node:fs'; import { readdir } from 'node:fs/promises'; -import { dirname, join, relative, resolve } from 'node:path'; +import { join, relative, resolve } from 'node:path'; import ts from 'typescript'; +import { GENERATED_SYNC_MANIFEST } from '../packages/gql/generated-sync-manifest.mjs'; const REPO_ROOT = resolve(import.meta.dir, '..'); -const DOC_ROOTS = [ - resolve(REPO_ROOT, 'packages/docs/src/pages/docs/apis'), - resolve(REPO_ROOT, 'packages/docs/src/pages/docs/types'), -]; +const DOC_ROOTS = [resolve(REPO_ROOT, 'packages/docs/src/pages/docs/apis'), resolve(REPO_ROOT, 'packages/docs/src/pages/docs/types')]; const DOC_PAGES_DIR = resolve(REPO_ROOT, 'packages/docs/src/pages'); const ACTIVE_DOCS_ROOT = resolve(REPO_ROOT, 'packages/docs/src/pages/docs'); -const TYPES_FILE = resolve(REPO_ROOT, 'libraries/expo-iap/src/types.ts'); -const RELEASE_NOTES_FILE = resolve( - REPO_ROOT, - 'packages/docs/src/pages/docs/updates/releases.tsx' -); -const VERSIONING_FILE = resolve( - REPO_ROOT, - 'packages/docs/src/lib/versioning.ts' -); -const DOC_VERSIONS_FILE = resolve( - REPO_ROOT, - 'packages/docs/openiap-versions.json' -); +const TYPES_FILE = resolve(REPO_ROOT, GENERATED_SYNC_MANIFEST.typescript.source); +const RELEASE_NOTES_FILE = resolve(REPO_ROOT, 'packages/docs/src/pages/docs/updates/releases.tsx'); +const VERSIONING_FILE = resolve(REPO_ROOT, 'packages/docs/src/lib/versioning.ts'); +const DOC_VERSIONS_FILE = resolve(REPO_ROOT, 'packages/docs/openiap-versions.json'); const ROOT_VERSIONS_FILE = resolve(REPO_ROOT, 'openiap-versions.json'); -const DOC_VERSION_METADATA_FILE = resolve( - REPO_ROOT, - 'packages/docs/src/generated/version-metadata.json' -); -const DISCOUNT_OFFER_DOC_FILE = resolve( - REPO_ROOT, - 'packages/docs/src/pages/docs/types/discount-offer.tsx' -); -const SUBSCRIPTION_OFFER_DOC_FILE = resolve( - REPO_ROOT, - 'packages/docs/src/pages/docs/types/subscription-offer.tsx' -); -const SEARCH_DATA_FILE = resolve( - REPO_ROOT, - 'packages/docs/src/lib/searchData.ts' -); +const DOC_VERSION_METADATA_FILE = resolve(REPO_ROOT, 'packages/docs/src/generated/version-metadata.json'); +const DISCOUNT_OFFER_DOC_FILE = resolve(REPO_ROOT, 'packages/docs/src/pages/docs/types/discount-offer.tsx'); +const SUBSCRIPTION_OFFER_DOC_FILE = resolve(REPO_ROOT, 'packages/docs/src/pages/docs/types/subscription-offer.tsx'); +const SEARCH_DATA_FILE = resolve(REPO_ROOT, 'packages/docs/src/lib/searchData.ts'); +const GENERATED_OFFER_TYPE_FILES = { + typescript: TYPES_FILE, + swift: resolve(REPO_ROOT, GENERATED_SYNC_MANIFEST.swift.source), + kotlin: resolve(REPO_ROOT, GENERATED_SYNC_MANIFEST.kotlin.source), + dart: resolve(REPO_ROOT, GENERATED_SYNC_MANIFEST.dart.source), +} as const; type Drift = { file: string; @@ -91,6 +72,7 @@ export type CanonicalOfferDocsSources = { discountOffer: SourceFile; subscriptionOffer: SourceFile; searchData: SourceFile; + generatedOfferTypes: Record<'typescript' | 'swift' | 'kotlin' | 'dart', SourceFile>; }; type CodeExampleRule = { @@ -103,57 +85,46 @@ const CODE_EXAMPLE_RULES: CodeExampleRule[] = [ { language: 'csharp', pattern: /@Deprecated|Task<(?:Boolean|String|List<)|\bList\b/, - message: - 'C# examples must use C# attributes and primitive/collection types (`[Obsolete]`, `bool`, `string`, `IReadOnlyList`).', + message: 'C# examples must use C# attributes and primitive/collection types (`[Obsolete]`, `bool`, `string`, `IReadOnlyList`).', }, { language: 'csharp', - pattern: - /\?:\s*return|\bwhen\s*\(|(?:^|\n)\s*(?:else|null)\s*->|\?\.let\s*\{|\bprintln\s*\(/m, + pattern: /\?:\s*return|\bwhen\s*\(|(?:^|\n)\s*(?:else|null)\s*->|\?\.let\s*\{|\bprintln\s*\(/m, message: 'C# example contains Kotlin syntax.', }, { language: 'dart', - pattern: - /\b(?:purchaseUpdatedStream|purchaseErrorStream|userChoiceBillingStream)\b/, + pattern: /\b(?:purchaseUpdatedStream|purchaseErrorStream|userChoiceBillingStream)\b/, message: 'Flutter examples must use the current listener streams (`purchaseUpdatedListener`, `purchaseErrorListener`, or `userChoiceBillingAndroid`).', }, { language: 'dart', pattern: /\.finishTransaction\(\s*(?!purchase\s*:)[A-Za-z_]\w*\s*(?:,|\))/, - message: - 'Flutter `finishTransaction` requires the named `purchase:` argument.', + message: 'Flutter `finishTransaction` requires the named `purchase:` argument.', }, { pattern: /OpenIapStore\.shared/, - message: - 'Apple/native store examples must construct or inject `OpenIapStore`; no `shared` singleton exists.', + message: 'Apple/native store examples must construct or inject `OpenIapStore`; no `shared` singleton exists.', }, { - pattern: - /\b(?:verifyPurchase|verify_purchase)\b[\s\S]{0,400}\b(?:serverUrl|server_url)\b/, - message: - '`verifyPurchase` accepts platform verification options, not a Purchase plus server URL.', + pattern: /\b(?:verifyPurchase|verify_purchase)\b[\s\S]{0,400}\b(?:serverUrl|server_url)\b/, + message: '`verifyPurchase` accepts platform verification options, not a Purchase plus server URL.', }, { language: 'typescript', pattern: /requestPurchase\(\{\s*(?:sku|purchaseToken|replacementMode)\s*:/, - message: - 'TypeScript `requestPurchase` must use the `request` platform union and explicit `type`.', + message: 'TypeScript `requestPurchase` must use the `request` platform union and explicit `type`.', }, { language: 'swift', pattern: /\bsubscription\.remove\(\)/, - message: - 'Swift listener tokens are removed with `OpenIapModule.shared.removeListener(subscription)`.', + message: 'Swift listener tokens are removed with `OpenIapModule.shared.removeListener(subscription)`.', }, { language: 'gdscript', - pattern: - /\bvar\s+([A-Za-z_]\w*)\s*=\s*(?:Types\.)?RequestPurchaseProps\.new\(\)[\s\S]{0,200}\b\1\.sku\s*=/, - message: - 'RequestPurchaseProps has no top-level sku; populate one request branch or use in_app().', + pattern: /\bvar\s+([A-Za-z_]\w*)\s*=\s*(?:Types\.)?RequestPurchaseProps\.new\(\)[\s\S]{0,200}\b\1\.sku\s*=/, + message: 'RequestPurchaseProps has no top-level sku; populate one request branch or use in_app().', }, { language: 'kotlin', @@ -162,27 +133,21 @@ const CODE_EXAMPLE_RULES: CodeExampleRule[] = [ 'Kotlin and KMP `requestPurchase` accept one positional `RequestPurchaseProps` argument; `activity` and `props` are not parameters.', }, { - pattern: - /(?:console\.log|println|print|Console\.WriteLine|Log\.[a-z]+)\([^)]{0,200}\b(?:offerToken|offer_token|OfferToken)\b/, + pattern: /(?:console\.log|println|print|Console\.WriteLine|Log\.[a-z]+)\([^)]{0,200}\b(?:offerToken|offer_token|OfferToken)\b/, message: 'Offer tokens must not be written to application logs.', }, ]; -export function auditActiveCodeExampleSource( - filePath: string, - src: string -): Drift[] { +export function auditActiveCodeExampleSource(filePath: string, src: string): Drift[] { if (resolve(filePath) === RELEASE_NOTES_FILE) return []; const drifts: Drift[] = []; - const blockRe = - /]*\blanguage="([^"]+)"[^>]*>\s*\{`([\s\S]*?)`\}\s*<\/CodeBlock>/g; + const blockRe = /]*\blanguage="([^"]+)"[^>]*>\s*\{`([\s\S]*?)`\}\s*<\/CodeBlock>/g; let blockMatch: RegExpExecArray | null; while ((blockMatch = blockRe.exec(src)) !== null) { const language = blockMatch[1]; const block = blockMatch[2]; - const auditedBlock = - language === 'kotlin' ? stripCommentsPreservingLayout(block) : block; + const auditedBlock = language === 'kotlin' ? stripCommentsPreservingLayout(block) : block; const blockOffset = blockMatch.index + blockMatch[0].indexOf(block); for (const rule of CODE_EXAMPLE_RULES) { @@ -200,18 +165,9 @@ export function auditActiveCodeExampleSource( return drifts; } -function findSearchEntriesByTitle( - source: string, - title: string -): { line: number; path: string | null; pathLine: number }[] { +function findSearchEntriesByTitle(source: string, title: string): { line: number; path: string | null; pathLine: number }[] { const entries: { line: number; path: string | null; pathLine: number }[] = []; - const sourceFile = ts.createSourceFile( - 'searchData.ts', - source, - ts.ScriptTarget.Latest, - true, - ts.ScriptKind.TS - ); + const sourceFile = ts.createSourceFile('searchData.ts', source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS); let apiData: ts.ArrayLiteralExpression | null = null; const unwrapExpression = (expression: ts.Expression): ts.Expression => { @@ -232,11 +188,7 @@ function findSearchEntriesByTitle( for (const statement of sourceFile.statements) { if (!ts.isVariableStatement(statement)) continue; for (const declaration of statement.declarationList.declarations) { - if ( - !ts.isIdentifier(declaration.name) || - declaration.name.text !== 'apiData' || - !declaration.initializer - ) { + if (!ts.isIdentifier(declaration.name) || declaration.name.text !== 'apiData' || !declaration.initializer) { continue; } const initializer = unwrapExpression(declaration.initializer); @@ -246,18 +198,14 @@ function findSearchEntriesByTitle( if (!apiData) return entries; - const propertyName = ( - property: ts.ObjectLiteralElementLike - ): string | null => { + const propertyName = (property: ts.ObjectLiteralElementLike): string | null => { if (!property.name) return null; if (ts.isIdentifier(property.name) || ts.isStringLiteral(property.name)) { return property.name.text; } return null; }; - const stringValue = ( - property: ts.ObjectLiteralElementLike - ): string | null => { + const stringValue = (property: ts.ObjectLiteralElementLike): string | null => { if (!ts.isPropertyAssignment(property)) return null; const initializer = unwrapExpression(property.initializer); return ts.isStringLiteralLike(initializer) ? initializer.text : null; @@ -266,86 +214,60 @@ function findSearchEntriesByTitle( for (const element of apiData.elements) { const entry = unwrapExpression(element); if (!ts.isObjectLiteralExpression(entry)) continue; - const titleProperty = entry.properties.find( - (property) => propertyName(property) === 'title' - ); + const titleProperty = entry.properties.find((property) => propertyName(property) === 'title'); if (!titleProperty || stringValue(titleProperty) !== title) continue; - const pathProperty = entry.properties.find( - (property) => propertyName(property) === 'path' - ); - const line = - sourceFile.getLineAndCharacterOfPosition( - titleProperty.getStart(sourceFile) - ).line + 1; + const pathProperty = entry.properties.find((property) => propertyName(property) === 'path'); + const line = sourceFile.getLineAndCharacterOfPosition(titleProperty.getStart(sourceFile)).line + 1; entries.push({ line, path: pathProperty ? stringValue(pathProperty) : null, - pathLine: pathProperty - ? sourceFile.getLineAndCharacterOfPosition( - pathProperty.getStart(sourceFile) - ).line + 1 - : line, + pathLine: pathProperty ? sourceFile.getLineAndCharacterOfPosition(pathProperty.getStart(sourceFile)).line + 1 : line, }); } return entries; } -function findTypeScriptDiscountOfferType( - source: string -): { line: number; members: string[] | null } | null { - const blockRe = - /]*\blanguage="typescript"[^>]*>\s*\{`([\s\S]*?)`\}\s*<\/CodeBlock>/g; +function parseTypeScriptDiscountOfferType(source: string): { index: number; members: string[] | null } | null { + const sourceFile = ts.createSourceFile('discount-offer-types.ts', source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS); + const declaration = sourceFile.statements.find( + (statement): statement is ts.TypeAliasDeclaration => + ts.isTypeAliasDeclaration(statement) && statement.name.text === 'DiscountOfferType', + ); + if (!declaration) return null; + + let offerType: ts.TypeNode = declaration.type; + while (ts.isParenthesizedTypeNode(offerType)) offerType = offerType.type; + const rawMembers = ts.isUnionTypeNode(offerType) ? offerType.types : [offerType]; + const members: string[] = []; + for (const rawMember of rawMembers) { + if (!ts.isLiteralTypeNode(rawMember) || !ts.isStringLiteral(rawMember.literal)) { + return { + index: declaration.getStart(sourceFile), + members: null, + }; + } + members.push(rawMember.literal.text); + } + + return { + index: declaration.getStart(sourceFile), + members, + }; +} + +function findTypeScriptDiscountOfferType(source: string): { line: number; members: string[] | null } | null { + const blockRe = /]*\blanguage="typescript"[^>]*>\s*\{`([\s\S]*?)`\}\s*<\/CodeBlock>/g; let blockMatch: RegExpExecArray | null; while ((blockMatch = blockRe.exec(source)) !== null) { const block = blockMatch[1]; - const sourceFile = ts.createSourceFile( - 'discount-offer-snippet.ts', - block, - ts.ScriptTarget.Latest, - true, - ts.ScriptKind.TS - ); - const declaration = sourceFile.statements.find( - (statement): statement is ts.TypeAliasDeclaration => - ts.isTypeAliasDeclaration(statement) && - statement.name.text === 'DiscountOfferType' - ); + const declaration = parseTypeScriptDiscountOfferType(block); if (!declaration) continue; - let offerType: ts.TypeNode = declaration.type; - while (ts.isParenthesizedTypeNode(offerType)) offerType = offerType.type; - const rawMembers = ts.isUnionTypeNode(offerType) - ? offerType.types - : [offerType]; - const members: string[] = []; - for (const rawMember of rawMembers) { - if ( - !ts.isLiteralTypeNode(rawMember) || - !ts.isStringLiteral(rawMember.literal) - ) { - return { - line: lineNumberAt( - source, - blockMatch.index + - blockMatch[0].indexOf(block) + - declaration.getStart(sourceFile) - ), - members: null, - }; - } - members.push(rawMember.literal.text); - } - return { - line: lineNumberAt( - source, - blockMatch.index + - blockMatch[0].indexOf(block) + - declaration.getStart(sourceFile) - ), - members, + line: lineNumberAt(source, blockMatch.index + blockMatch[0].indexOf(block) + declaration.index), + members: declaration.members, }; } @@ -354,9 +276,10 @@ function findTypeScriptDiscountOfferType( type NamedOfferTypeLanguage = 'swift' | 'kotlin' | 'dart'; -function stripCommentsPreservingLayout(source: string): string { +function maskNativeNonCodePreservingLayout(source: string, maskStrings: boolean): string { const output = source.split(''); let quote: "'" | '"' | null = null; + let tripleQuoted = false; let escaped = false; let inLineComment = false; let inBlockComment = false; @@ -383,6 +306,19 @@ function stripCommentsPreservingLayout(source: string): string { continue; } if (quote !== null) { + if (tripleQuoted && source.slice(i, i + 3) === quote.repeat(3)) { + if (maskStrings) { + output[i] = ' '; + output[i + 1] = ' '; + output[i + 2] = ' '; + } + quote = null; + tripleQuoted = false; + i += 2; + continue; + } + if (maskStrings && ch !== '\n') output[i] = ' '; + if (tripleQuoted) continue; if (escaped) { escaped = false; } else if (ch === '\\') { @@ -394,6 +330,15 @@ function stripCommentsPreservingLayout(source: string): string { } if (ch === "'" || ch === '"') { quote = ch; + tripleQuoted = source.slice(i, i + 3) === ch.repeat(3); + if (maskStrings) { + output[i] = ' '; + if (tripleQuoted) { + output[i + 1] = ' '; + output[i + 2] = ' '; + } + } + if (tripleQuoted) i += 2; continue; } if (ch === '/' && next === '/') { @@ -414,56 +359,287 @@ function stripCommentsPreservingLayout(source: string): string { return output.join(''); } +function stripCommentsPreservingLayout(source: string): string { + return maskNativeNonCodePreservingLayout(source, false); +} + +function findMatchingBrace(source: string, openingBrace: number): number | null { + let depth = 1; + for (let i = openingBrace + 1; i < source.length; i += 1) { + if (source[i] === '{') depth += 1; + if (source[i] !== '}') continue; + depth -= 1; + if (depth === 0) return i; + } + return null; +} + +function braceDepthAt(source: string, index: number): number { + let depth = 0; + for (let i = 0; i < index; i += 1) { + if (source[i] === '{') depth += 1; + else if (source[i] === '}') depth = Math.max(0, depth - 1); + } + return depth; +} + +function findTopLevelMemberBoundary(source: string, language: NamedOfferTypeLanguage): number { + let depth = 0; + for (let i = 0; i < source.length; i += 1) { + if (source[i] === '{') { + depth += 1; + continue; + } + if (source[i] === '}') { + depth = Math.max(0, depth - 1); + continue; + } + if (depth !== 0) continue; + if (language === 'dart' && source[i] === ';') return i; + if (language === 'kotlin' && /^companion\s+object\b/.test(source.slice(i))) return i; + } + return source.length; +} + +function parseNamedDiscountOfferTypeMembers( + source: string, + language: NamedOfferTypeLanguage, +): { index: number; members: string[] | null } | null { + const code = stripCommentsPreservingLayout(source); + const structuralCode = maskNativeNonCodePreservingLayout(source, true); + const declarationRe = + language === 'swift' + ? /\benum\s+DiscountOfferType\b[^{}]*\{/g + : language === 'kotlin' + ? /\benum\s+class\s+DiscountOfferType(?:\s*\([^)]*\))?\s*\{/g + : /\benum\s+DiscountOfferType\s*\{/g; + const declarations: { index: number; bodyStart: number; bodyEnd: number }[] = []; + let declaration: RegExpExecArray | null; + + while ((declaration = declarationRe.exec(structuralCode)) !== null) { + if (braceDepthAt(structuralCode, declaration.index) !== 0) continue; + const openingBrace = declaration.index + declaration[0].lastIndexOf('{'); + const closingBrace = findMatchingBrace(structuralCode, openingBrace); + if (closingBrace === null) { + return { index: declaration.index, members: null }; + } + declarations.push({ + index: declaration.index, + bodyStart: openingBrace + 1, + bodyEnd: closingBrace, + }); + } + if (declarations.length === 0) return null; + if (declarations.length !== 1) { + return { index: declarations[0].index, members: null }; + } + + const selected = declarations[0]; + const structuralBody = structuralCode.slice(selected.bodyStart, selected.bodyEnd); + const memberBoundary = findTopLevelMemberBoundary(structuralBody, language); + + const memberRe = + language === 'swift' + ? /(?:\bcase|,)\s*([A-Za-z]\w*)\s*=\s*"([^"]+)"/g + : language === 'kotlin' + ? /\b([A-Z]\w*)\s*\(\s*"([^"]+)"\s*\)/g + : /\b([A-Z]\w*)\s*\(\s*(['"])([^'"]+)\2\s*\)/g; + const memberSection = code.slice(selected.bodyStart, selected.bodyStart + memberBoundary); + const members = Array.from(memberSection.matchAll(memberRe), (match) => `${match[1]}=${match[language === 'dart' ? 3 : 2]}`); + const unmatchedMembers = memberSection.replace(memberRe, '').replace(/[\s,;]/g, '').length > 0; + + return { + index: selected.index, + members: unmatchedMembers ? null : members, + }; +} + function findNamedDiscountOfferTypeMembers( source: string, - language: NamedOfferTypeLanguage + language: NamedOfferTypeLanguage, ): { line: number; members: string[] | null } | null { - const blockRe = new RegExp( - `]*\\blanguage="${language}"[^>]*>\\s*\\{\`([\\s\\S]*?)\`\\}\\s*`, - 'g' - ); + const blockRe = new RegExp(`]*\\blanguage="${language}"[^>]*>\\s*\\{\`([\\s\\S]*?)\`\\}\\s*`, 'g'); let blockMatch: RegExpExecArray | null; while ((blockMatch = blockRe.exec(source)) !== null) { const block = blockMatch[1]; - const code = stripCommentsPreservingLayout(block); - const declaration = - language === 'swift' - ? /enum\s+DiscountOfferType[^{}]*\{([\s\S]*?)\}/.exec(code) - : language === 'kotlin' - ? /enum\s+class\s+DiscountOfferType(?:\([^)]*\))?\s*\{([\s\S]*?)\}/.exec( - code - ) - : /enum\s+DiscountOfferType\s*\{([\s\S]*?)\}/.exec(code); + const declaration = parseNamedDiscountOfferTypeMembers(block, language); if (!declaration) continue; - const memberRe = - language === 'swift' - ? /(?:\bcase|,)\s*([A-Za-z]\w*)\s*=\s*"([^"]+)"/g - : language === 'kotlin' - ? /\b([A-Z]\w*)\s*\(\s*"([^"]+)"\s*\)/g - : /\b([A-Z]\w*)\s*\(\s*(['"])([^'"]+)\2\s*\)/g; - const memberSection = - language === 'swift' ? declaration[1] : declaration[1].split(';', 1)[0]; - const members = Array.from( - memberSection.matchAll(memberRe), - (match) => `${match[1]}=${match[language === 'dart' ? 3 : 2]}` - ); - const unmatchedMembers = - memberSection.replace(memberRe, '').replace(/[\s,;]/g, '').length > 0; - return { - line: lineNumberAt( - source, - blockMatch.index + blockMatch[0].indexOf(block) + declaration.index - ), - members: unmatchedMembers ? null : members, + line: lineNumberAt(source, blockMatch.index + blockMatch[0].indexOf(block) + declaration.index), + members: declaration.members, }; } return null; } +type RenderedProse = { + text: string; + segments: { outputStart: number; sourceStart: number; text: string }[]; +}; + +/** + * Extract only statically rendered JSX text. Native mapping claims must be + * visible to readers, so comments, attributes, JavaScript strings, and + * CodeBlock examples are deliberately excluded from the evidence. + */ +function collectRenderedProse(source: string): RenderedProse { + const sourceFile = ts.createSourceFile('offer-doc.tsx', source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX); + const segments: RenderedProse['segments'] = []; + let outputLength = 0; + + const append = (text: string, sourceStart: number): void => { + if (!text) return; + if (segments.length > 0) outputLength += 1; + segments.push({ outputStart: outputLength, sourceStart, text }); + outputLength += text.length; + }; + + const collectedComponents = new Set(); + let resolveComponent: (expression: ts.Expression) => ts.FunctionLikeDeclaration | null; + let collectFunction: (component: ts.FunctionLikeDeclaration) => void; + + const collectLocalComponent = (tagName: ts.JsxTagNameExpression): boolean => { + if (!ts.isIdentifier(tagName)) return false; + const component = resolveComponent(tagName); + if (!component) return false; + collectFunction(component); + return true; + }; + + const collectJsx = (node: ts.Node): void => { + if (ts.isJsxElement(node)) { + if (node.openingElement.tagName.getText(sourceFile) === 'CodeBlock') { + return; + } + if (collectLocalComponent(node.openingElement.tagName)) return; + for (const child of node.children) collectJsx(child); + return; + } + if (ts.isJsxSelfClosingElement(node)) { + if (node.tagName.getText(sourceFile) === 'CodeBlock') return; + collectLocalComponent(node.tagName); + return; + } + if (ts.isJsxFragment(node)) { + for (const child of node.children) collectJsx(child); + return; + } + if (ts.isJsxText(node)) { + append(node.text, node.getStart(sourceFile)); + return; + } + if (ts.isJsxExpression(node) && node.expression) { + let expression = node.expression; + while (ts.isParenthesizedExpression(expression)) { + expression = expression.expression; + } + if (ts.isStringLiteralLike(expression)) { + append(expression.text, expression.getStart(sourceFile)); + } else if (ts.isJsxElement(expression) || ts.isJsxSelfClosingElement(expression) || ts.isJsxFragment(expression)) { + collectJsx(expression); + } + } + }; + + const unwrapRenderedExpression = (expression: ts.Expression): ts.Expression => { + let current = expression; + while ( + ts.isParenthesizedExpression(current) || + ts.isAsExpression(current) || + ts.isTypeAssertionExpression(current) || + ts.isSatisfiesExpression(current) + ) { + current = current.expression; + } + return current; + }; + + const collectExpression = (expression: ts.Expression): void => { + const rendered = unwrapRenderedExpression(expression); + if (ts.isJsxElement(rendered) || ts.isJsxSelfClosingElement(rendered) || ts.isJsxFragment(rendered)) { + collectJsx(rendered); + } else if (ts.isConditionalExpression(rendered)) { + collectExpression(rendered.whenTrue); + collectExpression(rendered.whenFalse); + } + }; + + collectFunction = (component: ts.FunctionLikeDeclaration): void => { + if (collectedComponents.has(component)) return; + collectedComponents.add(component); + if (!component.body) return; + if (!ts.isBlock(component.body)) { + collectExpression(component.body); + return; + } + + const visitReturn = (node: ts.Node): void => { + if (node !== component && ts.isFunctionLike(node)) return; + if (ts.isReturnStatement(node) && node.expression) { + collectExpression(node.expression); + return; + } + ts.forEachChild(node, visitReturn); + }; + visitReturn(component.body); + }; + + resolveComponent = (expression: ts.Expression): ts.FunctionLikeDeclaration | null => { + const unwrapped = unwrapRenderedExpression(expression); + if (ts.isArrowFunction(unwrapped) || ts.isFunctionExpression(unwrapped)) { + return unwrapped; + } + if (ts.isCallExpression(unwrapped) && unwrapped.arguments.length > 0) { + return resolveComponent(unwrapped.arguments[0]); + } + if (!ts.isIdentifier(unwrapped)) return null; + + for (const statement of sourceFile.statements) { + if (ts.isFunctionDeclaration(statement) && statement.name?.text === unwrapped.text) { + return statement; + } + if (!ts.isVariableStatement(statement)) continue; + for (const declaration of statement.declarationList.declarations) { + if (ts.isIdentifier(declaration.name) && declaration.name.text === unwrapped.text && declaration.initializer) { + return resolveComponent(declaration.initializer); + } + } + } + return null; + }; + + let component: ts.FunctionLikeDeclaration | null = null; + for (const statement of sourceFile.statements) { + if ( + ts.isFunctionDeclaration(statement) && + statement.modifiers?.some((modifier) => modifier.kind === ts.SyntaxKind.DefaultKeyword) && + statement.modifiers.some((modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword) + ) { + component = statement; + break; + } + if (ts.isExportAssignment(statement) && !statement.isExportEquals) { + component = resolveComponent(statement.expression); + break; + } + } + if (component) collectFunction(component); + + return { + text: segments.map((segment) => segment.text).join(' '), + segments, + }; +} + +function renderedSourceOffset(rendered: RenderedProse, outputOffset: number): number { + const segment = [...rendered.segments].reverse().find((candidate) => candidate.outputStart <= outputOffset); + if (!segment) return 0; + return segment.sourceStart + Math.min(outputOffset - segment.outputStart, segment.text.length); +} + /** * Guard the semantics of the two canonical offer pages independently from the * generated-field audit. `DiscountOffer` is the Android one-time product @@ -473,90 +649,80 @@ function findNamedDiscountOfferTypeMembers( * * Sources are injected to keep this check deterministic and fault-testable. */ -export function auditCanonicalOfferDocs( - sources: CanonicalOfferDocsSources -): Drift[] { +export function auditCanonicalOfferDocs(sources: CanonicalOfferDocsSources): Drift[] { const drifts: Drift[] = []; const discount = sources.discountOffer; const subscription = sources.subscriptionOffer; + const discountProse = collectRenderedProse(discount.source); + const subscriptionProse = collectRenderedProse(subscription.source); - if (!/\bOneTimePurchaseOfferDetails\b/.test(discount.source)) { + if (!/\bOneTimePurchaseOfferDetails\b/.test(discountProse.text)) { drifts.push({ file: discount.file, line: 1, rule: 'R12', - message: - 'DiscountOffer must reference the Android `ProductDetails.OneTimePurchaseOfferDetails` native source.', + message: 'DiscountOffer must reference the Android `ProductDetails.OneTimePurchaseOfferDetails` native source.', }); } const oneTimeAndroidClaim = - /\bone-time\b[\s\S]{0,240}\b(?:Android|Google Play)\b/i.test( - discount.source - ) || - /\b(?:Android|Google Play)\b[\s\S]{0,240}\bone-time\b/i.test( - discount.source - ); + /\bone-time\b[\s\S]{0,240}\b(?:Android|Google Play)\b/i.test(discountProse.text) || + /\b(?:Android|Google Play)\b[\s\S]{0,240}\bone-time\b/i.test(discountProse.text); if (!oneTimeAndroidClaim) { drifts.push({ file: discount.file, line: 1, rule: 'R12', - message: - 'DiscountOffer must state that it represents one-time product offers on Android/Google Play.', + message: 'DiscountOffer must state that it represents one-time product offers on Android/Google Play.', + }); + } + + const generatedTypeScriptOfferType = parseTypeScriptDiscountOfferType(sources.generatedOfferTypes.typescript.source); + const expectedOfferTypeMembers = generatedTypeScriptOfferType?.members; + if (!expectedOfferTypeMembers) { + drifts.push({ + file: sources.generatedOfferTypes.typescript.file, + line: generatedTypeScriptOfferType + ? lineNumberAt(sources.generatedOfferTypes.typescript.source, generatedTypeScriptOfferType.index) + : 1, + rule: 'R12', + message: 'The generated TypeScript SSOT must declare DiscountOfferType as a string-literal union.', }); } const typeScriptOfferType = findTypeScriptDiscountOfferType(discount.source); - const expectedOfferTypeMembers = ['introductory', 'promotional', 'one-time']; const actualOfferTypeMembers = typeScriptOfferType?.members; const hasExactOfferTypeMembers = + expectedOfferTypeMembers !== null && + expectedOfferTypeMembers !== undefined && actualOfferTypeMembers !== null && actualOfferTypeMembers !== undefined && actualOfferTypeMembers.length === expectedOfferTypeMembers.length && - expectedOfferTypeMembers.every((member) => - actualOfferTypeMembers.includes(member) - ); - if (!hasExactOfferTypeMembers) { + expectedOfferTypeMembers.every((member) => actualOfferTypeMembers.includes(member)); + if (expectedOfferTypeMembers && !hasExactOfferTypeMembers) { drifts.push({ file: discount.file, line: typeScriptOfferType?.line ?? 1, rule: 'R12', - message: - "The canonical DiscountOffer TypeScript snippet must declare DiscountOfferType with exactly the generated wire values 'introductory', 'promotional', and 'one-time'.", + message: `The canonical DiscountOffer TypeScript snippet must declare DiscountOfferType with exactly the generated wire values ${formatQuotedList(expectedOfferTypeMembers ?? [])}.`, }); } - for (const [language, expectedMembers] of [ - [ - 'swift', - [ - 'introductory=introductory', - 'promotional=promotional', - 'oneTime=one-time', - ], - ], - [ - 'kotlin', - [ - 'Introductory=introductory', - 'Promotional=promotional', - 'OneTime=one-time', - ], - ], - [ - 'dart', - [ - 'Introductory=introductory', - 'Promotional=promotional', - 'OneTime=one-time', - ], - ], - ] as const) { - const declaration = findNamedDiscountOfferTypeMembers( - discount.source, - language - ); + for (const language of ['swift', 'kotlin', 'dart'] as const) { + const generatedSource = sources.generatedOfferTypes[language]; + const generatedDeclaration = parseNamedDiscountOfferTypeMembers(generatedSource.source, language); + const expectedMembers = generatedDeclaration?.members; + if (!expectedMembers) { + drifts.push({ + file: generatedSource.file, + line: generatedDeclaration ? lineNumberAt(generatedSource.source, generatedDeclaration.index) : 1, + rule: 'R12', + message: `The generated ${language} SSOT must declare DiscountOfferType members with explicit wire values.`, + }); + continue; + } + + const declaration = findNamedDiscountOfferTypeMembers(discount.source, language); const actualMembers = declaration?.members; const hasExactMembers = actualMembers !== null && @@ -584,46 +750,42 @@ export function auditCanonicalOfferDocs( }, { pattern: /\b(?:ProductDetails\.)?SubscriptionOfferDetails\b/, - message: - 'DiscountOffer must not map to Play SubscriptionOfferDetails; it represents one-time product offers.', - }, - { - pattern: /\bWinBack\b/i, - message: - 'DiscountOffer must not claim WinBack support; WinBack is not a DiscountOfferType enum member.', + message: 'DiscountOffer must not map to Play SubscriptionOfferDetails; it represents one-time product offers.', }, ]; + if (expectedOfferTypeMembers && !expectedOfferTypeMembers.includes('win-back')) { + forbiddenDiscountClaims.push({ + pattern: /\bWinBack\b/i, + message: 'DiscountOffer must not claim WinBack support; WinBack is not a generated DiscountOfferType member.', + }); + } for (const claim of forbiddenDiscountClaims) { - const match = claim.pattern.exec(discount.source); + const match = claim.pattern.exec(discountProse.text); if (!match) continue; drifts.push({ file: discount.file, - line: lineNumberAt(discount.source, match.index), + line: lineNumberAt(discount.source, renderedSourceOffset(discountProse, match.index)), rule: 'R12', message: claim.message, }); } - const subscriptionWinBack = /\bWinBack\b/i.exec(subscription.source); - if (subscriptionWinBack) { + const subscriptionWinBack = /\bWinBack\b/i.exec(subscriptionProse.text); + if (subscriptionWinBack && expectedOfferTypeMembers && !expectedOfferTypeMembers.includes('win-back')) { drifts.push({ file: subscription.file, - line: lineNumberAt(subscription.source, subscriptionWinBack.index), + line: lineNumberAt(subscription.source, renderedSourceOffset(subscriptionProse, subscriptionWinBack.index)), rule: 'R12', - message: - 'SubscriptionOffer must not claim WinBack support; WinBack is not a DiscountOfferType enum member.', + message: 'SubscriptionOffer must not claim WinBack support; WinBack is not a generated DiscountOfferType member.', }); } for (const [pattern, nativeType] of [ [/\bProduct\.SubscriptionOffer\b/, 'Product.SubscriptionOffer'], - [ - /\b(?:ProductDetails\.)?SubscriptionOfferDetails\b/, - 'ProductDetails.SubscriptionOfferDetails', - ], + [/\b(?:ProductDetails\.)?SubscriptionOfferDetails\b/, 'ProductDetails.SubscriptionOfferDetails'], ] as const) { - if (pattern.test(subscription.source)) continue; + if (pattern.test(subscriptionProse.text)) continue; drifts.push({ file: subscription.file, line: 1, @@ -693,191 +855,35 @@ async function walkTsxFiles(root: string): Promise { } /** - * Build an index of every interface / type alias / enum-like union - * defined in libraries/expo-iap/src/types.ts. - * - * The index maps: - * typeName: string → { fields: Set, literals: Set } - * - * Fields = property names declared inside `interface X { ... }` or - * `type X = { ... }`. Optional `?` is stripped. - * Literals = string-literal members of a union type - * (`type X = 'a' | 'b' | 'c'`). + * Collect every field declared by exported interfaces and object-shaped type + * aliases in the generated TypeScript SSOT. Type names and literal unions are + * intentionally not retained because the field audit only asks whether a + * documented parameter exists on any generated shape. */ -function buildTypeIndex(): Map< - string, - { fields: Set; literals: Set } -> { +function buildKnownFields(): Set { const src = readFileSync(TYPES_FILE, 'utf8'); - const index = new Map< - string, - { fields: Set; literals: Set } - >(); - - // interface NAME { ... } — capture body via brace matching - const interfaceRe = - /export\s+interface\s+([A-Za-z_]\w*)\s*(?:extends[^{]+)?\{/g; - let m: RegExpExecArray | null; - while ((m = interfaceRe.exec(src)) !== null) { - const name = m[1]; - const body = extractBraceBlock(src, m.index + m[0].length - 1); - if (!body) continue; - const fields = extractInterfaceFields(body); - index.set(name, { - fields, - literals: new Set(), - }); - } - - // type NAME = '...' | '...' | ... — string-literal unions - const literalUnionRe = - /export\s+type\s+([A-Za-z_]\w*)\s*=\s*((?:'[^']*'\s*\|\s*)*'[^']*')\s*;/g; - while ((m = literalUnionRe.exec(src)) !== null) { - const name = m[1]; - const literals = new Set(); - for (const lit of m[2].matchAll(/'([^']*)'/g)) literals.add(lit[1]); - index.set(name, { - fields: new Set(), - literals, - }); - } - - // type NAME = { ... } — object-shape aliases - const objectAliasRe = /export\s+type\s+([A-Za-z_]\w*)\s*=\s*\{/g; - while ((m = objectAliasRe.exec(src)) !== null) { - const name = m[1]; - if (index.has(name)) continue; - const body = extractBraceBlock(src, m.index + m[0].length - 1); - if (!body) continue; - index.set(name, { - fields: extractInterfaceFields(body), - literals: new Set(), - }); - } - - return index; -} - -export function extractBraceBlock( - src: string, - openBraceIdx: number -): string | null { - if (src[openBraceIdx] !== '{') return null; - let depth = 1; - let i = openBraceIdx + 1; - let quote: "'" | '"' | null = null; - let escaped = false; - let inLineComment = false; - let inBlockComment = false; - let inTemplate = false; - const templateStack: { interpolationDepth: number | null }[] = []; - - while (i < src.length && depth > 0) { - const ch = src[i]; - const next = src[i + 1]; + const fields = new Set(); + const sourceFile = ts.createSourceFile(TYPES_FILE, src, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS); - if (inLineComment) { - if (ch === '\n') inLineComment = false; - i += 1; - continue; - } - if (inBlockComment) { - if (ch === '*' && next === '/') { - inBlockComment = false; - i += 2; - } else { - i += 1; - } - continue; - } - if (quote !== null) { - if (escaped) { - escaped = false; - } else if (ch === '\\') { - escaped = true; - } else if (ch === quote) { - quote = null; - } - i += 1; - continue; - } - if (inTemplate) { - if (escaped) { - escaped = false; - i += 1; - continue; - } - if (ch === '\\') { - escaped = true; - i += 1; - continue; - } - if (ch === '`') { - templateStack.pop(); - inTemplate = false; - i += 1; - continue; - } - if (ch === '$' && next === '{') { - depth += 1; - templateStack[templateStack.length - 1].interpolationDepth = depth; - inTemplate = false; - i += 2; - continue; - } - i += 1; - continue; - } - if (ch === '/' && next === '/') { - inLineComment = true; - i += 2; - continue; - } - if (ch === '/' && next === '*') { - inBlockComment = true; - i += 2; - continue; - } - if (ch === "'" || ch === '"') { - quote = ch; - i += 1; - continue; - } - if (ch === '`') { - templateStack.push({ interpolationDepth: null }); - inTemplate = true; - i += 1; - continue; - } - if (ch === '{') depth += 1; - else if (ch === '}') { - const template = templateStack[templateStack.length - 1]; - const closesInterpolation = - template?.interpolationDepth !== null && - template?.interpolationDepth === depth; - depth -= 1; - if (closesInterpolation) { - template.interpolationDepth = null; - inTemplate = true; + for (const statement of sourceFile.statements) { + const isExported = statement.modifiers?.some((modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword); + if (!isExported) continue; + + const members = ts.isInterfaceDeclaration(statement) + ? statement.members + : ts.isTypeAliasDeclaration(statement) && ts.isTypeLiteralNode(statement.type) + ? statement.type.members + : undefined; + if (!members) continue; + + for (const member of members) { + if (!ts.isPropertySignature(member) || !member.name) continue; + if (ts.isIdentifier(member.name) || ts.isStringLiteral(member.name) || ts.isNumericLiteral(member.name)) { + fields.add(member.name.text); } } - i += 1; } - if (depth !== 0) return null; - return src.slice(openBraceIdx + 1, i - 1); -} -function extractInterfaceFields(body: string): Set { - const fields = new Set(); - // Match `name?:` or `name:` at the start of a line (after whitespace - // or `;`). Skip lines starting with `//` (comments). - for (const line of body.split('\n')) { - const trimmed = line.trim(); - if (!trimmed || trimmed.startsWith('//') || trimmed.startsWith('*')) - continue; - const fieldMatch = trimmed.match(/^([a-z_$][\w$]*)\s*\??\s*:/i); - if (fieldMatch) fields.add(fieldMatch[1]); - } return fields; } @@ -896,7 +902,6 @@ function parseDocPage(filePath: string) { const lines = src.split('\n'); const linkTargets: { line: number; href: string }[] = []; - const seeUrls: { line: number; url: string }[] = []; const fieldMentions: { line: number; field: string }[] = []; // Track whether we're inside an `
        ` block. @@ -913,13 +918,6 @@ function parseDocPage(filePath: string) { for (const m of line.matchAll(/')) { @@ -936,11 +934,7 @@ function parseDocPage(filePath: string) { // notation. Take the LEAF identifier — that's the field on // some intermediate type. const leaf = token.split(/[.[\s]/).pop() ?? token; - if ( - /^[a-z_$][\w$]*$/.test(leaf) && - leaf.length > 1 && - !RESERVED_WORDS.has(leaf) - ) { + if (/^[a-z_$][\w$]*$/.test(leaf) && leaf.length > 1 && !RESERVED_WORDS.has(leaf)) { fieldMentions.push({ line: lineNo, field: leaf }); } liExpectingField = false; @@ -949,13 +943,12 @@ function parseDocPage(filePath: string) { } } - return { linkTargets, seeUrls, fieldMentions }; + return { linkTargets, fieldMentions }; } const PACKAGE_RELEASE_ITEM_RE = /\b(?:openiap-(?:gql|apple|google)|react-native-iap|expo-iap|flutter_inapp_purchase|godot-iap|kmp-iap|maui-iap)\s+v?\d+\.\d+\.\d+(?:[-\w.]+)?\b/; -const GITHUB_RELEASE_LINK_RE = - /href=["']https:\/\/github\.com\/hyodotdev\/openiap\/releases\/tag\/[^"']+["']/; +const GITHUB_RELEASE_LINK_RE = /href=["']https:\/\/github\.com\/hyodotdev\/openiap\/releases\/tag\/[^"']+["']/; function lineNumberAt(src: string, index: number): number { let line = 1; @@ -965,6 +958,14 @@ function lineNumberAt(src: string, index: number): number { return line; } +function formatQuotedList(values: string[]): string { + const quoted = values.map((value) => `'${value}'`); + if (quoted.length === 0) return 'no generated values'; + if (quoted.length === 1) return quoted[0]; + if (quoted.length === 2) return `${quoted[0]} and ${quoted[1]}`; + return `${quoted.slice(0, -1).join(', ')}, and ${quoted.at(-1)}`; +} + /** * Released `Package Releases` blocks should link every package/version item to * the GitHub Release. If a workflow is still publishing, keep the heading as @@ -974,8 +975,7 @@ function lineNumberAt(src: string, index: number): number { function auditReleaseNotePackageLinks(filePath: string): Drift[] { const src = readFileSync(filePath, 'utf8'); const drifts: Drift[] = []; - const headingRe = - /]*>\s*(Planned Package Releases|Package Releases)\s*<\/h5>/g; + const headingRe = /]*>\s*(Planned Package Releases|Package Releases)\s*<\/h5>/g; let headingMatch: RegExpExecArray | null; while ((headingMatch = headingRe.exec(src)) !== null) { @@ -1008,19 +1008,10 @@ function auditReleaseNotePackageLinks(filePath: string): Drift[] { return drifts; } -function readJsonRecord( - filePath: string, - drifts: Drift[], - rule: string, - label: string -): Record | null { +function readJsonRecord(filePath: string, drifts: Drift[], rule: string, label: string): Record | null { try { const parsed = JSON.parse(readFileSync(filePath, 'utf8')) as unknown; - if ( - parsed === null || - typeof parsed !== 'object' || - Array.isArray(parsed) - ) { + if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) { drifts.push({ file: filePath, line: 1, @@ -1041,12 +1032,7 @@ function readJsonRecord( } } -function requireRegexValue( - filePath: string, - pattern: RegExp, - drifts: Drift[], - label: string -): string | null { +function requireRegexValue(filePath: string, pattern: RegExp, drifts: Drift[], label: string): string | null { if (!statSyncSafe(filePath)) { drifts.push({ file: filePath, @@ -1070,12 +1056,7 @@ function requireRegexValue( return value; } -function requireJsonString( - filePath: string, - key: string, - drifts: Drift[], - label: string -): string | null { +function requireJsonString(filePath: string, key: string, drifts: Drift[], label: string): string | null { const data = readJsonRecord(filePath, drifts, 'R10', label); const value = data?.[key]; if (typeof value !== 'string' || value.trim() === '') { @@ -1098,18 +1079,8 @@ function metadataKeyLine(source: string, key: string): number { function auditVersionMetadata(): Drift[] { const drifts: Drift[] = []; - const rootVersions = readJsonRecord( - ROOT_VERSIONS_FILE, - drifts, - 'R10', - 'openiap-versions.json' - ); - const docsVersions = readJsonRecord( - DOC_VERSIONS_FILE, - drifts, - 'R10', - 'packages/docs/openiap-versions.json' - ); + const rootVersions = readJsonRecord(ROOT_VERSIONS_FILE, drifts, 'R10', 'openiap-versions.json'); + const docsVersions = readJsonRecord(DOC_VERSIONS_FILE, drifts, 'R10', 'packages/docs/openiap-versions.json'); if (rootVersions && docsVersions) { const rootJson = JSON.stringify(rootVersions); const docsJson = JSON.stringify(docsVersions); @@ -1118,18 +1089,12 @@ function auditVersionMetadata(): Drift[] { file: DOC_VERSIONS_FILE, line: 1, rule: 'R10', - message: - 'Docs openiap-versions.json must be a real synced copy of the root openiap-versions.json for Vercel.', + message: 'Docs openiap-versions.json must be a real synced copy of the root openiap-versions.json for Vercel.', }); } } - const metadata = readJsonRecord( - DOC_VERSION_METADATA_FILE, - drifts, - 'R10', - 'packages/docs/src/generated/version-metadata.json' - ); + const metadata = readJsonRecord(DOC_VERSION_METADATA_FILE, drifts, 'R10', 'packages/docs/src/generated/version-metadata.json'); if (metadata) { const metadataSource = readFileSync(DOC_VERSION_METADATA_FILE, 'utf8'); const expected: Record = { @@ -1138,85 +1103,79 @@ function auditVersionMetadata(): Drift[] { resolve(REPO_ROOT, 'libraries/expo-iap/package.json'), 'version', drifts, - 'expo-iap package.json' + 'expo-iap package.json', ), reactNativePackageVersion: requireJsonString( resolve(REPO_ROOT, 'libraries/react-native-iap/package.json'), 'version', drifts, - 'react-native-iap package.json' + 'react-native-iap package.json', ), flutterPackageVersion: requireRegexValue( resolve(REPO_ROOT, 'libraries/flutter_inapp_purchase/pubspec.yaml'), /^version:\s*(.+)$/m, drifts, - 'flutter_inapp_purchase pubspec.yaml version' + 'flutter_inapp_purchase pubspec.yaml version', ), godotPackageVersion: requireRegexValue( resolve(REPO_ROOT, 'libraries/godot-iap/addons/godot-iap/plugin.cfg'), /^version="([^"]+)"$/m, drifts, - 'godot-iap plugin.cfg version' + 'godot-iap plugin.cfg version', ), kmpPackageVersion: requireRegexValue( resolve(REPO_ROOT, 'libraries/kmp-iap/gradle.properties'), /^libraryVersion=(.+)$/m, drifts, - 'kmp-iap libraryVersion' + 'kmp-iap libraryVersion', ), mauiPackageId: requireRegexValue( - resolve( - REPO_ROOT, - 'libraries/maui-iap/src/OpenIap.Maui/OpenIap.Maui.csproj' - ), + resolve(REPO_ROOT, 'libraries/maui-iap/src/OpenIap.Maui/OpenIap.Maui.csproj'), /([^<]+)<\/PackageId>/, drifts, - 'MAUI PackageId' + 'MAUI PackageId', ), mauiPackageVersion: requireRegexValue( - resolve( - REPO_ROOT, - 'libraries/maui-iap/src/OpenIap.Maui/OpenIap.Maui.csproj' - ), + resolve(REPO_ROOT, 'libraries/maui-iap/src/OpenIap.Maui/OpenIap.Maui.csproj'), /([^<]+)<\/PackageVersion>/, drifts, - 'MAUI PackageVersion' + 'MAUI PackageVersion', ), googleCompileSdk: requireRegexValue( resolve(REPO_ROOT, 'packages/google/openiap/build.gradle.kts'), /compileSdk\s*=\s*(\d+)/, drifts, - 'openiap-google compileSdk' + 'openiap-google compileSdk', ), googleMinSdk: requireRegexValue( resolve(REPO_ROOT, 'packages/google/openiap/build.gradle.kts'), /minSdk\s*=\s*(\d+)/, drifts, - 'openiap-google minSdk' + 'openiap-google minSdk', ), googlePlayBillingVersion: requireRegexValue( resolve(REPO_ROOT, 'packages/google/openiap/build.gradle.kts'), /val\s+playBillingVersion\s*=\s*"([^"]+)"/, drifts, - 'openiap-google Play Billing version' + 'openiap-google Play Billing version', ), kmpCompileSdk: requireRegexValue( resolve(REPO_ROOT, 'libraries/kmp-iap/gradle/libs.versions.toml'), /^android-compileSdk = "([^"]+)"/m, drifts, - 'kmp-iap android-compileSdk' + 'kmp-iap android-compileSdk', ), kmpMinSdk: requireRegexValue( resolve(REPO_ROOT, 'libraries/kmp-iap/gradle/libs.versions.toml'), /^android-minSdk = "([^"]+)"/m, drifts, - 'kmp-iap android-minSdk' + 'kmp-iap android-minSdk', ), kmpTargetSdk: requireRegexValue( resolve(REPO_ROOT, 'libraries/kmp-iap/gradle/libs.versions.toml'), /^android-targetSdk = "([^"]+)"/m, drifts, - 'kmp-iap android-targetSdk' + 'kmp-iap android-targetSdk', ), }; @@ -1249,8 +1208,7 @@ function auditVersionMetadata(): Drift[] { file: VERSIONING_FILE, line: 1, rule: 'R10', - message: - 'versioning.ts must read framework package versions from generated version-metadata.json.', + message: 'versioning.ts must read framework package versions from generated version-metadata.json.', }); } for (const forbidden of ['../../../../libraries/', '../../../../packages/']) { @@ -1260,8 +1218,7 @@ function auditVersionMetadata(): Drift[] { file: VERSIONING_FILE, line: lineNumberAt(source, index), rule: 'R10', - message: - 'versioning.ts must not raw-import files outside packages/docs; Vercel deploys the docs package root.', + message: 'versioning.ts must not raw-import files outside packages/docs; Vercel deploys the docs package root.', }); } } @@ -1341,10 +1298,7 @@ function linkResolves(target: string): boolean { // strip leading /docs and trailing slash const slug = pathPart.replace(/^\/docs\/?/, '').replace(/\/$/, ''); if (!slug) return statSyncSafe(join(DOC_PAGES_DIR, 'docs/index.tsx')); - const candidates = [ - join(DOC_PAGES_DIR, 'docs', `${slug}.tsx`), - join(DOC_PAGES_DIR, 'docs', slug, 'index.tsx'), - ]; + const candidates = [join(DOC_PAGES_DIR, 'docs', `${slug}.tsx`), join(DOC_PAGES_DIR, 'docs', slug, 'index.tsx')]; return candidates.some(statSyncSafe); } @@ -1358,7 +1312,6 @@ function statSyncSafe(p: string): boolean { } async function main() { - const typeIndex = buildTypeIndex(); const drifts: Drift[] = []; // Build the set of every known field name across every type — used as @@ -1366,10 +1319,7 @@ async function main() { // know which type a `foo` mention belongs to without // page-level context, so we accept any field that exists somewhere in // types.ts.) - const allFields = new Set(); - for (const v of typeIndex.values()) { - for (const f of v.fields) allFields.add(f); - } + const allFields = buildKnownFields(); // Common framework + JS / Dart / Kotlin words that appear in code // examples without being IAP fields. Excluded from the fallback. @@ -1507,9 +1457,7 @@ async function main() { const activeDocPages = await walkTsxFiles(ACTIVE_DOCS_ROOT); for (const file of activeDocPages) { if (resolve(file) === RELEASE_NOTES_FILE) continue; - drifts.push( - ...auditActiveCodeExampleSource(file, readFileSync(file, 'utf8')) - ); + drifts.push(...auditActiveCodeExampleSource(file, readFileSync(file, 'utf8'))); } drifts.push(...auditReleaseNotePackageLinks(RELEASE_NOTES_FILE)); @@ -1528,7 +1476,25 @@ async function main() { file: SEARCH_DATA_FILE, source: readFileSync(SEARCH_DATA_FILE, 'utf8'), }, - }) + generatedOfferTypes: { + typescript: { + file: GENERATED_OFFER_TYPE_FILES.typescript, + source: readFileSync(GENERATED_OFFER_TYPE_FILES.typescript, 'utf8'), + }, + swift: { + file: GENERATED_OFFER_TYPE_FILES.swift, + source: readFileSync(GENERATED_OFFER_TYPE_FILES.swift, 'utf8'), + }, + kotlin: { + file: GENERATED_OFFER_TYPE_FILES.kotlin, + source: readFileSync(GENERATED_OFFER_TYPE_FILES.kotlin, 'utf8'), + }, + dart: { + file: GENERATED_OFFER_TYPE_FILES.dart, + source: readFileSync(GENERATED_OFFER_TYPE_FILES.dart, 'utf8'), + }, + }, + }), ); // R5 (broken /docs links) is a hard failure; R3 (field name not in diff --git a/scripts/audit-non-godot-parity.mjs b/scripts/audit-non-godot-parity.mjs index ca571b9a3..aa2400f33 100644 --- a/scripts/audit-non-godot-parity.mjs +++ b/scripts/audit-non-godot-parity.mjs @@ -1,9 +1,23 @@ #!/usr/bin/env node +import { execFileSync } from "node:child_process"; import fs from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; +import { GENERATED_SYNC_MANIFEST } from "../packages/gql/generated-sync-manifest.mjs"; +import { collectGeneratedSyncDrift } from "../packages/gql/scripts/verify-generated-sync.mjs"; const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +execFileSync( + process.execPath, + [ + "--test", + path.resolve( + root, + "packages/gql/scripts/standalone-generated-refreshers.test.mjs", + ), + ], + { stdio: "inherit" }, +); const failures = []; const EXPO_EXAMPLE_ROOT = "libraries/expo-iap/example"; @@ -279,7 +293,7 @@ function discoverExpoRoutes() { } function parseGeneratedOperations(kind) { - const sourcePath = "packages/gql/src/generated/types.ts"; + const sourcePath = GENERATED_SYNC_MANIFEST.typescript.source; expectFile(sourcePath); if (!exists(sourcePath)) return []; const match = read(sourcePath).match( @@ -362,7 +376,7 @@ function expectFlutterHandlers(kind, block) { function parseGodotOperationFields() { const fields = []; - const lines = read("libraries/godot-iap/addons/godot-iap/types.gd").split( + const lines = read(GENERATED_SYNC_MANIFEST.gdscript.targets.godot.path).split( "\n", ); let section = null; @@ -710,64 +724,16 @@ function checkE2eExampleIds() { } function checkGeneratedTypeSync() { - expectSameFile( - "packages/gql/src/generated/types.ts", - "libraries/expo-iap/src/types.ts", - "Expo generated TypeScript types", - ); - expectSameFile( - "packages/gql/src/generated/types.ts", - "libraries/react-native-iap/src/types.ts", - "React Native generated TypeScript types", - ); - expectSameFile( - "packages/gql/src/webhook-client.ts", - "libraries/expo-iap/src/webhook-client.ts", - "Expo webhook client helper", - ); - expectSameFile( - "packages/gql/src/webhook-client.ts", - "libraries/react-native-iap/src/webhook-client.ts", - "React Native webhook client helper", - ); - expectSameFile( - "packages/gql/src/kit-api.ts", - "libraries/expo-iap/src/kit-api.ts", - "Expo IAPKit API helper", - ); - expectSameFile( - "packages/gql/src/kit-api.ts", - "libraries/react-native-iap/src/kit-api.ts", - "React Native IAPKit API helper", - ); - expectSameFile( - "packages/gql/src/generated/types.dart", - "libraries/flutter_inapp_purchase/lib/types.dart", - "Flutter generated Dart types", - ); - expectSameFile( - "packages/gql/src/generated/Types.swift", - "packages/apple/Sources/Models/Types.swift", - "Apple generated Swift types", - ); - expectSameFile( - "packages/gql/src/generated/Types.cs", - "libraries/maui-iap/src/OpenIap.Maui/Types.cs", - "MAUI generated C# types", - ); - expectSameFile( - "packages/gql/src/generated/Types.kt", - "libraries/kmp-iap/library/src/commonMain/kotlin/io/github/hyochan/kmpiap/openiap/Types.kt", - "KMP generated Kotlin types", - kmpGeneratedKotlin, - ); + for (const drift of collectGeneratedSyncDrift(root)) { + fail(`generated sync manifest drift: ${drift}`); + } for (const kind of Object.keys(operationParityRegistry)) { expectSameSet( `Google generated ${kind} operations`, parseGeneratedOperations(kind), parseKotlinResolverOperations( - "packages/google/openiap/src/main/java/dev/hyo/openiap/Types.kt", + GENERATED_SYNC_MANIFEST.kotlin.targets.google.path, kind, ), ); @@ -777,13 +743,13 @@ function checkGeneratedTypeSync() { function checkGqlRuntimeExports() { const packageJson = JSON.parse(read("packages/gql/package.json")); const exports = packageJson.exports ?? {}; - for (const [exportPath, filePath] of [ - ["./kit-api", "./src/kit-api.ts"], - ["./webhook-client", "./src/webhook-client.ts"], - ]) { - if (exports[exportPath] !== filePath) { + for (const [groupName, definition] of Object.entries( + GENERATED_SYNC_MANIFEST, + )) { + const filePath = definition.source.replace(/^packages\/gql\//, "./"); + if (exports[definition.exportKey] !== filePath) { fail( - `@hyodotdev/openiap-gql export ${exportPath} should point to ${filePath}`, + `@hyodotdev/openiap-gql ${groupName} export ${definition.exportKey} should point to ${filePath}`, ); } } @@ -1242,10 +1208,7 @@ function checkFlutter() { ); expectNotIncludes( nativePlugin, - [ - "details: purchaseError.productId", - '"productId": error.productId', - ], + ["details: purchaseError.productId", '"productId": error.productId'], "Flutter Apple PurchaseError diagnostics must remain structured", ); } @@ -1806,7 +1769,7 @@ function checkNativeApis() { "Expo Android API exports", ); expectIncludes( - "libraries/flutter_inapp_purchase/lib/types.dart", + GENERATED_SYNC_MANIFEST.dart.targets.flutter.path, [ "Future getStorefront()", "Future checkAlternativeBillingAvailabilityAndroid()", @@ -1821,7 +1784,7 @@ function checkNativeApis() { "Flutter generated API", ); expectIncludes( - "libraries/kmp-iap/library/src/commonMain/kotlin/io/github/hyochan/kmpiap/openiap/Types.kt", + GENERATED_SYNC_MANIFEST.kotlin.targets.kmp.path, [ "suspend fun getStorefront(): String", "suspend fun checkAlternativeBillingAvailabilityAndroid(): Boolean", @@ -2442,7 +2405,7 @@ function checkBillingChoiceFieldBindings() { ['"subResponseCodeAndroid" to diagnostics["subResponseCodeAndroid"]'], ], [ - "libraries/godot-iap/addons/godot-iap/types.gd", + GENERATED_SYNC_MANIFEST.gdscript.targets.godot.path, [ "var sub_response_code_android: Variant = null", 'dict["subResponseCodeAndroid"]', @@ -4339,12 +4302,23 @@ function checkFrameworkDependencyHygiene() { "KMP docs card must not point at the legacy standalone docs", ); expectIncludes( - "scripts/agent/compile-context.ts", + "scripts/agent/context-files.ts", [ - "function readInstallationVersions()", + "libraries/flutter_inapp_purchase/pubspec.yaml", "libraries/kmp-iap/gradle.properties", "libraries/godot-iap/addons/godot-iap/plugin.cfg", "libraries/maui-iap/src/OpenIap.Maui/OpenIap.Maui.csproj", + ], + "AI context compiler framework package version source manifest", + ); + expectIncludes( + "scripts/agent/compile-context.ts", + [ + "function readInstallationVersions()", + "CONTEXT_SOURCES.flutterPackage", + "CONTEXT_SOURCES.kmpPackage", + "CONTEXT_SOURCES.godotPackage", + "CONTEXT_SOURCES.mauiPackage", "([^<]+)<\\/PackageId>", "flutter pub add flutter_inapp_purchase", "io.github.hyochan:kmp-iap:${versions.kmp}", @@ -4440,35 +4414,6 @@ function checkFrameworkDependencyHygiene() { ], "KMP README version script must not inject release-specific versions", ); - for (const generatedTypeScript of [ - "libraries/kmp-iap/scripts/generate-types.sh", - "libraries/godot-iap/scripts/generate-types.sh", - ]) { - expectIncludes( - generatedTypeScript, - [ - "json.loads", - 'data.get("spec")', - "Error: 'spec' version missing in openiap-versions.json", - ], - `${generatedTypeScript} must parse openiap-versions.json as JSON`, - ); - expectNotIncludes( - generatedTypeScript, - ["grep '\"spec\"'", "sed 's/.*: *"], - `${generatedTypeScript} must not parse JSON with grep/sed`, - ); - } - expectIncludes( - "libraries/godot-iap/scripts/generate-types.sh", - [ - 'ADDON_DIR="$REPO_ROOT/addons/godot-iap"', - 'EXAMPLE_ADDON_DIR="$REPO_ROOT/Example/addons/godot-iap"', - 'cp "$TEMP_DIR/types.gd" "$ADDON_DIR/types.gd"', - 'cp "$TEMP_DIR/types.gd" "$EXAMPLE_ADDON_DIR/types.gd"', - ], - "Godot generated types script must update the shipped addon and example", - ); expectIncludes( "libraries/kmp-iap/publish-local.sh", ["grep '^libraryVersion=' gradle.properties"], diff --git a/scripts/sync-versions.sh b/scripts/sync-versions.sh index ea6d59903..c310ff126 100755 --- a/scripts/sync-versions.sh +++ b/scripts/sync-versions.sh @@ -338,35 +338,10 @@ echo "" echo "📦 Syncing Godot Android dependency versions..." ./libraries/godot-iap/scripts/sync-versions.sh -# Sync generated types from packages/gql to libraries +# Delegate generated source distribution to the GQL package. It owns every +# source/target mapping and required per-platform post-processing. echo "" -echo "📦 Syncing generated types..." -GQL_GENERATED="packages/gql/src/generated" - -# TypeScript types → react-native-iap, expo-iap -if [ -f "$GQL_GENERATED/types.ts" ]; then - cp "$GQL_GENERATED/types.ts" "libraries/react-native-iap/src/types.ts" - echo " ✓ libraries/react-native-iap/src/types.ts" - cp "$GQL_GENERATED/types.ts" "libraries/expo-iap/src/types.ts" - echo " ✓ libraries/expo-iap/src/types.ts" -fi - -# Dart types → flutter_inapp_purchase -if [ -f "$GQL_GENERATED/types.dart" ]; then - cp "$GQL_GENERATED/types.dart" "libraries/flutter_inapp_purchase/lib/types.dart" - echo " ✓ libraries/flutter_inapp_purchase/lib/types.dart" -fi - -# GDScript types → godot-iap -if [ -f "$GQL_GENERATED/types.gd" ]; then - cp "$GQL_GENERATED/types.gd" "libraries/godot-iap/addons/godot-iap/types.gd" - echo " ✓ libraries/godot-iap/addons/godot-iap/types.gd" -fi - -# C# types → maui-iap -if [ -f "$GQL_GENERATED/Types.cs" ]; then - cp "$GQL_GENERATED/Types.cs" "libraries/maui-iap/src/OpenIap.Maui/Types.cs" - echo " ✓ libraries/maui-iap/src/OpenIap.Maui/Types.cs" -fi - -echo "✅ Version files and types synced successfully" +echo "📦 Syncing generated sources through packages/gql..." +node packages/gql/scripts/sync-to-platforms.mjs + +echo "✅ Version files and generated sources synced successfully" From 3e42cc1eea71248829294b44e27eb98d7c6dee6c Mon Sep 17 00:00:00 2001 From: Hyo Date: Fri, 24 Jul 2026 10:58:44 +0900 Subject: [PATCH 8/9] docs: add spec 2.5.1 release notes --- .../docs/src/pages/docs/updates/releases.tsx | 134 ++++++++++++++++++ 1 file changed, 134 insertions(+) diff --git a/packages/docs/src/pages/docs/updates/releases.tsx b/packages/docs/src/pages/docs/updates/releases.tsx index db950b72b..c02a0c617 100644 --- a/packages/docs/src/pages/docs/updates/releases.tsx +++ b/packages/docs/src/pages/docs/updates/releases.tsx @@ -22,6 +22,10 @@ interface Note { element: React.ReactNode; } +const generatedContractReleases = [ + ['OpenIAP Spec 2.5.1', 'docs-2.5.1'], +] as const; + const androidOfferCodeReleases = [ ['OpenIAP Spec 2.5.0', 'docs-2.5.0'], ['openiap-apple 2.4.2', '2.4.2'], @@ -86,6 +90,136 @@ function Releases() { useScrollToHash(); const allNotes: Note[] = [ + // July 24, 2026 - OpenIAP Spec 2.5.1 offer docs and generated-contract consistency + { + id: 'spec-2-5-1-offer-docs-codegen-ssot-2026-07-24', + date: new Date('2026-07-24'), + element: ( +
        + + July 24, 2026 - OpenIAP Spec 2.5.1 offer documentation and + generated-contract consistency + + +

        + Publishes a backward-compatible documentation and generated-contract + patch from{' '} + + issue #249 + {' '} + and{' '} + + PR #250 + + . Native and framework runtime APIs, wire values, and installable + package versions are unchanged. +

        + +
        Offer documentation
        +
          +
        • + DiscountOffer is documented as the standardized shape + for Android one-time product purchase options and offers. It is + not an iOS discount type and is not the subscription-offer model. +
        • +
        • + Subscription products consistently point to{' '} + SubscriptionOffer, while Android one-time products + read ProductAndroid.discountOffers. Legacy + platform-specific fields retain precise deprecation guidance. +
        • +
        • + Examples, type references, search data, and LLM exports now use + the same canonical field names and offer-page routes. +
        • +
        + +
        + Generated-contract consistency +
        +
          +
        • + GraphQL schema inventory, custom input contracts, deprecation + reasons, supported languages, generated sources, and synchronized + platform targets now derive from canonical manifests instead of + parallel file lists. +
        • +
        • + Generation and CI fail closed on tracked or untracked drift, and + standalone refreshers consume the versioned{' '} + docs-2.5.1 source snapshot. +
        • +
        • + Generated TypeScript, Swift, Kotlin, Dart, GDScript, and C# docs + carry the same offer and deprecation metadata without changing the + public wire contract. +
        • +
        + +
        +
        Package Releases
        +
          + {generatedContractReleases.map(([label, tag]) => ( +
        • + + {label} + +
        • + ))} +
        +
        +
        + ), + }, + // July 23, 2026 - IAPKit webhook deduplication and ASC review automation { id: 'iapkit-webhook-dedup-asc-review-automation-2026-07-23', From 5fe806aa469924a836b67bf58343445736c7845f Mon Sep 17 00:00:00 2001 From: Hyo Date: Fri, 24 Jul 2026 11:04:01 +0900 Subject: [PATCH 9/9] docs: clarify release SSOT scope --- packages/docs/src/pages/docs/updates/releases.tsx | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/docs/src/pages/docs/updates/releases.tsx b/packages/docs/src/pages/docs/updates/releases.tsx index c02a0c617..fb51b12f0 100644 --- a/packages/docs/src/pages/docs/updates/releases.tsx +++ b/packages/docs/src/pages/docs/updates/releases.tsx @@ -172,14 +172,14 @@ function Releases() { }} >
      • - GraphQL schema inventory, custom input contracts, deprecation - reasons, supported languages, generated sources, and synchronized - platform targets now derive from canonical manifests instead of - parallel file lists. + GraphQL schema inventory and deprecation reasons now come from + canonical SDL directives, custom input shapes from shared + contracts, supported languages from the plugin registry, and + generated sources and synchronized targets from the sync manifest.
      • Generation and CI fail closed on tracked or untracked drift, and - standalone refreshers consume the versioned{' '} + version-pinned framework refreshers consume the versioned{' '} docs-2.5.1 source snapshot.