diff --git a/packages/docs/src/pages/docs/updates/releases.tsx b/packages/docs/src/pages/docs/updates/releases.tsx
index db950b72b..fb51b12f0 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 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
+ version-pinned framework 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',
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 2a99c11d3..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.
+ * @see https://openiap.dev/docs/types/subscription-offer
* @deprecated Use the standardized SubscriptionOffer type instead for cross-platform compatibility.
- * @see https://openiap.dev/docs/types#subscription-offer
*/
public data class DiscountIOS(
val identifier: String,
@@ -2120,12 +2315,13 @@ 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
+ * @see https://openiap.dev/docs/types/discount-offer
*/
public data class DiscountOffer(
/**
@@ -2142,7 +2338,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,
/**
@@ -2196,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,
/**
@@ -2252,8 +2450,8 @@ public data class DiscountOffer(
/**
* iOS DiscountOffer (output type).
+ * @see https://openiap.dev/docs/types/subscription-offer
* @deprecated Use the standardized SubscriptionOffer type instead for cross-platform compatibility.
- * @see https://openiap.dev/docs/types#subscription-offer
*/
public data class DiscountOfferIOS(
/**
@@ -2326,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(
/**
@@ -2352,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(
/**
@@ -2770,9 +2968,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 +2980,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 +3000,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 +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 instead for cross-platform compatibility.
- * @see https://openiap.dev/docs/types#discount-offer
+ * @see https://openiap.dev/docs/types/discount-offer
+ * @deprecated Use the standardized DiscountOffer type for Android one-time offers.
*/
public data class ProductAndroidOneTimePurchaseOfferDetail(
/**
@@ -2973,7 +3171,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 +3230,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 +3239,9 @@ 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 +3261,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,
@@ -3117,8 +3314,8 @@ public data class ProductSubscriptionAndroid(
/**
* Subscription offer details (Android).
+ * @see https://openiap.dev/docs/types/subscription-offer
* @deprecated Use the standardized SubscriptionOffer type instead for cross-platform compatibility.
- * @see https://openiap.dev/docs/types#subscription-offer
*/
public data class ProductSubscriptionAndroidOfferDetails(
val basePlanId: String,
@@ -3195,7 +3392,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,
@@ -3296,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,
@@ -3467,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,
@@ -3917,8 +4120,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(
/**
@@ -4062,8 +4264,8 @@ public data class SubscriptionOffer(
/**
* iOS subscription offer details.
+ * @see https://openiap.dev/docs/types/subscription-offer
* @deprecated Use the standardized SubscriptionOffer type instead for cross-platform compatibility.
- * @see https://openiap.dev/docs/types#subscription-offer
*/
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 adafa4ba5..392e4fcd6 100644
--- a/packages/gql/scripts/fix-generated-types.mjs
+++ b/packages/gql/scripts/fix-generated-types.mjs
@@ -2,68 +2,128 @@ import { readFileSync, writeFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, resolve } from 'node:path';
import { parse } from 'graphql';
+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) ?? [];
@@ -88,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) => {
@@ -112,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');
@@ -195,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, '');
- }
-}
+const purchaseInput = requireTypeScriptInputContract(content, 'PurchaseInput');
+content = [content.slice(0, purchaseInput.start), 'export type PurchaseInput = Purchase;\n\n', content.slice(purchaseInput.end)].join('');
-// 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'),
-);
-
-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();
@@ -343,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();
@@ -386,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/);
@@ -395,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;
@@ -408,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}>;`;
@@ -595,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`,
@@ -636,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`;
@@ -674,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('');
@@ -695,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);
@@ -708,4 +532,35 @@ if (helperBlocks.length > 0) {
content += helperBlocks.join('\n');
}
+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
new file mode 100644
index 000000000..0dca687e8
--- /dev/null
+++ b/packages/gql/scripts/generated-doc-comments.mjs
@@ -0,0 +1,155 @@
+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');
+
+/**
+ * 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 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')
+ .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 0de6990e8..3b9084230 100644
--- a/packages/gql/src/generated-compatibility.test.ts
+++ b/packages/gql/src/generated-compatibility.test.ts
@@ -1,93 +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("preserves the published MAUI 1.x string signatures", () => {
- const csharp = generated("Types.cs");
+const schemaSources = () => SCHEMA_FILE_NAMES.map((fileName) => readFileSync(new URL(`./${fileName}`, import.meta.url), 'utf8'));
- for (const method of [
- "DeepLinkToSubscriptions",
- "FinishTransaction",
- "RestorePurchases",
- ]) {
- expect(csharp).toContain(`Task ${method}Async(`);
- expect(csharp).not.toContain(`Task ${method}Async(`);
+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))),
+ );
+ 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 new user-choice details optional outside Kotlin", () => {
- expect(generated("types.ts")).toContain(
- "productDetailsAndroid?: (DeveloperProvidedBillingProductAndroid[] | null);",
+ 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(/^\s*(?:\/\*\*|\*)\s*@deprecated\b/gm) ?? []).length > 1,
);
- expect(generated("types.dart")).toContain(
- "final List? productDetailsAndroid;",
+
+ expect(duplicateBlocks).toEqual([]);
+ 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(generated("Types.swift")).toContain(
- "public var productDetailsAndroid: [DeveloperProvidedBillingProductAndroid]? = nil",
+ 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.',
);
- expect(generated("Types.cs")).toContain(
- "public IReadOnlyList? ProductDetailsAndroid { get; init; }",
+ });
+
+ 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.');
+ }
+
+ // 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("keeps additive Kotlin fields out of published data-class constructors", () => {
- const kotlin = generated("Types.kt");
+ 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 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