diff --git a/.claude/commands/review-pr.md b/.claude/commands/review-pr.md index 2cffa5d7c..f61f2aa85 100644 --- a/.claude/commands/review-pr.md +++ b/.claude/commands/review-pr.md @@ -27,7 +27,7 @@ Based on changed files, run these checks BEFORE committing: When reviewing, check these project-specific rules: - **iOS functions**: Must end with `IOS` suffix (e.g., `syncIOS`) - **Android functions in packages/google**: NO `Android` suffix (it's Android-only) -- **Generated files**: Do NOT edit `packages/apple/Sources/Models/Types.swift` or `packages/google/openiap/src/main/Types.kt` +- **Generated files**: Do NOT edit `packages/apple/Sources/Models/Types.swift` or `packages/google/openiap/src/main/java/dev/hyo/openiap/Types.kt` See [CLAUDE.md](../../CLAUDE.md) and [knowledge/internal/](../../knowledge/internal/) for full conventions. diff --git a/.claude/commands/verify-all.md b/.claude/commands/verify-all.md index 44e267acb..ba4729164 100644 --- a/.claude/commands/verify-all.md +++ b/.claude/commands/verify-all.md @@ -27,7 +27,6 @@ set -euo pipefail # Regenerate the schema SSOT, run codegen tests, and sync every wrapper first. (cd packages/gql && bun run generate && bun run test) -bash scripts/sync-versions.sh # Docs formatting, typecheck, and production bundle (cd packages/docs && bun run format:check && bun run build) @@ -193,34 +192,18 @@ git diff --check ### 2. Type Consistency -Verify `DuplicatePurchase` (and any new ErrorCode) exists in ALL generated types: +Verify the manifest-owned generated graph and cross-SDK contracts: ```bash set -euo pipefail -missing=0 -for f in packages/gql/src/generated/types.ts \ - libraries/react-native-iap/src/types.ts \ - libraries/expo-iap/src/types.ts \ - libraries/flutter_inapp_purchase/lib/types.dart \ - libraries/godot-iap/addons/godot-iap/types.gd \ - packages/apple/Sources/Models/Types.swift \ - packages/google/openiap/src/main/java/dev/hyo/openiap/Types.kt \ - libraries/maui-iap/src/OpenIap.Maui/Types.cs; do - expected='DuplicatePurchase' - if [[ "$f" = *.gd ]]; then - expected='DUPLICATE_PURCHASE' - fi - if grep -q "$expected" "$f"; then - echo "$f: OK" - else - echo "$f: MISSING" >&2 - missing=1 - fi -done -exit "$missing" +(cd packages/gql && bun run test) +bun run audit:parity ``` +The GQL suite derives source/target paths from +`packages/gql/generated-sync-manifest.mjs`; do not add a hard-coded file loop. + Also verify `COMMON_ERROR_CODE_MAP` in react-native-iap and expo-iap includes all ErrorCode entries: - `libraries/react-native-iap/src/utils/errorMapping.ts` @@ -334,7 +317,6 @@ the complete platform matrix in step 1. set -euo pipefail (cd packages/gql && bun run generate && bun run test) -bash scripts/sync-versions.sh (cd packages/docs && bun run format:check && bun run build) (cd packages/apple && swift test) (cd packages/google && ./gradlew \ diff --git a/.claude/guides/04-apple-package.md b/.claude/guides/04-apple-package.md index 03c8943a6..75acbd8c3 100644 --- a/.claude/guides/04-apple-package.md +++ b/.claude/guides/04-apple-package.md @@ -23,8 +23,6 @@ packages/apple/ │ ├── ProductManager.swift # Thread-safe product caching │ └── IapStatus.swift # UI status for SwiftUI ├── Tests/ -├── scripts/ -│ └── generate-types.sh # Type generation script └── openiap-versions.json # Version management ``` @@ -60,11 +58,8 @@ Types.swift is auto-generated from GraphQL schema. **Never edit Types.swift directly!** ```bash -# Generate types -./scripts/generate-types.sh - -# Or with specific version -OPENIAP_GQL_VERSION=1.0.10 ./scripts/generate-types.sh +# From the monorepo root: generate all languages and sync manifest targets +cd packages/gql && bun run generate ``` ## Version Management diff --git a/.claude/guides/06-gql-package.md b/.claude/guides/06-gql-package.md index fb5e8adff..834191161 100644 --- a/.claude/guides/06-gql-package.md +++ b/.claude/guides/06-gql-package.md @@ -1,43 +1,12 @@ # GQL Package Guide -Location: `packages/gql/` +The GraphQL package's canonical instructions live in: -## Overview +- `packages/gql/CONVENTION.md` — schema organization, marker/deprecation + contracts, supported generation commands, and generated-file rules. +- `knowledge/internal/04-platform-packages.md` — platform sync and SDK parity. +- `knowledge/internal/07-docs-consistency.md` — documentation and generated + API SSOT requirements. -Central GraphQL schema generating types for all platforms. - -## Directory Structure - -```text -packages/gql/ -├── src/ -│ ├── api.graphql # Main API schema -│ ├── api-ios.graphql # iOS-specific extensions -│ ├── api-android.graphql # Android-specific extensions -│ └── generated/ -│ ├── types.ts # TypeScript -│ ├── Types.swift # Swift -│ ├── Types.kt # Kotlin -│ └── types.dart # Dart -└── package.json -``` - -## Type Generation - -```bash -cd packages/gql - -bun run generate # All types -bun run generate:swift # Swift only -bun run generate:kotlin # Kotlin only -bun run sync # Copy to packages -``` - -## Deprecation in GraphQL - -```graphql -validateReceipt(options: ReceiptValidationProps!): ReceiptValidationResult! - @deprecated(reason: "Use verifyPurchase") -``` - -Generates appropriate annotations for each platform. +Do not duplicate those rules here. Read all three before changing +`packages/gql/`, then use the repository-owned `bun run generate` workflow. diff --git a/.github/pr-previews/openiap-codegen-ssot.jpg b/.github/pr-previews/openiap-codegen-ssot.jpg new file mode 100644 index 000000000..7437211d4 Binary files /dev/null and b/.github/pr-previews/openiap-codegen-ssot.jpg differ diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 386afba8e..5307ad656 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -68,8 +68,6 @@ jobs: ios: ${{ steps.filter.outputs.ios }} docs: ${{ steps.filter.outputs.docs }} web: ${{ steps.filter.outputs.web }} - agent: ${{ steps.filter.outputs.agent }} - parity: ${{ steps.filter.outputs.parity }} steps: - name: Checkout uses: actions/checkout@v7 @@ -82,6 +80,8 @@ jobs: gql: - 'packages/gql/**' - 'scripts/**' + - 'package.json' + - 'bun.lock' - 'openiap-versions.json' - '.github/workflows/ci.yml' - 'libraries/maui-iap/src/OpenIap.Maui/Types.cs' @@ -99,6 +99,10 @@ jobs: - '.github/workflows/ci.yml' docs: - 'packages/docs/**' + - 'packages/gql/src/generated/**' + - 'packages/gql/generated-sync-manifest.mjs' + - 'scripts/audit-docs.ts' + - 'scripts/audit-docs.test.ts' - '.github/workflows/ci.yml' web: - 'packages/docs/**' @@ -107,26 +111,9 @@ jobs: - 'package.json' - 'bun.lock' - '.github/workflows/ci.yml' - agent: - - 'scripts/agent/**' - - 'packages/kit/public/llms.txt' - - 'packages/docs/public/llms*.txt' - - '.github/workflows/ci.yml' - parity: - - 'libraries/**' - - 'packages/apple/**' - - 'packages/google/**' - - 'packages/gql/**' - - 'scripts/audit-non-godot-parity.mjs' - - 'package.json' - - 'knowledge/internal/04-platform-packages.md' - - '.github/workflows/ci.yml' - audit-parity: name: Audit SDK Parity runs-on: ubuntu-latest - needs: changes - if: needs.changes.outputs.parity == 'true' steps: - name: Checkout uses: actions/checkout@v7 @@ -136,9 +123,15 @@ jobs: with: node-version: 20 + - name: Test clean-worktree drift guard + run: node --test scripts/assert-clean-worktree.test.mjs + - name: Sync generated version files run: ./scripts/sync-versions.sh + - name: Verify version and generated synchronization is committed + run: node scripts/assert-clean-worktree.mjs + - name: Run non-Godot SDK parity audit run: node scripts/audit-non-godot-parity.mjs @@ -170,43 +163,16 @@ jobs: working-directory: packages/gql run: bun run generate - - name: Verify generated files - working-directory: packages/gql - run: | - test -f src/generated/types.ts || exit 1 - test -f src/generated/Types.kt || exit 1 - test -f src/generated/Types.swift || exit 1 - test -f src/generated/types.dart || exit 1 - test -f src/generated/types.gd || exit 1 - test -f src/generated/Types.cs || exit 1 - - name: Run tests working-directory: packages/gql run: bun run test - name: Verify generated types are committed (no drift) - run: | - # `bun run generate` is expected to be idempotent: running it - # against the checked-in schema must produce the same bytes - # as the files checked into the repo. If this step fails, - # somebody changed a codegen plugin or the schema without - # re-running `bun run generate` + committing the output. - # - # Use `git status --porcelain` (plus `git diff`) so newly- - # generated but untracked files also fail the check — a - # plain `git diff` would silently pass if the generator - # starts emitting a new target file. - status="$(git status --porcelain)" - if [ -n "$status" ] || ! git diff --exit-code; then - echo "" - echo "::error::Generated types differ from checked-in copies." - echo "Run 'cd packages/gql && bun run generate' locally and commit the result." - echo "" - echo "Untracked or modified paths:" - printf '%s\n' "$status" - exit 1 - fi + run: node scripts/assert-clean-worktree.mjs + # test-gql owns regeneration, platform sync, and the clean-worktree drift + # check. Consumer jobs compile the committed copies instead of invoking a + # second generator path. test-android: name: Test Android runs-on: ubuntu-latest @@ -222,25 +188,6 @@ jobs: distribution: 'temurin' java-version: '17' - - name: Setup Bun - uses: oven-sh/setup-bun@v2 - with: - bun-version: 1.3.13 - - - name: Install dependencies - run: | - # Retry bun install up to 3 times to handle transient registry errors - for i in 1 2 3; do - bun install --frozen-lockfile && break - [ $i -eq 3 ] && exit 1 - echo "Attempt $i failed. Retrying..." - sleep 5 - done - - - name: Generate types - working-directory: packages/google - run: bun run generate:types - - name: Grant execute permission working-directory: packages/google run: chmod +x gradlew @@ -275,25 +222,6 @@ jobs: with: xcode-version: ${{ env.XCODE_VERSION }} - - name: Setup Bun - uses: oven-sh/setup-bun@v2 - with: - bun-version: 1.3.13 - - - name: Install dependencies - run: | - # Retry bun install up to 3 times to handle transient registry errors - for i in 1 2 3; do - bun install --frozen-lockfile && break - [ $i -eq 3 ] && exit 1 - echo "Attempt $i failed. Retrying..." - sleep 5 - done - - - name: Generate types - working-directory: packages/apple - run: bun run generate:types - - name: Build working-directory: packages/apple run: swift build @@ -330,6 +258,11 @@ jobs: working-directory: packages/docs run: bun run typecheck + - name: Audit docs consistency + run: | + bun test scripts/audit-docs.test.ts + bun run audit:docs + - name: Lint working-directory: packages/docs run: bun run lint @@ -409,8 +342,6 @@ jobs: test-agent: name: Test Agent Scripts runs-on: ubuntu-latest - needs: changes - if: needs.changes.outputs.agent == 'true' steps: - name: Checkout uses: actions/checkout@v7 @@ -438,3 +369,10 @@ jobs: - name: Run tests working-directory: scripts/agent run: bun test + + - name: Compile generated agent context + working-directory: scripts/agent + run: bun run compile:ai + + - name: Verify compiled agent context is committed + run: node scripts/assert-clean-worktree.mjs diff --git a/.husky/pre-commit b/.husky/pre-commit index 557cf5e33..d6c126aba 100755 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -113,12 +113,35 @@ if git diff --cached --name-only --diff-filter=ACMR \ | grep -qE '^libraries/flutter_inapp_purchase/(lib|test)/'; then if command -v flutter >/dev/null 2>&1; then echo "🐦 flutter-touched commit — running flutter analyze…" - (cd libraries/flutter_inapp_purchase && flutter analyze) + ( + # Git exports repository-local variables to hooks. Flutter shells out to + # Git to determine its own SDK version, so inheriting the OpenIAP index + # can make it misidentify the app repository as the Flutter checkout. + unset $(git rev-parse --local-env-vars) + cd libraries/flutter_inapp_purchase + flutter analyze + ) else echo "⚠️ flutter not on PATH — skipping flutter analyze (CI will catch any issues)." fi fi +# Paths-aware GQL generator gate. Core parser/plugin/script edits are as +# contract-sensitive as SDL edits, so every packages/gql/** change must run +# the complete schema suite and canonical generation/sync. The final manifest- +# backed check rejects generated outputs that changed but were not staged. +if node packages/gql/scripts/assert-generation-inputs-staged.mjs has-staged-inputs; then + echo "🧬 gql-touched commit — running tests + canonical generation…" + node packages/gql/scripts/assert-generation-inputs-staged.mjs assert-staged-clean + bun install --frozen-lockfile + ( + cd packages/gql + bun run test + bun run generate + bun run verify:generated-staged + ) +fi + # Paths-aware KMP compile check. Compiles each Android store flavor because # `compileDebugKotlinAndroid` is ambiguous once play/horizon/amazon flavors exist. # With a warm gradle daemon this finishes in 5-10s; first run after @@ -141,16 +164,34 @@ if git diff --cached --name-only --diff-filter=ACMR \ fi fi -# Paths-aware docs typecheck/audit. The kit integration brought React 19 into +# Paths-aware docs typecheck/audit. Audit-script changes run the same checks so +# the guard cannot be edited without exercising its own fixtures. The kit +# integration brought React 19 into # the workspace alongside docs's React 18, which previously caused # @types/react hoisting to break docs's tsc only in CI. Both are now on # React 19, but if either drifts again we want to know on commit. -if git diff --cached --name-only --diff-filter=ACMR | grep -q '^packages/docs/'; then - echo "📘 docs-touched commit — running typecheck + audit + format…" +if git diff --cached --name-only --diff-filter=ACMR \ + | grep -qE '^(packages/docs/|packages/gql/|scripts/audit-docs(\.test)?\.ts$)'; then + echo "📘 docs/contract/audit-touched commit — running typecheck + audit + format…" bun install --frozen-lockfile bun run --filter @hyodotdev/openiap-docs typecheck + bun test scripts/audit-docs.test.ts bun run audit:docs ( cd packages/docs && bunx prettier --check "src/**/*.{ts,tsx,css}" ) fi + +# Generated agent/context files are compiled from the knowledge corpus and +# pinned package metadata. Recompile on every relevant staged change and fail +# if the resulting tracked outputs were not staged with their sources. +if bun scripts/agent/context-files.ts has-staged-inputs; then + echo "🧠 knowledge/agent-touched commit — compiling generated context…" + bun scripts/agent/context-files.ts assert-inputs-staged-clean + ( + cd scripts/agent + bun install --frozen-lockfile + bun run compile:ai + ) + bun scripts/agent/context-files.ts assert-outputs-clean +fi diff --git a/AGENTS.md b/AGENTS.md index 9eef175cb..16a69124c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -82,6 +82,7 @@ openiap/ - `libraries/expo-iap/src/types.ts` - Synced from GQL - `libraries/flutter_inapp_purchase/lib/types.dart` - Synced from GQL - `libraries/godot-iap/addons/godot-iap/types.gd` - Synced from GQL +- `libraries/kmp-iap/library/src/commonMain/kotlin/io/github/hyochan/kmpiap/openiap/Types.kt` - Synced from GQL - `libraries/maui-iap/src/OpenIap.Maui/Types.cs` - Synced from GQL - `openiap-versions.json` - Tracks only `spec`, `google`, and `apple`. Google and Apple are CI-managed; the spec may be bumped directly in a feature PR @@ -102,23 +103,19 @@ copy nearby release blocks without checking the actual package/tag. Regenerate and sync types: ```bash -cd packages/gql && bun run generate # Generate types from GraphQL schema -cd ../.. && ./scripts/sync-versions.sh # Sync to all packages and libraries +cd packages/gql && bun run generate # Generate every language and sync every manifest target ``` ### GQL Code Generation System -The type generation uses an **IR-based (Intermediate Representation)** architecture: +Type generation has two guarded lanes over the same schema inventory and +contract metadata: ```text -GraphQL Schema → Parser → IR → Language Plugins → Generated Code - ↓ - codegen/core/ codegen/plugins/ - ├── types.ts ├── swift.ts - ├── parser.ts ├── kotlin.ts - └── transformer.ts├── dart.ts - ├── gdscript.ts - └── csharp.ts +GraphQL Schema ─┬─► graphql-codegen + AST guards ─► TypeScript + └─► Parser → IR → language plugins ─► Swift/Kotlin/Dart/GDScript/C# + ↓ + generated-sync-manifest.mjs ``` **Language plugins handle:** diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 388db1322..64722ffb8 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -19,7 +19,7 @@ openiap/ │ ├── kmp-iap/ # Kotlin Multiplatform (Maven Central) │ └── maui-iap/ # .NET MAUI / C# (NuGet) ├── scripts/ -│ └── sync-versions.sh # Sync types & versions to all packages +│ └── sync-versions.sh # Sync version metadata and replay manifest copies └── .github/workflows/ # CI/CD ``` @@ -31,7 +31,8 @@ openiap/ ### Prerequisites -- [Bun](https://bun.sh/) v1.1.0+ +- [Bun](https://bun.sh/) at the exact version declared by the root + `packageManager` field (currently 1.3.13) - For Android: JDK 17+, Gradle - For iOS: Xcode, Swift 5.9+ - For Flutter: Flutter SDK @@ -45,7 +46,7 @@ git clone https://github.com/hyodotdev/openiap.git cd openiap bun install -# Sync types and versions to all packages and libraries +# Sync checked-in version metadata and compatibility copies ./scripts/sync-versions.sh ``` @@ -66,22 +67,21 @@ Each library uses its own package manager: 1. Edit `packages/gql/src/*.graphql` 2. `cd packages/gql && bun run generate` -3. `./scripts/sync-versions.sh` (syncs generated types to all libraries) -4. Update Swift switch statements in `packages/apple/Sources/Models/OpenIapError.swift` and `packages/apple/Sources/OpenIapModule.swift` -5. Update `COMMON_ERROR_CODE_MAP` in `libraries/react-native-iap/src/utils/errorMapping.ts` and `libraries/expo-iap/src/utils/errorMapping.ts` +3. Update Swift switch statements in `packages/apple/Sources/Models/OpenIapError.swift` and `packages/apple/Sources/OpenIapModule.swift` +4. Update `COMMON_ERROR_CODE_MAP` in `libraries/react-native-iap/src/utils/errorMapping.ts` and `libraries/expo-iap/src/utils/errorMapping.ts` ### Type Generation Architecture ```text -GraphQL Schema → Parser → IR (Intermediate Representation) → Language Plugins → Generated Code - ├── swift.ts - ├── kotlin.ts - ├── dart.ts - ├── gdscript.ts - └── csharp.ts +GraphQL Schema ─┬─► graphql-codegen + guarded AST post-processing ─► TypeScript + └─► Parser → IR → language plugins ─► Swift/Kotlin/Dart/GDScript/C# + ↓ + generated-sync-manifest.mjs ``` -One `bun run generate` command in `packages/gql` produces types for all platforms. Then `sync-versions.sh` copies them to the correct locations in each package and library. +One `bun run generate` command in `packages/gql` produces every language and +syncs every target declared in `generated-sync-manifest.mjs`. Do not run a +second type-copy command or maintain another target list. ### Working on a Specific Library @@ -135,7 +135,8 @@ All workflows support version bumps: `patch` / `minor` / `major` / `rc` / `promo - `openiap-versions.json` tracks only `spec`, `google`, and `apple` versions. - Framework library versions live in each library's package metadata and release workflow. -- `./scripts/sync-versions.sh` syncs generated types and native version metadata across the monorepo. +- `./scripts/sync-versions.sh` syncs native/docs version metadata and replays + the canonical manifest copies; it does not regenerate schema types. ## 5. CI/CD @@ -151,7 +152,8 @@ All workflows support version bumps: `patch` / `minor` / `major` / `rc` / `promo ## 6. Auto-generated Files (DO NOT EDIT) -These files are generated by `bun run generate` in `packages/gql` and synced by `sync-versions.sh`. Never edit them directly: +These files are generated and synchronized by `bun run generate` in +`packages/gql`. Never edit them directly: - `packages/gql/src/generated/*` -- All generated type files (SSOT) - `packages/apple/Sources/Models/Types.swift` @@ -160,14 +162,16 @@ These files are generated by `bun run generate` in `packages/gql` and synced by - `libraries/expo-iap/src/types.ts` - `libraries/flutter_inapp_purchase/lib/types.dart` - `libraries/godot-iap/addons/godot-iap/types.gd` +- `libraries/kmp-iap/library/src/commonMain/kotlin/io/github/hyochan/kmpiap/openiap/Types.kt` - `libraries/maui-iap/src/OpenIap.Maui/Types.cs` -- `openiap-versions.json` -- Managed by CI/CD workflows only +- `openiap-versions.json` -- Tracks only `spec`, `google`, and `apple`; + Google/Apple are CI-managed, while `spec` changes only on an explicit + coordinated request To regenerate: ```bash cd packages/gql && bun run generate -cd ../.. && ./scripts/sync-versions.sh ``` ## 7. Commit Conventions diff --git a/bun.lock b/bun.lock index 6b791c686..3ae7b8629 100644 --- a/bun.lock +++ b/bun.lock @@ -12,14 +12,14 @@ }, "packages/apple": { "name": "@hyodotdev/openiap-ios", - "version": "2.4.1", + "version": "2.4.2", "dependencies": { "@hyodotdev/openiap-gql": "workspace:*", }, }, "packages/docs": { "name": "@hyodotdev/openiap-docs", - "version": "2.4.0", + "version": "2.5.0", "dependencies": { "@preact/signals-react": "^3.2.1", "@types/prismjs": "^1.26.5", @@ -57,21 +57,19 @@ }, "packages/google": { "name": "@hyodotdev/openiap-android", - "version": "2.4.1", + "version": "2.5.0", "dependencies": { "@hyodotdev/openiap-gql": "workspace:*", }, }, "packages/gql": { "name": "@hyodotdev/openiap-gql", - "version": "2.4.0", + "version": "2.5.0", "devDependencies": { "@graphql-codegen/add": "^6.0.0", "@graphql-codegen/cli": "^6.0.0", "@graphql-codegen/typescript": "^5.0.0", "graphql": "^16.11.0", - "handlebars": "^4.7.8", - "ts-node": "^10.9.2", "typescript": "^5.9.2", "vitest": "^4.1.5", }, @@ -254,8 +252,6 @@ "@convex-dev/migrations": ["@convex-dev/migrations@0.3.4", "", { "peerDependencies": { "convex": "^1.24.8" } }, "sha512-fCUkc4hDzkZTgxicjV1WN4QfUdDDPPrMtvC7VgSLTGssg1B8pm+XlPC6NbZ2Aes38Xf5iiodX+y8VnU96narKQ=="], - "@cspotcode/source-map-support": ["@cspotcode/source-map-support@0.8.1", "", { "dependencies": { "@jridgewell/trace-mapping": "0.3.9" } }, "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw=="], - "@csstools/color-helpers": ["@csstools/color-helpers@6.0.2", "", {}, "sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q=="], "@csstools/css-calc": ["@csstools/css-calc@3.2.0", "", { "peerDependencies": { "@csstools/css-parser-algorithms": "^4.0.0", "@csstools/css-tokenizer": "^4.0.0" } }, "sha512-bR9e6o2BDB12jzN/gIbjHa5wLJ4UjD1CB9pM7ehlc0ddk6EBz+yYS1EV2MF55/HUxrHcB/hehAyt5vhsA3hx7w=="], @@ -270,7 +266,7 @@ "@emnapi/core": ["@emnapi/core@0.45.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-DPWjcUDQkCeEM4VnljEOEcXdAD7pp8zSZsgOujk/LGIwCXWbXJngin+MO4zbH429lzeC3WbYLGjE2MaUOwzpyw=="], - "@emnapi/runtime": ["@emnapi/runtime@1.11.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw=="], + "@emnapi/runtime": ["@emnapi/runtime@0.45.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-Txumi3td7J4A/xTTwlssKieHKTGl3j4A1tglBx72auZ49YK7ePY6XZricgIg9mnZT4xPfA+UPCUdnhRuEFDL+w=="], "@emotion/hash": ["@emotion/hash@0.8.0", "", {}, "sha512-kBJtf7PH6aWwZ6fka3zQ0p6SBYzx4fl1LoZXE2RrnYST9Xljm7WfKJrU4g/Xr3Beg72MLrp1AWNUmuYJTL7Cow=="], @@ -928,14 +924,6 @@ "@testing-library/user-event": ["@testing-library/user-event@14.6.1", "", { "peerDependencies": { "@testing-library/dom": ">=7.21.4" } }, "sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw=="], - "@tsconfig/node10": ["@tsconfig/node10@1.0.12", "", {}, "sha512-UCYBaeFvM11aU2y3YPZ//O5Rhj+xKyzy7mvcIoAjASbigy8mHMryP5cK7dgjlz2hWxh1g5pLw084E0a/wlUSFQ=="], - - "@tsconfig/node12": ["@tsconfig/node12@1.0.11", "", {}, "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag=="], - - "@tsconfig/node14": ["@tsconfig/node14@1.0.3", "", {}, "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow=="], - - "@tsconfig/node16": ["@tsconfig/node16@1.0.4", "", {}, "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA=="], - "@tybys/wasm-util": ["@tybys/wasm-util@0.8.3", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-Z96T/L6dUFFxgFJ+pQtkPpne9q7i6kIPYCFnQBHSgSPV9idTsKfIhCss0h5iM9irweZCatkrdeP8yi5uM1eX6Q=="], "@types/aria-query": ["@types/aria-query@5.0.4", "", {}, "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw=="], @@ -1080,8 +1068,6 @@ "acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="], - "acorn-walk": ["acorn-walk@8.3.5", "", { "dependencies": { "acorn": "^8.11.0" } }, "sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw=="], - "agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="], "ajv": ["ajv@6.15.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw=="], @@ -1096,8 +1082,6 @@ "antd": ["antd@6.3.7", "", { "dependencies": { "@ant-design/colors": "^8.0.1", "@ant-design/cssinjs": "^2.1.2", "@ant-design/cssinjs-utils": "^2.1.2", "@ant-design/fast-color": "^3.0.1", "@ant-design/icons": "^6.1.1", "@ant-design/react-slick": "~2.0.0", "@babel/runtime": "^7.28.4", "@rc-component/cascader": "~1.14.0", "@rc-component/checkbox": "~2.0.0", "@rc-component/collapse": "~1.2.0", "@rc-component/color-picker": "~3.1.1", "@rc-component/dialog": "~1.8.4", "@rc-component/drawer": "~1.4.2", "@rc-component/dropdown": "~1.0.2", "@rc-component/form": "~1.8.1", "@rc-component/image": "~1.9.0", "@rc-component/input": "~1.1.2", "@rc-component/input-number": "~1.6.2", "@rc-component/mentions": "~1.6.0", "@rc-component/menu": "~1.2.0", "@rc-component/motion": "^1.3.2", "@rc-component/mutate-observer": "^2.0.1", "@rc-component/notification": "~1.2.0", "@rc-component/pagination": "~1.2.0", "@rc-component/picker": "~1.9.1", "@rc-component/progress": "~1.0.2", "@rc-component/qrcode": "~1.1.1", "@rc-component/rate": "~1.0.1", "@rc-component/resize-observer": "^1.1.2", "@rc-component/segmented": "~1.3.0", "@rc-component/select": "~1.6.15", "@rc-component/slider": "~1.0.1", "@rc-component/steps": "~1.2.2", "@rc-component/switch": "~1.0.3", "@rc-component/table": "~1.9.1", "@rc-component/tabs": "~1.7.0", "@rc-component/textarea": "~1.1.2", "@rc-component/tooltip": "~1.4.0", "@rc-component/tour": "~2.3.0", "@rc-component/tree": "~1.2.4", "@rc-component/tree-select": "~1.8.0", "@rc-component/trigger": "^3.9.0", "@rc-component/upload": "~1.1.0", "@rc-component/util": "^1.10.1", "clsx": "^2.1.1", "dayjs": "^1.11.11", "scroll-into-view-if-needed": "^3.1.0", "throttle-debounce": "^5.0.2" }, "peerDependencies": { "react": ">=18.0.0", "react-dom": ">=18.0.0" } }, "sha512-WTHi4bHVNKpYXLHESzU0Tts7rRNQeL84Bph9dfI3Qw7mHbTulExDcYKNHny5CTXcrBBOpraXbU9miBAwUR5vaw=="], - "arg": ["arg@4.1.3", "", {}, "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA=="], - "argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], "aria-query": ["aria-query@5.3.2", "", {}, "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw=="], @@ -1234,8 +1218,6 @@ "cosmiconfig": ["cosmiconfig@9.0.1", "", { "dependencies": { "env-paths": "^2.2.1", "import-fresh": "^3.3.0", "js-yaml": "^4.1.0", "parse-json": "^5.2.0" }, "peerDependencies": { "typescript": ">=4.9.5" }, "optionalPeers": ["typescript"] }, "sha512-hr4ihw+DBqcvrsEDioRO31Z17x71pUYoNe/4h6Z0wB72p7MU7/9gH8Q3s12NFhHPfYBBOV3qyfUxmr/Yn3shnQ=="], - "create-require": ["create-require@1.1.1", "", {}, "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ=="], - "cross-inspect": ["cross-inspect@1.0.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-Pcw1JTvZLSJH83iiGWt6fRcT+BjZlCDRVwYLbUcHzv/CRpB7r0MlSrGbIyQvVSNyGnbt7G4AXuyCiDR3POvZ1A=="], "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], @@ -1314,8 +1296,6 @@ "devlop": ["devlop@1.1.0", "", { "dependencies": { "dequal": "^2.0.0" } }, "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA=="], - "diff": ["diff@4.0.4", "", {}, "sha512-X07nttJQkwkfKfvTPG/KSnE2OMdcUCao6+eXF3wmnIQRn2aPAHH3VxDbDOdegkd6JbPsXqShpvEOHfAT+nCNwQ=="], - "dir-glob": ["dir-glob@3.0.1", "", { "dependencies": { "path-type": "^4.0.0" } }, "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA=="], "dom-accessibility-api": ["dom-accessibility-api@0.6.3", "", {}, "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w=="], @@ -1520,8 +1500,6 @@ "graphql-ws": ["graphql-ws@6.0.8", "", { "peerDependencies": { "@fastify/websocket": "^10 || ^11", "crossws": "~0.3", "graphql": "^15.10.1 || ^16", "ws": "^8" }, "optionalPeers": ["@fastify/websocket", "crossws", "ws"] }, "sha512-m3EOaNsUBXwAnkBWbzPfe0Nq8pXUfxsWnolC54sru3FzHvhTZL0Ouf/BoQsaGAXqM+YPerXOJ47BUnmgmoupCw=="], - "handlebars": ["handlebars@4.7.9", "", { "dependencies": { "minimist": "^1.2.5", "neo-async": "^2.6.2", "source-map": "^0.6.1", "wordwrap": "^1.0.0" }, "optionalDependencies": { "uglify-js": "^3.1.4" }, "bin": { "handlebars": "bin/handlebars" } }, "sha512-4E71E0rpOaQuJR2A3xDZ+GM1HyWYv1clR58tC8emQNeQe3RH7MAzSbat+V0wG78LQBo6m6bzSG/L4pBuCsgnUQ=="], - "has-bigints": ["has-bigints@1.1.0", "", {}, "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg=="], "has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="], @@ -1812,8 +1790,6 @@ "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], - "make-error": ["make-error@1.3.6", "", {}, "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw=="], - "map-cache": ["map-cache@0.2.2", "", {}, "sha512-8y/eV9QQZCiyn1SprXSrCmqJN0yNRATe+PO8ztwqrvrbdRLA3eYJF0yaR0YayLWkMbsQSKWS9N2gPcGEc4UsZg=="], "markdown-table": ["markdown-table@3.0.4", "", {}, "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw=="], @@ -1934,8 +1910,6 @@ "minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="], - "minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="], - "mitt": ["mitt@3.0.1", "", {}, "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw=="], "mixpanel-browser": ["mixpanel-browser@2.78.0", "", { "dependencies": { "@mixpanel/rrweb": "2.0.0-alpha.18.4", "@mixpanel/rrweb-plugin-console-record": "2.0.0-alpha.18.4", "@mixpanel/rrweb-utils": "2.0.0-alpha.18.4", "@types/json-logic-js": "2.0.5", "json-logic-js": "2.0.5" } }, "sha512-K2nsMLnTK0PXcQxhj1aJyGpKyEfo2u7wgZhVm532DTjkoCbJJkuSjDBWJFCH5agEM5oE0aVoCYKd0hZ+i8LsYw=="], @@ -1954,8 +1928,6 @@ "negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="], - "neo-async": ["neo-async@2.6.2", "", {}, "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw=="], - "nice-try": ["nice-try@1.0.5", "", {}, "sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ=="], "no-case": ["no-case@3.0.4", "", { "dependencies": { "lower-case": "^2.0.2", "tslib": "^2.0.3" } }, "sha512-fgAN3jGAh+RoxUGZHTSOLJIqUc2wmoBwGR4tbpNAKmmovFoWq0OdRkb0VkldReO2a2iBT/OEulG9XSUc10r3zg=="], @@ -2258,8 +2230,6 @@ "sonner": ["sonner@2.0.7", "", { "peerDependencies": { "react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc", "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w=="], - "source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], - "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], "space-separated-tokens": ["space-separated-tokens@2.0.2", "", {}, "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q=="], @@ -2370,8 +2340,6 @@ "ts-log": ["ts-log@2.2.7", "", {}, "sha512-320x5Ggei84AxzlXp91QkIGSw5wgaLT6GeAH0KsqDmRZdVWW2OiSeVvElVoatk3f7nicwXlElXsoFkARiGE2yg=="], - "ts-node": ["ts-node@10.9.2", "", { "dependencies": { "@cspotcode/source-map-support": "^0.8.0", "@tsconfig/node10": "^1.0.7", "@tsconfig/node12": "^1.0.7", "@tsconfig/node14": "^1.0.0", "@tsconfig/node16": "^1.0.2", "acorn": "^8.4.1", "acorn-walk": "^8.1.1", "arg": "^4.1.0", "create-require": "^1.1.0", "diff": "^4.0.1", "make-error": "^1.1.1", "v8-compile-cache-lib": "^3.0.1", "yn": "3.1.1" }, "peerDependencies": { "@swc/core": ">=1.2.50", "@swc/wasm": ">=1.2.50", "@types/node": "*", "typescript": ">=2.7" }, "optionalPeers": ["@swc/core", "@swc/wasm"], "bin": { "ts-node": "dist/bin.js", "ts-script": "dist/bin-script-deprecated.js", "ts-node-cwd": "dist/bin-cwd.js", "ts-node-esm": "dist/bin-esm.js", "ts-node-script": "dist/bin-script.js", "ts-node-transpile-only": "dist/bin-transpile.js" } }, "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ=="], - "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], "type-check": ["type-check@0.4.0", "", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="], @@ -2390,8 +2358,6 @@ "typescript-eslint": ["typescript-eslint@8.59.1", "", { "dependencies": { "@typescript-eslint/eslint-plugin": "8.59.1", "@typescript-eslint/parser": "8.59.1", "@typescript-eslint/typescript-estree": "8.59.1", "@typescript-eslint/utils": "8.59.1" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-xqDcFVBmlrltH64lklOVp1wYxgJr6LVdg3NamBgH2OOQDLFdTKfIZXF5PfghrnXQKXZGTQs8tr1vL7fJvq8CTQ=="], - "uglify-js": ["uglify-js@3.19.3", "", { "bin": { "uglifyjs": "bin/uglifyjs" } }, "sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ=="], - "unbox-primitive": ["unbox-primitive@1.1.0", "", { "dependencies": { "call-bound": "^1.0.3", "has-bigints": "^1.0.2", "has-symbols": "^1.1.0", "which-boxed-primitive": "^1.1.1" } }, "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw=="], "unc-path-regex": ["unc-path-regex@0.1.2", "", {}, "sha512-eXL4nmJT7oCpkZsHZUOJo8hcX3GbsiDOa0Qu9F646fi8dT3XuSVopVqAcEiVzSKKH7UoDti23wNX3qGFxcW5Qg=="], @@ -2430,8 +2396,6 @@ "use-sync-external-store": ["use-sync-external-store@1.6.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w=="], - "v8-compile-cache-lib": ["v8-compile-cache-lib@3.0.1", "", {}, "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg=="], - "valibot": ["valibot@1.3.1", "", { "peerDependencies": { "typescript": ">=5" }, "optionalPeers": ["typescript"] }, "sha512-sfdRir/QFM0JaF22hqTroPc5xy4DimuGQVKFrzF1YfGwaS1nJot3Y8VqMdLO2Lg27fMzat2yD3pY5PbAYO39Gg=="], "validate-npm-package-license": ["validate-npm-package-license@3.0.4", "", { "dependencies": { "spdx-correct": "^3.0.0", "spdx-expression-parse": "^3.0.0" } }, "sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew=="], @@ -2472,8 +2436,6 @@ "word-wrap": ["word-wrap@1.2.5", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="], - "wordwrap": ["wordwrap@1.0.0", "", {}, "sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q=="], - "wrap-ansi": ["wrap-ansi@9.0.2", "", { "dependencies": { "ansi-styles": "^6.2.1", "string-width": "^7.0.0", "strip-ansi": "^7.1.0" } }, "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww=="], "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="], @@ -2496,8 +2458,6 @@ "yargs-parser": ["yargs-parser@21.1.1", "", {}, "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw=="], - "yn": ["yn@3.1.1", "", {}, "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q=="], - "yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="], "yoctocolors-cjs": ["yoctocolors-cjs@2.1.3", "", {}, "sha512-U/PBtDf35ff0D8X8D0jfdzHYEPFxAI7jJlxZXwCSez5M3190m+QobIfh+sWDWSHMCWWJN2AWamkegn6vr6YBTw=="], @@ -2516,8 +2476,6 @@ "@convex-dev/auth/jose": ["jose@5.10.0", "", {}, "sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg=="], - "@cspotcode/source-map-support/@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.9", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.0.3", "@jridgewell/sourcemap-codec": "^1.4.10" } }, "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ=="], - "@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="], "@eslint/eslintrc/globals": ["globals@14.0.0", "", {}, "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ=="], @@ -2546,16 +2504,14 @@ "@hyodotdev/openiap-mcp-server/@types/node": ["@types/node@24.12.2", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-A1sre26ke7HDIuY/M23nd9gfB+nrmhtYyMINbjI1zHJxYteKR6qSMX56FsmjMcDb3SMcjJg5BiRRgOCC/yBD0g=="], + "@img/sharp-wasm32/@emnapi/runtime": ["@emnapi/runtime@1.11.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw=="], + "@inquirer/core/wrap-ansi": ["wrap-ansi@6.2.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA=="], "@mixpanel/rrweb-snapshot/postcss": ["postcss@8.5.13", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-qif0+jGGZoLWdHey3UFHHWP0H7Gbmsk8T5VEqyYFbWqPr1XqvLGBbk/sl8V5exGmcYJklJOhOQq1pV9IcsiFag=="], "@modelcontextprotocol/sdk/ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="], - "@node-rs/argon2-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@0.45.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-Txumi3td7J4A/xTTwlssKieHKTGl3j4A1tglBx72auZ49YK7ePY6XZricgIg9mnZT4xPfA+UPCUdnhRuEFDL+w=="], - - "@node-rs/bcrypt-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@0.45.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-Txumi3td7J4A/xTTwlssKieHKTGl3j4A1tglBx72auZ49YK7ePY6XZricgIg9mnZT4xPfA+UPCUdnhRuEFDL+w=="], - "@opentelemetry/instrumentation-http/@opentelemetry/core": ["@opentelemetry/core@2.6.1", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-8xHSGWpJP9wBxgBpnqGL0R3PbdWQndL1Qp50qrg71+B28zK5OQmUgcDKLJgzyAAV38t4tOyLMGDD60LneR5W8g=="], "@prisma/instrumentation/@opentelemetry/instrumentation": ["@opentelemetry/instrumentation@0.207.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.207.0", "import-in-the-middle": "^2.0.0", "require-in-the-middle": "^8.0.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-y6eeli9+TLKnznrR8AZlQMSJT7wILpXH+6EYq5Vf/4Ao+huI7EedxQHwRgVUOMLFbe7VFDvHJrX9/f4lcwnJsA=="], diff --git a/knowledge/_claude-context/context.md b/knowledge/_claude-context/context.md index c09f6bd28..c45f36195 100644 --- a/knowledge/_claude-context/context.md +++ b/knowledge/_claude-context/context.md @@ -1,7 +1,7 @@ # OpenIAP Project Context > **Auto-generated for Claude Code** -> Last updated: 2026-07-20T00:25:18.244Z +> Last updated: 2026-07-24T00:17:24.969Z > > Usage: `claude --context knowledge/_claude-context/context.md` @@ -800,11 +800,8 @@ Before writing or editing anything, **ALWAYS** review: The `Types.swift` file in `Sources/Models/` is **auto-generated** from the OpenIAP GraphQL schema. ```bash -# Generate types using version from openiap-versions.json -./scripts/generate-types.sh - -# Or override with environment variable -OPENIAP_GQL_VERSION=1.0.9 ./scripts/generate-types.sh +# From the monorepo root: regenerate all languages and sync manifest targets +cd packages/gql && bun run generate ``` ### Version Management @@ -821,9 +818,12 @@ Version is managed in `openiap-versions.json`: **To update GQL types:** -1. Edit `openiap-versions.json` - change the `"spec"` version -2. Run `./scripts/generate-types.sh` -3. Run `swift test` to verify compatibility +1. Edit the canonical schema under `packages/gql/src/`. +2. Run `cd packages/gql && bun run generate`. +3. Run `cd packages/apple && swift test` to verify compatibility. + +Change the `"spec"` version only when the release train explicitly requests a +version bump; type regeneration itself does not require one. **To bump Apple package version:** @@ -1063,7 +1063,7 @@ The Google package supports **three build flavors**: 1. **DO NOT edit generated files**: `openiap/src/main/java/dev/hyo/openiap/Types.kt` is auto-generated 2. Put reusable Kotlin helpers in `openiap/src/main/java/dev/hyo/openiap/utils/` -3. Run `./scripts/generate-types.sh` to regenerate types +3. Run `cd packages/gql && bun run generate` from the monorepo root 4. **Test ALL THREE flavors** when making changes to shared code 5. **Never persist local receipt-to-SKU aliases as entitlement identity**: store-specific adapters may cache data for performance or correlate an @@ -1155,8 +1155,9 @@ maps OpenIAP product queries, purchases, restore calls, and fulfillment to ### Updating openiap-gql Version -1. Edit `openiap-versions.json` and update the `spec` field -2. Run `./scripts/generate-types.sh` to download and regenerate Types.kt +1. Update the canonical schema and change `openiap-versions.json` only when an + explicitly coordinated release requests a new `spec` version. +2. Run `cd packages/gql && bun run generate` from the monorepo root. 3. Compile ALL THREE flavors to verify: ```bash ./gradlew :openiap:compilePlayDebugKotlin @@ -1247,18 +1248,16 @@ Before writing or editing anything, **ALWAYS** review: ### Code Generation Architecture -The GQL package uses an **IR-based (Intermediate Representation) code generation system**: +The GQL package uses two guarded generation lanes over one schema inventory: ```text GraphQL Schema (src/*.graphql) - ↓ - [1] Parser (codegen/core/parser.ts) - ↓ - [2] Transformer → IR (codegen/core/transformer.ts) - ↓ - [3] Language Plugins (codegen/plugins/*.ts) - ↓ - Generated Files (src/generated/*) + ├──► graphql-codegen + guarded TypeScript AST post-processing + │ └──► src/generated/types.ts + └──► Parser → Transformer → IR → Language Plugins + └──► Swift/Kotlin/Dart/GDScript/C# + ↓ + generated-sync-manifest.mjs ``` #### Directory Structure @@ -1278,7 +1277,6 @@ packages/gql/codegen/ │ ├── dart.ts # Dart plugin (sealed class, factory constructors) │ ├── gdscript.ts # GDScript plugin (Godot engine) │ └── csharp.ts # C# plugin (.NET MAUI) -└── templates/ # Handlebars templates (optional) ``` #### IR (Intermediate Representation) @@ -1308,16 +1306,16 @@ Each plugin handles language-specific requirements: ### Scripts -| Script | Description | -| ------------------- | ------------------------------------------- | -| `generate:ts` | Generate TypeScript types (graphql-codegen) | -| `generate:swift` | Generate Swift types (IR-based plugin) | -| `generate:kotlin` | Generate Kotlin types (IR-based plugin) | -| `generate:dart` | Generate Dart types (IR-based plugin) | -| `generate:gdscript` | Generate GDScript types (IR-based plugin) | -| `generate:csharp` | Generate C# / MAUI types (IR-based plugin) | -| `generate` | Generate all types + sync to platforms | -| `sync` | Sync generated types to platform packages | +| Script | Description | +| ------------------- | --------------------------------------------- | +| `generate:ts` | Generate TypeScript types (graphql-codegen) | +| `generate:swift` | Generate Swift types (IR-based plugin) | +| `generate:kotlin` | Generate Kotlin types (IR-based plugin) | +| `generate:dart` | Generate Dart types (IR-based plugin) | +| `generate:gdscript` | Generate GDScript types (IR-based plugin) | +| `generate:csharp` | Generate C# / MAUI types (IR-based plugin) | +| `generate` | Generate every type and sync manifest targets | +| `sync` | Replay manifest-owned synchronized copies | ### Generating Types @@ -1327,7 +1325,8 @@ cd packages/gql # Generate all platform types bun run generate -# Generate specific platform +# Diagnostic single-plugin generation (always finish with `bun run generate` +# before committing so every manifest target is synchronized) bun run generate:swift bun run generate:kotlin bun run generate:dart @@ -2057,9 +2056,48 @@ GraphQL schema → generated Types → hand-written wrapper SDK → docs p /src/*.graphql) /types.{ts,kt,...}) Dart / TS / GDScript) src/pages/...) ``` +- `packages/gql/schema-files.mjs` — ordered inventory of every production SDL + input. Every repository-owned generator imports it directly. Do not add + another hard-coded schema list or an unverified external generator manifest. +- `packages/gql/schema-source-utils.mjs` — shared source identity normalization + and block-string line detection. Metadata extractors must not duplicate this + lexical bookkeeping. - `packages/gql/src/*.graphql` — schema descriptions ARE the canonical doc string. Edits propagate via `bun run generate` to every generated - `Types.{ts,kt,swift,dart,gd}`. + `types.ts`, `Types.kt`, `Types.swift`, `types.dart`, `types.gd`, and + `Types.cs`. +- `packages/gql/schema-markers.mjs` — the only parser for the SDL comment + contracts `# Future` and `# => Union`. Generators and the schema linter must + consume it rather than maintaining independent line-state machines. A union + wrapper must be a non-root object with at least one field and all fields + nullable; operation roots, empty wrappers, and required fields fail + generation instead of silently degrading to an object. +- `packages/gql/schema-deprecations.mjs` — the only extractor and validator for + canonical deprecation ownership. Standard GraphQL declarations use + `@deprecated(reason: ...)`; named types use the project-scoped + `@openiapDeprecated(reason: ...)` directive declared in `schema.graphql`. + Do not duplicate an + `@deprecated` tag inside the description or encode deprecation only as + description prose. The schema linter rejects missing, duplicate, empty, and + conflicting ownership. The generator appends the directive reason wherever + the target exposes a corresponding declaration; TypeScript receives an + explicit injection for project-scoped type-level directives. + TypeScript string-union members have no per-member declaration and therefore + cannot carry GraphQL enum-value docs; `ErrorCode` remains a real enum, so its + member deprecations are required. Custom aliases such as + `PurchaseInput = Purchase` rely on the aliased declaration's canonical docs + instead of duplicating them. GDScript does not emit GraphQL interfaces, so + implementation declarations carry the applicable field guidance. GDScript + also expresses `# => Union` result wrappers only through operation return + metadata rather than declarations, so wrapper-variant docs have no generated + declaration target there; every language that emits a wrapper or variant + declaration must preserve the canonical reason. +- `packages/gql/custom-input-contracts.ts` — typed + field/type/nullability/default contracts for inputs that custom generators + alias or project. The shared IR transformer validates these before any + language plugin runs. +- `packages/gql/generated-sync-manifest.mjs` — generated source/target mapping + shared by canonical platform sync and the pre-commit drift guard. - `libraries/*/src/types.ts` (or equivalent) — generated; never hand-edit. When a docs page mentions a field name, that field MUST exist in the generated TS type. The audit script enforces this. @@ -2105,10 +2143,10 @@ When changing a default, update: ### R3 — Doc pages reference real fields only When a Type doc page lists fields in a `` or `
` or ` @@ -201,7 +193,10 @@ function DiscountOffer() { - + @@ -340,11 +330,13 @@ function Product() { discountOffers diff --git a/packages/docs/src/pages/docs/types/request-purchase-props.tsx b/packages/docs/src/pages/docs/types/request-purchase-props.tsx index aef95dfae..cc79af668 100644 --- a/packages/docs/src/pages/docs/types/request-purchase-props.tsx +++ b/packages/docs/src/pages/docs/types/request-purchase-props.tsx @@ -436,7 +436,8 @@ await iap.request_purchase(subs_props)`} withOffer diff --git a/packages/docs/src/pages/docs/types/subscription-offer.tsx b/packages/docs/src/pages/docs/types/subscription-offer.tsx index b00f6b0f1..d6036617c 100644 --- a/packages/docs/src/pages/docs/types/subscription-offer.tsx +++ b/packages/docs/src/pages/docs/types/subscription-offer.tsx @@ -125,8 +125,7 @@ function SubscriptionOffer() { @@ -344,20 +343,13 @@ interface SubscriptionPeriod { value: number; } -enum SubscriptionPeriodUnit { - Day = 'Day', - Week = 'Week', - Month = 'Month', - Year = 'Year', - Unknown = 'Unknown', -} +type SubscriptionPeriodUnit = 'day' | 'week' | 'month' | 'year' | 'unknown'; -enum PaymentMode { - FreeTrial = 'FreeTrial', - PayAsYouGo = 'PayAsYouGo', - PayUpFront = 'PayUpFront', - Unknown = 'Unknown', -}`} +type PaymentMode = + | 'free-trial' + | 'pay-as-you-go' + | 'pay-up-front' + | 'unknown';`} ), swift: ( {`struct SubscriptionOffer: Codable { @@ -398,18 +390,18 @@ struct SubscriptionPeriod: Codable { } enum SubscriptionPeriodUnit: String, Codable { - case day = "Day" - case week = "Week" - case month = "Month" - case year = "Year" - case unknown = "Unknown" + case day = "day" + case week = "week" + case month = "month" + case year = "year" + case unknown = "unknown" } enum PaymentMode: String, Codable { - case freeTrial = "FreeTrial" - case payAsYouGo = "PayAsYouGo" - case payUpFront = "PayUpFront" - case unknown = "Unknown" + case freeTrial = "free-trial" + case payAsYouGo = "pay-as-you-go" + case payUpFront = "pay-up-front" + case unknown = "unknown" }`} ), kotlin: ( @@ -450,12 +442,19 @@ data class SubscriptionPeriod( val value: Int ) -enum class SubscriptionPeriodUnit { - Day, Week, Month, Year, Unknown +enum class SubscriptionPeriodUnit(val rawValue: String) { + Day("day"), + Week("week"), + Month("month"), + Year("year"), + Unknown("unknown") } -enum class PaymentMode { - FreeTrial, PayAsYouGo, PayUpFront, Unknown +enum class PaymentMode(val rawValue: String) { + FreeTrial("free-trial"), + PayAsYouGo("pay-as-you-go"), + PayUpFront("pay-up-front"), + Unknown("unknown") }`} ), dart: ( @@ -525,9 +524,26 @@ class SubscriptionPeriod { SubscriptionPeriod({required this.unit, required this.value}); } -enum SubscriptionPeriodUnit { day, week, month, year, unknown } +enum SubscriptionPeriodUnit { + Day('day'), + Week('week'), + Month('month'), + Year('year'), + Unknown('unknown'); + + const SubscriptionPeriodUnit(this.value); + final String value; +} -enum PaymentMode { freeTrial, payAsYouGo, payUpFront, unknown }`} +enum PaymentMode { + FreeTrial('free-trial'), + PayAsYouGo('pay-as-you-go'), + PayUpFront('pay-up-front'), + Unknown('unknown'); + + const PaymentMode(this.value); + final String value; +}`} ), csharp: ( {`using OpenIap; diff --git a/packages/docs/src/pages/docs/types/subscription-product.tsx b/packages/docs/src/pages/docs/types/subscription-product.tsx index f2835a001..4b16385c5 100644 --- a/packages/docs/src/pages/docs/types/subscription-product.tsx +++ b/packages/docs/src/pages/docs/types/subscription-product.tsx @@ -80,7 +80,7 @@ function SubscriptionProduct() { debugDescription,{' '} platform ( Deprecated.)), plus the subscription-only override - and the cross-platform offer arrays below. + and standardized subscription offer array below.

- Type of offer: Introductory,{' '} - Promotional, or OneTime (Android-only - Play Billing 8.0+ feature). + Always one-time for DiscountOffer{' '} + entries. The shared enum's introductory and{' '} + promotional variants are used by{' '} + + SubscriptionOffer + + .
String Formatted discount amount (e.g., "$5.00 OFF") + Localized discount amount including the currency symbol (e.g., + "$5.00") +
@@ -270,32 +265,29 @@ function DiscountOffer() { typescript: ( {`interface DiscountOffer { // Common fields - id: string | null; + id?: string | null; displayPrice: string; price: number; currency: string; type: DiscountOfferType; // Android-specific fields - offerTokenAndroid?: string; - offerTagsAndroid?: string[]; - fullPriceMicrosAndroid?: string; - percentageDiscountAndroid?: number; - discountAmountMicrosAndroid?: string; - formattedDiscountAmountAndroid?: string; - validTimeWindowAndroid?: ValidTimeWindowAndroid; - limitedQuantityInfoAndroid?: LimitedQuantityInfoAndroid; - preorderDetailsAndroid?: PreorderDetailsAndroid; - rentalDetailsAndroid?: RentalDetailsAndroid; - purchaseOptionIdAndroid?: string; + offerTokenAndroid?: string | null; + offerTagsAndroid?: string[] | null; + fullPriceMicrosAndroid?: string | null; + percentageDiscountAndroid?: number | null; + discountAmountMicrosAndroid?: string | null; + formattedDiscountAmountAndroid?: string | null; + validTimeWindowAndroid?: ValidTimeWindowAndroid | null; + limitedQuantityInfoAndroid?: LimitedQuantityInfoAndroid | null; + preorderDetailsAndroid?: PreorderDetailsAndroid | null; + rentalDetailsAndroid?: RentalDetailsAndroid | null; + purchaseOptionIdAndroid?: string | null; } -enum DiscountOfferType { - Introductory = 'Introductory', - Promotional = 'Promotional', - WinBack = 'WinBack', // iOS 18+ - OneTime = 'OneTime', -}`} +type DiscountOfferType = 'introductory' | 'promotional' | 'one-time'; + +// DiscountOffer instances currently use only 'one-time'.`} ), swift: ( {`struct DiscountOffer: Codable { @@ -321,16 +313,15 @@ enum DiscountOfferType { } enum DiscountOfferType: String, Codable { - case introductory = "Introductory" - case promotional = "Promotional" - case winBack = "WinBack" // iOS 18+ - case oneTime = "OneTime" + case introductory = "introductory" + case promotional = "promotional" + case oneTime = "one-time" }`} ), kotlin: ( {`data class DiscountOffer( // Common fields - val id: String?, + val id: String? = null, val displayPrice: String, val price: Double, val currency: String, @@ -350,11 +341,10 @@ enum DiscountOfferType: String, Codable { val purchaseOptionIdAndroid: String? = null ) -enum class DiscountOfferType { - Introductory, - Promotional, - WinBack, // iOS 18+ - OneTime +enum class DiscountOfferType(val rawValue: String) { + Introductory("introductory"), + Promotional("promotional"), + OneTime("one-time") }`} ), dart: ( @@ -400,10 +390,12 @@ enum class DiscountOfferType { } enum DiscountOfferType { - introductory, - promotional, - winBack, // iOS 18+ - oneTime, + Introductory('introductory'), + Promotional('promotional'), + OneTime('one-time'); + + const DiscountOfferType(this.value); + final String value; }`} ), csharp: ( @@ -445,29 +437,28 @@ public enum DiscountOfferType {`class_name DiscountOffer # Common fields -var id: String -var display_price: String -var price: float -var currency: String +var id: Variant = null +var display_price: String = "" +var price: float = 0.0 +var currency: String = "" var type: DiscountOfferType # Android-specific fields -var offer_token_android: String -var offer_tags_android: Array[String] -var full_price_micros_android: String -var percentage_discount_android: int -var discount_amount_micros_android: String -var formatted_discount_amount_android: String +var offer_token_android: Variant = null +var offer_tags_android: Array[String] = [] +var full_price_micros_android: Variant = null +var percentage_discount_android: Variant = null +var discount_amount_micros_android: Variant = null +var formatted_discount_amount_android: Variant = null var valid_time_window_android: ValidTimeWindowAndroid var limited_quantity_info_android: LimitedQuantityInfoAndroid var preorder_details_android: PreorderDetailsAndroid var rental_details_android: RentalDetailsAndroid -var purchase_option_id_android: String +var purchase_option_id_android: Variant = null enum DiscountOfferType { INTRODUCTORY, PROMOTIONAL, - WIN_BACK, # iOS 18+ ONE_TIME }`} ), diff --git a/packages/docs/src/pages/docs/types/index.tsx b/packages/docs/src/pages/docs/types/index.tsx index e762dd54e..2f1d72765 100644 --- a/packages/docs/src/pages/docs/types/index.tsx +++ b/packages/docs/src/pages/docs/types/index.tsx @@ -145,7 +145,7 @@ const COMMON_TYPES: TypeRow[] = [ { to: '/docs/types/discount-offer', name: 'DiscountOffer', - description: 'Cross-platform discount offer details.', + description: 'Standardized Android one-time product offer details.', }, { to: '/docs/types/subscription-offer', diff --git a/packages/docs/src/pages/docs/types/product.tsx b/packages/docs/src/pages/docs/types/product.tsx index 802db5394..b3efd28a1 100644 --- a/packages/docs/src/pages/docs/types/product.tsx +++ b/packages/docs/src/pages/docs/types/product.tsx @@ -283,28 +283,18 @@ function Product() {
- oneTimePurchaseOfferDetailsAndroid + + oneTimePurchaseOfferDetailsAndroid + - Array of one-time purchase offers. Each offer contains:{' '} - formattedPrice,{' '} - priceAmountMicros,{' '} - priceCurrencyCode, offerToken,{' '} - discountDisplayInfo (discount info),{' '} - fullPriceMicros (original price),{' '} - validTimeWindow,{' '} - limitedQuantityInfo,{' '} - preorderDetailsAndroid,{' '} - rentalDetailsAndroid. See{' '} - Discounts. - Requires{' '} - - Billing Library 8.0+ - + Deprecated. Legacy Android-native + one-time purchase offer details. Use{' '} + discountOffers and the standardized{' '} + + DiscountOffer + {' '} + shape instead.
- Cross-platform array of{' '} + Standardized Android one-time product purchase options + and offers as{' '} DiscountOffer {' '} - — unified discount metadata. + entries. Populated from Google Play Billing 8.0+{' '} + OneTimePurchaseOfferDetails.
- Promotional/discount offer to apply (see DiscountOffer) + Signed iOS subscription promotional offer input ( + DiscountOfferInputIOS)
- Introductory, Promotional, or{' '} - WinBack (iOS 18+) + introductory or promotional
@@ -118,21 +118,6 @@ function SubscriptionProduct() { offers on iOS and from Play Billing offer details on Android. - - - - -
- discountOffers - - - DiscountOffer[] - - - Cross-platform discount list (introductory pricing, promo - codes). Always present in the schema; iOS-only stores may return - an empty array. -
@@ -281,6 +266,14 @@ function SubscriptionProduct() { ProductSubscriptionAndroid

Additional fields available on Android subscriptions:

+

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

diff --git a/packages/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
+ + +
+ Generated-contract consistency +
+ + +
+
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? = null", - ); - expect(purchaseError).toContain( - "var subResponseCodeAndroid: SubResponseCodeAndroid? = null", - ); - expect(userChoice).toContain("private set"); - expect(purchaseError).toContain("private set"); + expect(userChoice).toContain('val externalTransactionToken: String,'); + expect(userChoice).toContain('val products: List'); + expect(userChoice).toContain('var productDetailsAndroid: List? = null'); + expect(purchaseError).toContain('var subResponseCodeAndroid: SubResponseCodeAndroid? = null'); + expect(userChoice).toContain('private set'); + expect(purchaseError).toContain('private set'); expect(iapkitResultPrimary).toContain( - "public data class RequestVerifyPurchaseWithIapkitResult( val isValid: Boolean, val state: IapkitPurchaseState, val store: IapStore ) {", + 'public data class RequestVerifyPurchaseWithIapkitResult( val isValid: Boolean, val state: IapkitPurchaseState, val store: IapStore ) {', ); - expect(iapkitResult).toContain( - "var clientPayload: IapkitProductClientPayload? = null", - ); - expect(iapkitResult).toContain("var productId: String? = null"); - expect(iapkitResultPrimary).not.toContain("clientPayload"); - expect(iapkitResultPrimary).not.toContain("productId"); + expect(iapkitResult).toContain('var clientPayload: IapkitProductClientPayload? = null'); + expect(iapkitResult).toContain('var productId: String? = null'); + expect(iapkitResultPrimary).not.toContain('clientPayload'); + expect(iapkitResultPrimary).not.toContain('productId'); expect(iapkitPropsPrimary).toContain( - "public data class RequestVerifyPurchaseWithIapkitProps( val amazon: RequestVerifyPurchaseWithIapkitAmazonProps? = null, val apiKey: String? = null, val apple: RequestVerifyPurchaseWithIapkitAppleProps? = null, val baseUrl: String? = null, val google: RequestVerifyPurchaseWithIapkitGoogleProps? = null ) {", + 'public data class RequestVerifyPurchaseWithIapkitProps( val amazon: RequestVerifyPurchaseWithIapkitAmazonProps? = null, val apiKey: String? = null, val apple: RequestVerifyPurchaseWithIapkitAppleProps? = null, val baseUrl: String? = null, val google: RequestVerifyPurchaseWithIapkitGoogleProps? = null ) {', ); - expect(iapkitProps).toContain( - "var includeClientPayload: Boolean? = null", + expect(iapkitProps).toContain('var includeClientPayload: Boolean? = null'); + expect(iapkitResult).toContain('private set'); + expect(iapkitProps).toContain('private set'); + expect(iapkitPropsPrimary).not.toContain('includeClientPayload'); + }); + + it('preserves the published TypeScript webhook union order', () => { + const typescript = generated('types.ts'); + + expect(typescript).toContain( + "export type SubscriptionState = 'active' | 'expired' | 'in-billing-retry' | 'in-grace-period' | 'paused' | 'refunded' | 'revoked' | 'unknown';", ); - expect(iapkitResult).toContain("private set"); - expect(iapkitProps).toContain("private set"); - expect(iapkitPropsPrimary).not.toContain("includeClientPayload"); + expect(typescript).toContain( + "export type WebhookCancellationReason = 'billing-error' | 'other' | 'price-increase-declined' | 'product-unavailable' | 'refunded' | 'user-canceled';", + ); + expect(typescript).toContain( + "export type WebhookEventType = 'purchase-consumption-request' | 'purchase-refunded' | 'subscription-canceled' | 'subscription-expired' | 'subscription-in-billing-retry' | 'subscription-in-grace-period' | 'subscription-paused' | 'subscription-price-change' | 'subscription-product-changed' | 'subscription-recovered' | 'subscription-renewed' | 'subscription-resumed' | 'subscription-revoked' | 'subscription-started' | 'subscription-uncanceled' | 'test-notification';", + ); + }); + + it('preserves schema prose in custom purchase and discount generators', () => { + const swift = generated('Types.swift'); + const kotlin = generated('Types.kt'); + const dart = generated('types.dart'); + const csharp = generated('Types.cs'); + + for (const fieldDescription of [ + 'Discount identifier', + 'Key identifier for validation', + 'Cryptographic nonce', + 'Signature for validation', + 'Timestamp of discount offer', + ]) { + expect(swift).toContain(`/// ${fieldDescription}`); + } + + for (const source of [swift, kotlin, csharp]) { + expect(source).toContain('Explicit purchase type hint (defaults to in-app)'); + expect(source).toContain('Per-platform purchase request props'); + expect(source).toContain('Per-platform subscription request props'); + } + expect(dart).toContain('Per-platform purchase request props'); + expect(dart).toContain('Per-platform subscription request props'); }); }); diff --git a/packages/gql/src/generated-doc-comments.test.mjs b/packages/gql/src/generated-doc-comments.test.mjs new file mode 100644 index 000000000..4e4e5b0b7 --- /dev/null +++ b/packages/gql/src/generated-doc-comments.test.mjs @@ -0,0 +1,113 @@ +import { describe, expect, it } from 'vitest'; +import { injectPropertyDeprecationJSDoc, injectTypeDeprecationJSDoc, operationArgsOwnerNames } from '../scripts/generated-doc-comments.mjs'; + +describe('generated TypeScript documentation comments', () => { + it('injects canonical type reasons into existing and missing JSDoc blocks', () => { + const source = `/** + * Legacy offer. + */ +export interface LegacyOffer {} + +export type OtherLegacy = { + id: string; +}; + +export enum LegacyMode { + Old = 'old', +}`; + + const result = injectTypeDeprecationJSDoc( + source, + new Map([ + ['LegacyOffer', 'Use SubscriptionOffer instead.'], + ['OtherLegacy', 'Use DiscountOffer instead.'], + ['LegacyMode', 'Use BillingProgram instead.'], + ]), + ); + + expect(result).toContain('* Legacy offer.\n * @deprecated Use SubscriptionOffer instead.'); + expect(result).toContain('/**\n * @deprecated Use DiscountOffer instead.\n */\nexport type OtherLegacy'); + expect(result).toContain('/**\n * @deprecated Use BillingProgram instead.\n */\nexport enum LegacyMode'); + }); + + it('fails closed for duplicate tags and missing declarations', () => { + expect(() => + injectTypeDeprecationJSDoc( + `/** @deprecated Manual reason. */ +export interface Legacy {}`, + new Map([['Legacy', 'Canonical reason.']]), + ), + ).toThrow('manual @deprecated'); + + expect(() => injectTypeDeprecationJSDoc('export interface Present {}', new Map([['Missing', 'Canonical reason.']]))).toThrow('found 0'); + }); + + it('injects canonical operation argument reasons into generated properties', () => { + const source = `export interface QueryValueArgs { + /** Legacy selector. */ + legacy?: string | null; + modern?: string | null; +} + +export interface MutationRunArgs { + oldMode?: string | null; +}`; + const result = injectPropertyDeprecationJSDoc(source, [ + { + ownerName: 'QueryValueArgs', + propertyName: 'legacy', + reason: 'Use modern instead.', + }, + { + ownerName: 'MutationRunArgs', + propertyName: 'oldMode', + reason: 'Use mode instead.', + }, + ]); + + expect(result).toContain('* Legacy selector.\n * @deprecated Use modern instead.'); + expect(result).toContain('/**\n * @deprecated Use mode instead.\n */\n oldMode?'); + expect(result).not.toContain('@deprecated Use modern instead.\n */\n modern'); + }); + + it('resolves graphql-codegen casing for IOS-suffixed operation args', () => { + const ownerNames = operationArgsOwnerNames('Query', 'currentEntitlementIOS'); + expect(ownerNames).toEqual(['QueryCurrentEntitlementIosArgs', 'QueryCurrentEntitlementIOSArgs']); + + const result = injectPropertyDeprecationJSDoc( + `export interface QueryCurrentEntitlementIosArgs { + sku: string; +}`, + [ + { + ownerNames, + propertyName: 'sku', + reason: 'Use productId instead.', + }, + ], + ); + expect(result).toContain('@deprecated Use productId instead.'); + }); + + it('fails closed for ambiguous operation argument ownership', () => { + expect(() => + injectPropertyDeprecationJSDoc('export interface PresentArgs { value?: string }', [ + { + ownerName: 'MissingArgs', + propertyName: 'value', + reason: 'Use modern instead.', + }, + ]), + ).toThrow('must have exactly one generated TypeScript interface; found 0'); + + expect(() => + injectPropertyDeprecationJSDoc('export interface PresentArgs { value?: string }', [ + { + ownerName: 'PresentArgs', + propertyName: 'missing', + reason: 'Use modern instead.', + }, + ]), + ).toThrow('must have exactly one generated TypeScript property; found 0'); + }); +}); diff --git a/packages/gql/src/generated-gdscript.test.ts b/packages/gql/src/generated-gdscript.test.ts index 3e3963be6..68835fca7 100644 --- a/packages/gql/src/generated-gdscript.test.ts +++ b/packages/gql/src/generated-gdscript.test.ts @@ -33,12 +33,8 @@ describe('generated GDScript list decoding', () => { 'static func create_billing_program_reporting_details_android_args(program: BillingProgramAndroid, developer_billing_type: Variant = null)', ); expect(generated).toContain('if developer_billing_type != null:'); - expect(generated).toContain( - 'args["program"] = BILLING_PROGRAM_ANDROID_VALUES[program]', - ); - expect(generated).toContain( - 'args["developerBillingType"] = DEVELOPER_BILLING_TYPE_ANDROID_VALUES[developer_billing_type]', - ); + expect(generated).toContain('args["program"] = BILLING_PROGRAM_ANDROID_VALUES[program]'); + expect(generated).toContain('args["developerBillingType"] = DEVELOPER_BILLING_TYPE_ANDROID_VALUES[developer_billing_type]'); }); it('builds typed scalar arrays from JSON arrays', () => { @@ -89,6 +85,7 @@ describe('generated GDScript list decoding', () => { fields: [ { name: 'statuses', + description: 'Status values from the schema.\nPreserves every documentation line.\n@see https://openiap.dev/docs/types', type: { kind: 'list', nullable: false, @@ -117,23 +114,15 @@ describe('generated GDScript list decoding', () => { interfaces: [], unions: [], isResultUnion: false, - isSingleFieldArgs: false, }, ], inputs: [], unions: [], operations: [], - metadata: { - unionWrapperNames: new Set(), - futureFieldNames: new Set(), - platformDefaults: new Map(), - singleFieldObjects: new Map(), - unionMembership: new Map(), - inputsWithRequiredFields: new Set(), - }, }; const source = new GDScriptPlugin({ outputPath: 'types.gd' }).generate(schema); + expect(source).toContain('## Status values from the schema. Preserves every documentation line. @see https://openiap.dev/docs/types'); expect(source).toContain('if data["statuses"] is Array:'); expect(source).toContain('arr.append(TEST_STATUS_FROM_STRING.get(item, TestStatus.UNKNOWN))'); expect(source).toContain('if item is String and STRICT_STATUS_FROM_STRING.has(item):'); diff --git a/packages/gql/src/generated-sync-manifest.test.mjs b/packages/gql/src/generated-sync-manifest.test.mjs new file mode 100644 index 000000000..96f2424cc --- /dev/null +++ b/packages/gql/src/generated-sync-manifest.test.mjs @@ -0,0 +1,418 @@ +import { spawnSync } from "node:child_process"; +import { + chmodSync, + existsSync, + mkdirSync, + mkdtempSync, + readdirSync, + readFileSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { describe, expect, it } from "vitest"; +import { + GENERATED_DRIFT_PATHS, + GENERATED_SYNC_EDGES, + GENERATED_SYNC_MANIFEST, + GQL_GENERATED_SOURCE_DIRECTORY, + GQL_GENERATION_INPUT_PATHS, + generatedSourceFileName, + gqlPackageRelativePath, + isGqlGenerationInputPath, +} from "../generated-sync-manifest.mjs"; + +const repositoryRoot = resolve(import.meta.dirname, "../../.."); +const readRepositoryFile = (path) => + readFileSync(resolve(repositoryRoot, path), "utf8"); + +describe("generated sync manifest", () => { + it("owns every source and target path exactly once", () => { + const sourcePaths = Object.values(GENERATED_SYNC_MANIFEST).map( + (definition) => definition.source, + ); + const targetPaths = GENERATED_SYNC_EDGES.map((edge) => edge.path); + + expect(new Set(sourcePaths).size).toBe(sourcePaths.length); + expect(new Set(targetPaths).size).toBe(targetPaths.length); + expect( + targetPaths.some((targetPath) => sourcePaths.includes(targetPath)), + ).toBe(false); + }); + + it("points only to existing canonical files and synchronized copies", () => { + for (const definition of Object.values(GENERATED_SYNC_MANIFEST)) { + expect( + existsSync(resolve(repositoryRoot, definition.source)), + definition.source, + ).toBe(true); + for (const definitionTarget of Object.values(definition.targets)) { + expect( + existsSync(resolve(repositoryRoot, definitionTarget.path)), + definitionTarget.path, + ).toBe(true); + } + } + }); + + it("owns the exact generated source directory inventory", () => { + const expectedFileNames = Object.entries(GENERATED_SYNC_MANIFEST) + .filter(([, definition]) => definition.generated) + .map(([groupName]) => generatedSourceFileName(groupName)) + .sort(); + const entries = readdirSync( + resolve(repositoryRoot, GQL_GENERATED_SOURCE_DIRECTORY), + { + withFileTypes: true, + }, + ); + + expect(entries.every((entry) => entry.isFile())).toBe(true); + expect(entries.map((entry) => entry.name).sort()).toEqual( + expectedFileNames, + ); + expect(gqlPackageRelativePath(GQL_GENERATED_SOURCE_DIRECTORY)).toBe( + "src/generated", + ); + expect(() => generatedSourceFileName("webhookClient")).toThrow( + "Expected a generated manifest group", + ); + }); + + it("covers every generated source and all synchronized targets in drift checks", () => { + const expected = new Set([ + ...Object.values(GENERATED_SYNC_MANIFEST) + .filter((definition) => definition.generated) + .map((definition) => definition.source), + ...GENERATED_SYNC_EDGES.map((edge) => edge.path), + ]); + + expect(new Set(GENERATED_DRIFT_PATHS)).toEqual(expected); + }); + + it("distinguishes canonical generator inputs from generated outputs", () => { + expect(isGqlGenerationInputPath("packages/gql/src/schema.graphql")).toBe( + true, + ); + expect( + isGqlGenerationInputPath("packages/gql/codegen/core/transformer.ts"), + ).toBe(true); + expect(isGqlGenerationInputPath("packages/gql/src/webhook-client.ts")).toBe( + true, + ); + expect(isGqlGenerationInputPath("package.json")).toBe(true); + expect(isGqlGenerationInputPath("bun.lock")).toBe(true); + expect( + isGqlGenerationInputPath("packages/gql/src/generated/types.ts"), + ).toBe(false); + expect( + isGqlGenerationInputPath("libraries/react-native-iap/src/types.ts"), + ).toBe(false); + }); + + it("keeps generated-source drift ownership on each manifest group", () => { + for (const definition of Object.values(GENERATED_SYNC_MANIFEST)) { + expect(typeof definition.generated, definition.source).toBe("boolean"); + expect( + GENERATED_DRIFT_PATHS.includes(definition.source), + definition.source, + ).toBe(definition.generated); + } + }); + + it("owns the public GQL export paths", () => { + const packageJson = JSON.parse( + readRepositoryFile("packages/gql/package.json"), + ); + const definitions = Object.values(GENERATED_SYNC_MANIFEST); + expect(new Set(definitions.map(({ exportKey }) => exportKey)).size).toBe( + definitions.length, + ); + expect(new Set(Object.keys(packageJson.exports))).toEqual( + new Set(definitions.map(({ exportKey }) => exportKey)), + ); + expect(`./${packageJson.main.replace(/^\.\//, "")}`).toBe( + packageJson.exports["."], + ); + + for (const definition of definitions) { + const packageRelativeSource = definition.source.replace( + /^packages\/gql\//, + "./", + ); + expect( + packageJson.exports[definition.exportKey], + definition.exportKey, + ).toBe(packageRelativeSource); + } + }); + + it("keeps executable sync consumers on the manifest", () => { + for (const path of [ + "packages/gql/scripts/sync-to-platforms.mjs", + "packages/gql/scripts/assert-generated-staged.mjs", + "packages/gql/scripts/verify-generated-sync.mjs", + "packages/gql/codegen.ts", + "packages/gql/codegen/index.ts", + "packages/gql/scripts/fix-generated-types.mjs", + "scripts/audit-docs.ts", + "scripts/audit-non-godot-parity.mjs", + ]) { + expect(readRepositoryFile(path), path).toContain( + "generated-sync-manifest.mjs", + ); + } + + const targetPaths = GENERATED_SYNC_EDGES.map((edge) => edge.path); + for (const path of [ + "scripts/audit-docs.ts", + "scripts/audit-non-godot-parity.mjs", + ]) { + const source = readRepositoryFile(path); + for (const targetPath of targetPaths) { + expect(source, `${path} duplicates ${targetPath}`).not.toContain( + targetPath, + ); + } + } + }); + + it("runs parity unconditionally and checks the whole worktree after synchronization", () => { + const workflow = readRepositoryFile(".github/workflows/ci.yml"); + const parityJob = workflow.slice( + workflow.indexOf(" audit-parity:"), + workflow.indexOf("\n test-gql:"), + ); + const syncIndex = parityJob.indexOf("./scripts/sync-versions.sh"); + const driftIndex = parityJob.indexOf( + "node scripts/assert-clean-worktree.mjs", + ); + const parityIndex = parityJob.indexOf( + "node scripts/audit-non-godot-parity.mjs", + ); + + expect(syncIndex).toBeGreaterThanOrEqual(0); + expect(driftIndex).toBeGreaterThan(syncIndex); + expect(parityIndex).toBeGreaterThan(driftIndex); + expect(parityJob).not.toContain("needs: changes"); + expect(parityJob).not.toContain("needs.changes.outputs.parity"); + expect(parityJob).not.toContain("assert-generated-staged.mjs"); + expect(parityJob).not.toContain( + "node packages/gql/scripts/verify-generated-sync.mjs", + ); + expect(parityJob).toContain( + "node --test scripts/assert-clean-worktree.test.mjs", + ); + expect( + workflow.match(/node scripts\/assert-clean-worktree\.mjs/g), + ).toHaveLength(3); + expect(workflow).not.toContain("git status --porcelain"); + const changesJob = workflow.slice( + workflow.indexOf(" changes:"), + workflow.indexOf("\n audit-parity:"), + ); + expect(changesJob).not.toContain("parity:"); + const parityAudit = readRepositoryFile( + "scripts/audit-non-godot-parity.mjs", + ); + expect(parityAudit).toContain("execFileSync("); + expect(parityAudit).toContain("process.execPath"); + expect(parityAudit).toContain('"--test"'); + expect(parityAudit).toContain( + "packages/gql/scripts/standalone-generated-refreshers.test.mjs", + ); + const syncCheck = parityAudit.slice( + parityAudit.indexOf("function checkGeneratedTypeSync()"), + parityAudit.indexOf("\nfunction checkGqlRuntimeExports()"), + ); + expect(syncCheck).toContain("collectGeneratedSyncDrift(root)"); + expect(syncCheck).not.toContain("expectSameFile("); + }); + + it("keeps root generator inputs on CI and the staged-snapshot helper", () => { + const workflow = readRepositoryFile(".github/workflows/ci.yml"); + const gqlFilter = workflow.slice( + workflow.indexOf(" gql:"), + workflow.indexOf(" android:"), + ); + const [sourceRoot, ...externalInputs] = GQL_GENERATION_INPUT_PATHS; + expect(gqlFilter).toContain(`- '${sourceRoot}/**'`); + for (const input of externalInputs) { + expect(gqlFilter).toContain(`- '${input}'`); + } + + const preCommit = readRepositoryFile(".husky/pre-commit"); + expect(preCommit).toContain( + "assert-generation-inputs-staged.mjs has-staged-inputs", + ); + expect(preCommit).toContain( + "assert-generation-inputs-staged.mjs assert-staged-clean", + ); + expect(preCommit).toContain("node scripts/audit-non-godot-parity.mjs"); + const flutterGate = preCommit.slice( + preCommit.indexOf("# Paths-aware Flutter analyze."), + preCommit.indexOf("# Paths-aware GQL generator gate."), + ); + expect(flutterGate).toContain("unset $(git rev-parse --local-env-vars)"); + expect( + flutterGate.indexOf("unset $(git rev-parse --local-env-vars)"), + ).toBeLessThan(flutterGate.lastIndexOf("\n flutter analyze")); + + const harnessRoot = mkdtempSync(join(tmpdir(), "openiap-flutter-hook-")); + const fakeBin = resolve(harnessRoot, "bin"); + const flutterObservedFile = resolve(harnessRoot, "flutter-env"); + const hookAfterFile = resolve(harnessRoot, "hook-env"); + try { + mkdirSync(fakeBin); + const fakeGit = resolve(fakeBin, "git"); + const fakeFlutter = resolve(fakeBin, "flutter"); + writeFileSync( + fakeGit, + `#!/bin/sh +if [ "$1" = "diff" ]; then + printf '%s\\n' 'libraries/flutter_inapp_purchase/lib/types.dart' +elif [ "$1" = "rev-parse" ] && [ "$2" = "--local-env-vars" ]; then + printf '%s\\n' GIT_INDEX_FILE GIT_DIR +else + exit 91 +fi +`, + ); + writeFileSync( + fakeFlutter, + `#!/bin/sh +[ "$1" = "analyze" ] || exit 92 +[ -z "\${GIT_INDEX_FILE+x}" ] || exit 93 +[ -z "\${GIT_DIR+x}" ] || exit 94 +printf clean > "$FLUTTER_OBSERVED_FILE" +`, + ); + chmodSync(fakeGit, 0o755); + chmodSync(fakeFlutter, 0o755); + + const harness = spawnSync( + "sh", + [ + "-c", + `${flutterGate} +printf '%s|%s' "$GIT_INDEX_FILE" "$GIT_DIR" > "$HOOK_AFTER_FILE" +`, + ], + { + cwd: repositoryRoot, + encoding: "utf8", + env: { + ...process.env, + PATH: `${fakeBin}:${process.env.PATH ?? ""}`, + FLUTTER_OBSERVED_FILE: flutterObservedFile, + HOOK_AFTER_FILE: hookAfterFile, + GIT_INDEX_FILE: "sentinel-index", + GIT_DIR: "sentinel-dir", + }, + }, + ); + expect(harness.status, harness.stderr).toBe(0); + expect(readFileSync(flutterObservedFile, "utf8")).toBe("clean"); + expect(readFileSync(hookAfterFile, "utf8")).toBe( + "sentinel-index|sentinel-dir", + ); + } finally { + rmSync(harnessRoot, { recursive: true, force: true }); + } + expect(preCommit).not.toContain( + "node packages/gql/scripts/verify-generated-sync.mjs", + ); + }); + + it("keeps package entry points on the complete canonical pipeline", () => { + const gqlPackageJson = JSON.parse( + readRepositoryFile("packages/gql/package.json"), + ); + const rootPackageJson = JSON.parse(readRepositoryFile("package.json")); + expect(rootPackageJson.scripts.generate).toBe( + "cd packages/gql && bun run generate", + ); + expect(gqlPackageJson.scripts.generate).toBe( + "bun run generate:ts && bun codegen/index.ts && bun run sync", + ); + + for (const path of [ + "packages/apple/package.json", + "packages/google/package.json", + ]) { + const packageJson = JSON.parse(readRepositoryFile(path)); + expect(packageJson.scripts["generate:types"], path).toBe( + "cd ../gql && bun run generate", + ); + } + + const googleCompatibilityScript = readRepositoryFile( + "packages/google/scripts/generate-types.sh", + ); + expect(googleCompatibilityScript).toContain("bun run generate"); + expect(googleCompatibilityScript).not.toContain("bun run generate:"); + expect(googleCompatibilityScript).not.toMatch(/\bcp\s/); + + const appleStandaloneWorkflow = readRepositoryFile( + "packages/apple/.github/workflows/test.yml", + ); + expect(appleStandaloneWorkflow).toContain( + "test -s Sources/Models/Types.swift", + ); + expect(appleStandaloneWorkflow).not.toContain( + "./scripts/generate-types.sh", + ); + expect( + existsSync( + resolve(repositoryRoot, "packages/apple/scripts/generate-types.sh"), + ), + ).toBe(false); + expect( + existsSync( + resolve( + repositoryRoot, + "packages/google/scripts/post-process-types.sh", + ), + ), + ).toBe(false); + expect( + existsSync( + resolve( + repositoryRoot, + "packages/gql/.github/workflows/generate-types.yml", + ), + ), + ).toBe(false); + expect( + existsSync( + resolve( + repositoryRoot, + "packages/gql/.github/workflows/release-types.yml", + ), + ), + ).toBe(false); + }); + + it("makes every manifest target an executable sync edge", () => { + const declaredTargets = Object.entries(GENERATED_SYNC_MANIFEST).flatMap( + ([groupName, definition]) => + Object.entries(definition.targets).map( + ([targetName, definitionTarget]) => ({ + groupName, + targetName, + source: definition.source, + ...definitionTarget, + }), + ), + ); + + expect(GENERATED_SYNC_EDGES).toEqual(declaredTargets); + expect(new Set(GENERATED_SYNC_EDGES.map((edge) => edge.mode))).toEqual( + new Set(["copy", "google-kotlin", "kmp-kotlin"]), + ); + expect( + readRepositoryFile("packages/gql/scripts/sync-to-platforms.mjs"), + ).toContain("for (const edge of GENERATED_SYNC_EDGES)"); + }); +}); diff --git a/packages/gql/src/generated-sync-verifier.test.mjs b/packages/gql/src/generated-sync-verifier.test.mjs new file mode 100644 index 000000000..04e8cb10c --- /dev/null +++ b/packages/gql/src/generated-sync-verifier.test.mjs @@ -0,0 +1,50 @@ +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, resolve } from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import { GENERATED_SYNC_EDGES } from '../generated-sync-manifest.mjs'; +import { materializeGeneratedSyncEdge } from '../scripts/generated-sync-materializer.mjs'; +import { collectGeneratedSyncDrift } from '../scripts/verify-generated-sync.mjs'; + +const temporaryRoots = []; +afterEach(() => { + for (const root of temporaryRoots.splice(0)) { + rmSync(root, { force: true, recursive: true }); + } +}); + +const write = (root, path, contents) => { + const absolutePath = resolve(root, path); + mkdirSync(dirname(absolutePath), { recursive: true }); + writeFileSync(absolutePath, contents); +}; + +describe('generated sync verifier', () => { + it('materializes and compares every manifest edge, including Godot', () => { + const root = mkdtempSync(resolve(tmpdir(), 'openiap-generated-sync-')); + temporaryRoots.push(root); + const sourceContents = new Map(); + + for (const edge of GENERATED_SYNC_EDGES) { + if (!sourceContents.has(edge.source)) { + const source = edge.mode.endsWith('kotlin') ? '// canonical\npackage openiap\n' : `${edge.groupName} canonical\n`; + sourceContents.set(edge.source, source); + write(root, edge.source, source); + } + write(root, edge.path, materializeGeneratedSyncEdge(edge, sourceContents.get(edge.source))); + } + + expect(collectGeneratedSyncDrift(root)).toEqual([]); + + const godotEdge = GENERATED_SYNC_EDGES.find((edge) => edge.groupName === 'gdscript' && edge.targetName === 'godot'); + expect(godotEdge).toBeDefined(); + write(root, godotEdge.path, `${readFileSync(resolve(root, godotEdge.path), 'utf8')}# drift\n`); + expect(collectGeneratedSyncDrift(root)).toContain(`${godotEdge.path} is not the copy materialization of ${godotEdge.source}`); + }); + + it('fails closed for an unknown manifest mode', () => { + expect(() => materializeGeneratedSyncEdge({ groupName: 'test', targetName: 'test', mode: 'mystery' }, 'source')).toThrow( + 'Unknown sync mode "mystery"', + ); + }); +}); diff --git a/packages/gql/src/generated/Types.cs b/packages/gql/src/generated/Types.cs index 7677ef615..390c0759e 100644 --- a/packages/gql/src/generated/Types.cs +++ b/packages/gql/src/generated/Types.cs @@ -1,6 +1,6 @@ // ============================================================================ // AUTO-GENERATED TYPES — DO NOT EDIT DIRECTLY -// Run `bun run generate` after updating any *.graphql schema file. +// Refresh this file with the generated-types workflow documented for your checkout. // ============================================================================ #nullable enable @@ -19,8 +19,8 @@ namespace OpenIap; ///

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

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

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

SubscriptionOffer maps to Product.SubscriptionOffer and ProductDetails.SubscriptionOfferDetails.

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

A generic cross-platform discount.

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

A generic cross-platform discount.

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

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

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

WinBack is supported.

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

Android one-time products use OneTimePurchaseOfferDetails.

+

Maps to Product.SubscriptionOffer and SubscriptionOfferDetails.

+

WinBack is supported.

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

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

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

SubscriptionOffer maps to ProductDetails.SubscriptionOfferDetails.

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

SubscriptionOffer maps to Product.SubscriptionOffer.

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

A generic subscription offer.

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

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

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

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

+${discountOffer}`, + }), + ), + ).toEqual([]); + }); + + test('accepts a parenthesized TypeScript union', () => { + const discountOffer = replaceRequired( + VALID_DISCOUNT_OFFER_TYPE_BLOCKS, + offerTypeBlockPattern('typescript'), + `{\` +type DiscountOfferType = ( + | 'introductory' + | 'promotional' + | 'one-time' +); +\`}`, + ); + + expect( + auditCanonicalOfferDocs( + validOfferDocsSources({ + discountOffer: `

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

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

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

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

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

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

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

+${discountOffer}`, + }), + ), + ).toEqual([ + expect.objectContaining({ + rule: 'R12', + message: expect.stringContaining('swift snippet'), + }), + ]); + }); + + test('flags incorrect generated-language DiscountOfferType wire values', () => { + for (const [language, brokenBlock] of [ + [ + 'swift', + `{\` +enum DiscountOfferType: String { + case introductory = "introductory" + case promotional = "promotional" + case oneTime = "OneTime" +} +\`}`, + ], + [ + 'kotlin', + `{\` +enum class DiscountOfferType(val rawValue: String) { + Introductory("introductory"), + Promotional("promotional"), + OneTime("OneTime") +} +\`}`, + ], + [ + 'dart', + `{\` +enum DiscountOfferType { + Introductory('introductory'), + Promotional('promotional'), + OneTime('OneTime'); +} +\`}`, + ], + ] as const) { + const discountOffer = replaceRequired(VALID_DISCOUNT_OFFER_TYPE_BLOCKS, offerTypeBlockPattern(language), brokenBlock); + const drifts = auditCanonicalOfferDocs( + validOfferDocsSources({ + discountOffer: `

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

+${discountOffer}`, + }), + ); + + expect(drifts).toEqual([ + expect.objectContaining({ + rule: 'R12', + message: expect.stringContaining(`${language} snippet`), + }), + ]); + } + }); + + test('flags unmatched extra generated-language enum members', () => { + for (const [language, brokenBlock] of [ + [ + 'swift', + `{\` +enum DiscountOfferType: String { + case introductory = "introductory" + case promotional = "promotional" + case oneTime = "one-time", + legacy = "legacy" +} +\`}`, + ], + [ + 'kotlin', + `{\` +enum class DiscountOfferType(val rawValue: String) { + Introductory("introductory"), + Promotional("promotional"), + OneTime("one-time"), + Legacy +} +\`}`, + ], + [ + 'dart', + `{\` +enum DiscountOfferType { + Introductory('introductory'), + Promotional('promotional'), + OneTime('one-time'), + Legacy("legacy"); +} +\`}`, + ], + ] as const) { + const discountOffer = replaceRequired(VALID_DISCOUNT_OFFER_TYPE_BLOCKS, offerTypeBlockPattern(language), brokenBlock); + const drifts = auditCanonicalOfferDocs( + validOfferDocsSources({ + discountOffer: `

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

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

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

+${discountOffer}`, + }), + ), + ).toEqual([]); + }); + + test('flags legacy native search routes and missing canonical entries', () => { + const legacySearchData = `export const apiData = [ + { + title: 'DiscountOffer', + path: '/docs/types/ios/discount-offer-ios', + }, + { + title: 'SubscriptionOffer', + path: '/docs/types/android/subscription-offer-android', + }, +];`; + const wrongRouteDrifts = auditCanonicalOfferDocs(validOfferDocsSources({ searchData: legacySearchData })); + + expect(wrongRouteDrifts).toEqual([ + expect.objectContaining({ + line: 4, + message: expect.stringContaining('/docs/types/discount-offer'), + }), + expect.objectContaining({ + line: 8, + message: expect.stringContaining('/docs/types/subscription-offer'), + }), + ]); + + const missingEntryDrifts = auditCanonicalOfferDocs(validOfferDocsSources({ searchData: 'export const apiData = [];' })); + expect(missingEntryDrifts).toEqual([ + expect.objectContaining({ + message: expect.stringContaining('canonical DiscountOffer entry'), + }), + expect.objectContaining({ + message: expect.stringContaining('canonical SubscriptionOffer entry'), + }), + ]); + }); + + test('ignores commented search entries and path-like description strings', () => { + const commentedEntries = `export const apiData = []; +// { title: 'DiscountOffer', path: '/docs/types/discount-offer' } +/* { title: 'SubscriptionOffer', path: '/docs/types/subscription-offer' } */`; + const commentedDrifts = auditCanonicalOfferDocs(validOfferDocsSources({ searchData: commentedEntries })); + expect(commentedDrifts).toEqual([ + expect.objectContaining({ + message: expect.stringContaining('canonical DiscountOffer entry'), + }), + expect.objectContaining({ + message: expect.stringContaining('canonical SubscriptionOffer entry'), + }), ]); + + const misleadingDescriptions = `export const apiData = [ + { + title: 'DiscountOffer', + description: "path: '/docs/types/discount-offer'", + path: '/wrong-discount-path', + }, + { + title: 'SubscriptionOffer', + description: "path: '/docs/types/subscription-offer'", + path: '/wrong-subscription-path', + }, +];`; + const pathDrifts = auditCanonicalOfferDocs(validOfferDocsSources({ searchData: misleadingDescriptions })); + expect(pathDrifts).toEqual([ + expect.objectContaining({ + line: 5, + message: expect.stringContaining('/wrong-discount-path'), + }), + expect.objectContaining({ + line: 10, + message: expect.stringContaining('/wrong-subscription-path'), + }), + ]); + }); + + test('finds canonical search paths across indentation and nested formatting', () => { + const reformattedSearchData = `export const apiData = [ +\t{ +\t\tmetadata: { +\t\t\tpath: '/internal/discount-offer-metadata', +\t\t}, +\t\ttitle: 'DiscountOffer', +\t\tpath: '/docs/types/discount-offer', +\t}, + { metadata: { path: '/internal/subscription-offer-metadata' }, title: 'SubscriptionOffer', path: '/docs/types/subscription-offer' }, +];`; + + expect(auditCanonicalOfferDocs(validOfferDocsSources({ searchData: reformattedSearchData }))).toEqual([]); + }); + + test('accepts parenthesized and typed apiData array initializers', () => { + const entries = `[ + { title: 'DiscountOffer', path: '/docs/types/discount-offer' }, + { title: 'SubscriptionOffer', path: '/docs/types/subscription-offer' }, +]`; + for (const searchData of [ + `export const apiData = (${entries});`, + `export const apiData = ${entries} as const;`, + `export const apiData = ${entries} satisfies readonly SearchItem[];`, + ]) { + expect(auditCanonicalOfferDocs(validOfferDocsSources({ searchData }))).toEqual([]); + } + }); + + test('accepts wrapped apiData elements and string properties', () => { + const searchData = `export const apiData = [ + ({ + title: ('DiscountOffer' as const), + path: '/docs/types/discount-offer' as const, + }), + ({ + title: 'SubscriptionOffer' as const, + path: ('/docs/types/subscription-offer'), + } as const), +];`; + + expect(auditCanonicalOfferDocs(validOfferDocsSources({ searchData }))).toEqual([]); + }); + + test('ignores nested apiData shadow declarations', () => { + const searchData = `export const apiData = [ + { title: 'DiscountOffer', path: '/docs/types/discount-offer' }, + { title: 'SubscriptionOffer', path: '/docs/types/subscription-offer' }, +]; +function shadow() { + const apiData = []; + return apiData; +}`; + + expect(auditCanonicalOfferDocs(validOfferDocsSources({ searchData }))).toEqual([]); + }); + + test('parses search entries after nested template literals', () => { + const searchData = [ + 'export const apiData = [', + ' {', + " title: 'DiscountOffer',", + ' description: `outer ${`}`}`,', + " path: '/docs/types/discount-offer',", + ' },', + ' {', + " title: 'SubscriptionOffer',", + ' description: `outer ${`}`}`,', + " path: '/docs/types/subscription-offer',", + ' },', + '];', + ].join('\n'); + + expect(auditCanonicalOfferDocs(validOfferDocsSources({ searchData }))).toEqual([]); + }); + + test('ignores braces inside search strings and comments', () => { + const edgeCases = [ + `export const apiData = [ + { + title: 'DiscountOffer', + description: 'Placeholder {value', + path: '/docs/types/discount-offer', + }, + { + title: 'SubscriptionOffer', + description: "Quoted } delimiter", + path: '/docs/types/subscription-offer', + }, +];`, + `export const apiData = [ + { + title: 'DiscountOffer', + // Ignore an unmatched { + path: '/docs/types/discount-offer', + }, + { + title: 'SubscriptionOffer', + // Ignore an unmatched } + path: '/docs/types/subscription-offer', + }, +];`, + `export const apiData = [ + { + title: 'DiscountOffer', + /* Ignore an unmatched { */ + path: '/docs/types/discount-offer', + }, + { + title: 'SubscriptionOffer', + /* Ignore an unmatched } */ + path: '/docs/types/subscription-offer', + }, +];`, + ]; + + for (const searchData of edgeCases) { + expect(auditCanonicalOfferDocs(validOfferDocsSources({ searchData }))).toEqual([]); + } }); }); diff --git a/scripts/audit-docs.ts b/scripts/audit-docs.ts index a6ca0f002..b451d1f32 100644 --- a/scripts/audit-docs.ts +++ b/scripts/audit-docs.ts @@ -5,15 +5,13 @@ * What it does * 1. Walks every `packages/docs/src/pages/docs/apis/**\/*.tsx` and * `packages/docs/src/pages/docs/types/**\/*.tsx` page. - * 2. Loads the generated TypeScript types from - * `libraries/expo-iap/src/types.ts` and indexes every `interface`, - * `type` alias, and `enum` / string-literal union shape. + * 2. Loads the generated TypeScript SSOT from + * `packages/gql/src/generated/types.ts` and indexes every exported + * `interface` and object-shaped `type` alias field. * 3. For each doc page, extracts: * - `` targets * - `fieldName` mentions inside `
` rows or * `