From e621c255d27e386c986f780f3771a33469d46bd3 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Fri, 17 Jul 2026 22:49:33 +0900 Subject: [PATCH 01/26] docs(readme): redesign subscription quick start --- README.md | 143 ++++++++++++++++++++++++++++++++++++++---------------- 1 file changed, 102 insertions(+), 41 deletions(-) diff --git a/README.md b/README.md index 9727246..ff7a5dd 100644 --- a/README.md +++ b/README.md @@ -38,17 +38,49 @@ Your app owns everything the user sees and everything it persists: ## Quick start -Define the entitlement identifiers in the app. A string-backed enum keeps -StoreKit product identifiers typed without requiring a framework protocol. +Define the Product IDs and app entitlements that gate your features. Keep the +subscription group alongside each subscription so the catalog can validate +StoreKit's group membership: ```swift import StoreTransactionKit -enum SubscriptionID: String, Hashable, Sendable { - case monthly = "com.example.subscription.monthly" - case yearly = "com.example.subscription.yearly" +enum SubscriptionGroupID: String, Hashable, Sendable { + case plans = "YOUR_SUBSCRIPTION_GROUP_ID" } +enum SubscriptionProductID: String, CaseIterable, Hashable, Sendable { + case tier1Monthly = "com.example.subscription.tier1.monthly" + case tier1Yearly = "com.example.subscription.tier1.yearly" + case tier2Monthly = "com.example.subscription.tier2.monthly" + case tier2Yearly = "com.example.subscription.tier2.yearly" + + var entitlement: SubscriptionEntitlement { + switch self { + case .tier1Monthly, .tier1Yearly: + .tier1 + + case .tier2Monthly, .tier2Yearly: + .tier2 + } + } + + var subscriptionGroupID: SubscriptionGroupID { + .plans + } +} + +enum SubscriptionEntitlement: Hashable, Sendable { + case tier1 + case tier2 +} + +let entitlementCatalog = EntitlementCatalog( + products: SubscriptionProductID.self, + entitlement: \.entitlement, + subscriptionGroupID: \.subscriptionGroupID +) + actor PurchaseLedger { func apply(_ transaction: StoreTransactionSnapshot) async throws { if let revocationDate = transaction.revocationDate { @@ -78,8 +110,9 @@ actor StoreDiagnostics { func makeStore( ledger: PurchaseLedger, diagnostics: StoreDiagnostics -) -> TransactionStore { +) -> TransactionStore { TransactionStore( + entitlementCatalog: entitlementCatalog, handleTransaction: { transaction in try await ledger.apply(transaction) }, @@ -96,11 +129,12 @@ process-lifetime composition root, retain one store with SwiftUI state, and inject that same instance into the environment: ```swift +import StoreTransactionKit import SwiftUI @main struct ExampleApp: App { - @State private var store: TransactionStore + @State private var store: TransactionStore init() { let ledger = PurchaseLedger() @@ -115,56 +149,71 @@ struct ExampleApp: App { var body: some Scene { WindowGroup { - PremiumStoreView() - .environment(store) + NavigationStack { + ContentView() + } + .environment(store) } } } ``` -The view reads the store directly and renders the three entitlement states — -resolving, failed, and resolved: +Read the store from the environment and gate premium features without making +the rest of the UI depend on entitlement availability: ```swift import StoreKit +import StoreTransactionKit import SwiftUI -struct PremiumStoreView: View { - @Environment(TransactionStore.self) private var store - @State private var refreshError: (any Error)? +struct ContentView: View { + @Environment(TransactionStore.self) private var store + @State private var isShowingPaywall = false + + private var canExportPDF: Bool { + store.activeEntitlements?.contains(.tier1) == true + } var body: some View { - VStack { - if let activeEntitlements = store.activeEntitlements { - if activeEntitlements.contains(.monthly) - || activeEntitlements.contains(.yearly) - { - Label("Premium active", systemImage: "checkmark.seal.fill") - } - } else if let error = refreshError ?? store.startupError { - Text(error.localizedDescription) - Button("Retry") { - Task { - do { - try await store.refreshEntitlements() - refreshError = nil - } catch { - refreshError = error - } - } + List { + Section { + NavigationLink("All notes") { + NotesView() } - } else { - ProgressView() } + Section { + Button("Export as PDF") { + exportPDF() + } + .disabled(!canExportPDF) + + Button("Plans and subscriptions") { + isShowingPaywall = true + } + } header: { + Text("Premium") + } + } + .sheet(isPresented: $isShowingPaywall) { SubscriptionStoreView( - groupID: "YOUR_SUBSCRIPTION_GROUP_ID" + groupID: SubscriptionGroupID.plans.rawValue ) } } } ``` +### Connect the identifiers + +Replace the group and product raw values with the identifiers configured in +[App Store Connect][subscription-setup]. Each Product ID maps to one app +entitlement. StoreKit remains the source of truth for subscription group levels +and durations. + +For local StoreKit Testing, use the same values in the active `.storekit` +configuration. See [Setting up StoreKit Testing in Xcode][storekit-testing]. + No `onInAppPurchaseCompletion` modifier is needed: successful StoreKit view purchases arrive through `Transaction.updates`, which the store already monitors. If you add a non-`nil` completion action, it replaces that default @@ -194,18 +243,28 @@ promptly instead of performing work that can wait indefinitely. Both callback contracts are documented on `TransactionStore.init`. -## How entitlement state behaves - -- `activeEntitlements` is `nil` until the first entitlement query resolves; an - empty set means the query resolved and no known identifier matched. +## How entitlement availability behaves + +- `entitlementStatus` is `.loading` before the first readiness result, + `.failed(error)` after a readiness failure, and `.ready` after success. +- `activeEntitlements` is `nil` while `entitlementStatus` is `.loading` or + `.failed`. When the status is `.ready`, an empty set means no catalog + entitlement is active. +- Gate paid features on `activeEntitlements` without blocking the surrounding + UI. Consult `entitlementStatus` only when the app needs to explain why the + entitlement set is unavailable. +- A successful refresh after `.failed` publishes `.ready` and the new active + entitlement set. A background refresh failure after `.ready` preserves the + last active set and reports the failure through `reportFailure`. - Startup and every refresh reconcile `Transaction.unfinished` — including consumables — before publishing state. A handler failure fails that refresh; the next refresh retries the unfinished work. - Transactions superseded by a subscription upgrade stay in `entitlements` but - leave `activeEntitlements`. + don't appear in `activeEntitlements`. - Unverified current-entitlement elements are omitted and reported to `reportFailure` with source `.currentEntitlementVerification`. -- Identifiers map 1:1 to product IDs. Gate access on the tier set, or use +- Product IDs mapped to the same app entitlement appear as the same typed value. + Gate access on that set, or use `StoreTransactionSnapshot.subscriptionGroupID` to grant at subscription-group granularity. @@ -247,3 +306,5 @@ command. StoreTransactionKit is available under the MIT License. [understanding]: https://lynnswap.github.io/StoreTransactionKit/documentation/storetransactionkit/understandingtransactionhandling +[subscription-setup]: https://developer.apple.com/help/app-store-connect/manage-subscriptions/offer-auto-renewable-subscriptions +[storekit-testing]: https://developer.apple.com/documentation/xcode/setting-up-storekit-testing-in-xcode From 984ad61bb7779a29ff4e413109cd5ab0a399dec3 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Fri, 17 Jul 2026 23:14:43 +0900 Subject: [PATCH 02/26] docs(readme): model subscription groups explicitly --- README.md | 69 ++++++++++++++++++++++++++----------------------------- 1 file changed, 32 insertions(+), 37 deletions(-) diff --git a/README.md b/README.md index ff7a5dd..bf187f1 100644 --- a/README.md +++ b/README.md @@ -38,48 +38,43 @@ Your app owns everything the user sees and everything it persists: ## Quick start -Define the Product IDs and app entitlements that gate your features. Keep the -subscription group alongside each subscription so the catalog can validate -StoreKit's group membership: +Define the app entitlements, then describe one App Store Connect subscription +group with its Product IDs: ```swift import StoreTransactionKit -enum SubscriptionGroupID: String, Hashable, Sendable { - case plans = "YOUR_SUBSCRIPTION_GROUP_ID" +enum SubscriptionEntitlement: Hashable, Sendable { + case tier1 + case tier2 } -enum SubscriptionProductID: String, CaseIterable, Hashable, Sendable { - case tier1Monthly = "com.example.subscription.tier1.monthly" - case tier1Yearly = "com.example.subscription.tier1.yearly" - case tier2Monthly = "com.example.subscription.tier2.monthly" - case tier2Yearly = "com.example.subscription.tier2.yearly" +enum Plans: SubscriptionGroup { + static let id = SubscriptionGroupID( + rawValue: "YOUR_SUBSCRIPTION_GROUP_ID" + ) - var entitlement: SubscriptionEntitlement { - switch self { - case .tier1Monthly, .tier1Yearly: + enum ProductID: String, CaseIterable { + case tier1_Monthly = "com.example.subscription.tier1.monthly" + case tier1_Yearly = "com.example.subscription.tier1.yearly" + case tier2_Monthly = "com.example.subscription.tier2.monthly" + case tier2_Yearly = "com.example.subscription.tier2.yearly" + } + + static func entitlement( + for productID: ProductID + ) -> SubscriptionEntitlement { + switch productID { + case .tier1_Monthly, .tier1_Yearly: .tier1 - case .tier2Monthly, .tier2Yearly: + case .tier2_Monthly, .tier2_Yearly: .tier2 } } - - var subscriptionGroupID: SubscriptionGroupID { - .plans - } -} - -enum SubscriptionEntitlement: Hashable, Sendable { - case tier1 - case tier2 } -let entitlementCatalog = EntitlementCatalog( - products: SubscriptionProductID.self, - entitlement: \.entitlement, - subscriptionGroupID: \.subscriptionGroupID -) +let subscriptionCatalog = SubscriptionCatalog(Plans.self) actor PurchaseLedger { func apply(_ transaction: StoreTransactionSnapshot) async throws { @@ -112,7 +107,7 @@ func makeStore( diagnostics: StoreDiagnostics ) -> TransactionStore { TransactionStore( - entitlementCatalog: entitlementCatalog, + subscriptionCatalog: subscriptionCatalog, handleTransaction: { transaction in try await ledger.apply(transaction) }, @@ -197,7 +192,7 @@ struct ContentView: View { } .sheet(isPresented: $isShowingPaywall) { SubscriptionStoreView( - groupID: SubscriptionGroupID.plans.rawValue + groupID: Plans.id.rawValue ) } } @@ -206,10 +201,10 @@ struct ContentView: View { ### Connect the identifiers -Replace the group and product raw values with the identifiers configured in -[App Store Connect][subscription-setup]. Each Product ID maps to one app -entitlement. StoreKit remains the source of truth for subscription group levels -and durations. +Replace `Plans.id` and the nested Product ID raw values with the identifiers +configured in [App Store Connect][subscription-setup]. Map monthly and yearly +products that grant the same access level to the same app entitlement. StoreKit +remains the source of truth for levels and durations. For local StoreKit Testing, use the same values in the active `.storekit` configuration. See [Setting up StoreKit Testing in Xcode][storekit-testing]. @@ -264,9 +259,9 @@ Both callback contracts are documented on `TransactionStore.init`. - Unverified current-entitlement elements are omitted and reported to `reportFailure` with source `.currentEntitlementVerification`. - Product IDs mapped to the same app entitlement appear as the same typed value. - Gate access on that set, or use - `StoreTransactionSnapshot.subscriptionGroupID` to grant at - subscription-group granularity. +- `SubscriptionCatalog` maps auto-renewable subscriptions only. Other product + types don't belong to subscription groups; consumables remain part of + transaction handling and never appear in current entitlements. For the full delivery, reconciliation, and failure-reporting model, see [Understanding transaction handling][understanding]. From 223db9fc157b40ec8fa727fe5df24ffbba18b30d Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Fri, 17 Jul 2026 23:30:45 +0900 Subject: [PATCH 03/26] docs(readme): add entitlement query API --- README.md | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index bf187f1..06320a4 100644 --- a/README.md +++ b/README.md @@ -166,7 +166,7 @@ struct ContentView: View { @State private var isShowingPaywall = false private var canExportPDF: Bool { - store.activeEntitlements?.contains(.tier1) == true + store.isEntitled(to: .tier1) } var body: some View { @@ -245,8 +245,8 @@ Both callback contracts are documented on `TransactionStore.init`. - `activeEntitlements` is `nil` while `entitlementStatus` is `.loading` or `.failed`. When the status is `.ready`, an empty set means no catalog entitlement is active. -- Gate paid features on `activeEntitlements` without blocking the surrounding - UI. Consult `entitlementStatus` only when the app needs to explain why the +- Gate paid features with `isEntitled(to:)` without blocking the surrounding UI. + Consult `entitlementStatus` only when the app needs to explain why the entitlement set is unavailable. - A successful refresh after `.failed` publishes `.ready` and the new active entitlement set. A background refresh failure after `.ready` preserves the @@ -290,6 +290,12 @@ Apple's [purchase API guidance](https://developer.apple.com/documentation/storekit/product/purchase(options:)) explains which purchase entry point to use for each UI framework and platform. +## API design + +See [Subscription catalog API design](Docs/SubscriptionCatalogAPI.md) for the +proposed public interface, validation rules, ownership boundaries, and state +transition contract behind the Quick start. + ## Testing The app-hosted StoreKit integration suite runs with `xcodebuild`. See From edb556eb83255cae2cdb607f0ed53136161fee21 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Fri, 17 Jul 2026 23:49:16 +0900 Subject: [PATCH 04/26] docs(readme): clarify catalog failure state --- README.md | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 06320a4..5e47765 100644 --- a/README.md +++ b/README.md @@ -241,7 +241,8 @@ Both callback contracts are documented on `TransactionStore.init`. ## How entitlement availability behaves - `entitlementStatus` is `.loading` before the first readiness result, - `.failed(error)` after a readiness failure, and `.ready` after success. + `.failed(error)` when no usable catalog projection is available, and `.ready` + when raw and typed entitlement state is available. - `activeEntitlements` is `nil` while `entitlementStatus` is `.loading` or `.failed`. When the status is `.ready`, an empty set means no catalog entitlement is active. @@ -249,8 +250,11 @@ Both callback contracts are documented on `TransactionStore.init`. Consult `entitlementStatus` only when the app needs to explain why the entitlement set is unavailable. - A successful refresh after `.failed` publishes `.ready` and the new active - entitlement set. A background refresh failure after `.ready` preserves the - last active set and reports the failure through `reportFailure`. + entitlement set. A background query or transaction-handler failure after + `.ready` preserves the last active set and reports the failure through + `reportFailure`. +- A verified catalog mismatch fails closed: it changes the status to `.failed` + and clears both entitlement projections instead of preserving stale access. - Startup and every refresh reconcile `Transaction.unfinished` — including consumables — before publishing state. A handler failure fails that refresh; the next refresh retries the unfinished work. From 751648b5e581b1c2333a8f7390a19e2f084a96f6 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Fri, 17 Jul 2026 23:51:38 +0900 Subject: [PATCH 05/26] docs(api): add subscription catalog design proposal --- Docs/SubscriptionCatalogAPI.md | 616 +++++++++++++++++++++++++++++++++ 1 file changed, 616 insertions(+) create mode 100644 Docs/SubscriptionCatalogAPI.md diff --git a/Docs/SubscriptionCatalogAPI.md b/Docs/SubscriptionCatalogAPI.md new file mode 100644 index 0000000..888779a --- /dev/null +++ b/Docs/SubscriptionCatalogAPI.md @@ -0,0 +1,616 @@ +# Subscription catalog API design + +Status: Proposed for the next beta API. The README shows this design, but the +source implementation and symbol documentation do not provide it yet. + +## Purpose + +StoreTransactionKit needs to translate StoreKit Product IDs into the app's +feature-access vocabulary without making Product IDs themselves the public +entitlement type. The translation must represent App Store Connect +subscription groups accurately, reject configuration drift, and preserve the +difference between unavailable entitlement state and a resolved empty set. + +The primary consumer is an app with one auto-renewable subscription group that +contains multiple access levels and multiple durations at each level. A second +supported consumer has multiple independent subscription groups whose products +map into one app entitlement type. + +## Goals + +- Scope each Product ID type to one App Store Connect subscription group. +- Map multiple billing durations at the same access level to one app + entitlement. +- Keep StoreKit's subscription group level and duration metadata in StoreKit. +- Validate the remote transaction metadata that the app's static catalog can + know about without loading products eagerly. +- Publish raw and typed entitlement state as one atomic snapshot. +- Keep the surrounding app UI usable while entitlement state is unavailable. +- Support one group in the common case and explicit composition for independent + groups. + +## Non-goals + +- The catalog does not describe consumables, non-consumables, or non-renewing + subscriptions. +- The catalog does not own product merchandising, prices, localized names, + purchase UI, renewal UI, or `Product.SubscriptionInfo.Status`. +- The framework does not infer app access from StoreKit `groupLevel`. +- The framework does not infer an entitlement for an unknown product. +- The design does not retain the current Product-ID-as-entitlement API for + source compatibility. The package is still beta. + +## StoreKit model + +An App Store Connect subscription group contains auto-renewable subscriptions +with different access levels and durations. A customer can hold one +subscription product in a group at a time. Products at one level may have +monthly and yearly variants. + +StoreKit owns these facts: + +- `Product.SubscriptionInfo.subscriptionGroupID` identifies the group. +- `Product.SubscriptionInfo.groupLevel` ranks upgrade and downgrade paths; + level `1` is the highest service level. +- `Product.SubscriptionInfo.subscriptionPeriod` describes the renewal period. +- `Transaction.currentEntitlements` includes current non-consumables, + qualifying auto-renewable subscriptions, and non-renewing subscriptions. It + excludes consumables. + +The app owns the meaning of access. `SubscriptionEntitlement.tier1` is an app +domain value; it is not a copy of StoreKit `groupLevel == 1`. The explicit +Product ID mapping is the boundary between the two models. + +## Consumer story + +```swift +enum SubscriptionEntitlement: Hashable, Sendable { + case tier1 + case tier2 +} + +enum Plans: SubscriptionGroup { + static let id = SubscriptionGroupID( + rawValue: "YOUR_SUBSCRIPTION_GROUP_ID" + ) + + enum ProductID: String, CaseIterable { + case tier1_Monthly = "com.example.subscription.tier1.monthly" + case tier1_Yearly = "com.example.subscription.tier1.yearly" + case tier2_Monthly = "com.example.subscription.tier2.monthly" + case tier2_Yearly = "com.example.subscription.tier2.yearly" + } + + static func entitlement( + for productID: ProductID + ) -> SubscriptionEntitlement { + switch productID { + case .tier1_Monthly, .tier1_Yearly: + .tier1 + + case .tier2_Monthly, .tier2_Yearly: + .tier2 + } + } +} + +let subscriptionCatalog = SubscriptionCatalog(Plans.self) + +let store = TransactionStore( + subscriptionCatalog: subscriptionCatalog, + handleTransaction: handleTransaction, + reportFailure: reportFailure +) + +let canExportPDF = store.isEntitled(to: .tier1) +``` + +An app with independent groups composes them without erasing their nested +Product ID types: + +```swift +let subscriptionCatalog = SubscriptionCatalog(Plans.self) + .including(ChannelSubscriptions.self) +``` + +## Proposed public interface + +```swift +public struct SubscriptionGroupID: + RawRepresentable, + Hashable, + Sendable +{ + public let rawValue: String + + public init(rawValue: String) +} + +public protocol SubscriptionGroup { + associatedtype Entitlement: Hashable & Sendable + associatedtype ProductID: + RawRepresentable & CaseIterable + + static var id: SubscriptionGroupID { get } + + static func entitlement( + for productID: ProductID + ) -> Entitlement +} + +public struct SubscriptionCatalog: Sendable +where Entitlement: Hashable & Sendable { + public init(_ groupType: Group.Type) + where Group: SubscriptionGroup + + public func including( + _ groupType: Group.Type + ) -> SubscriptionCatalog + where Group: SubscriptionGroup +} + +public enum SubscriptionCatalogError: LocalizedError, Sendable { + case unknownProduct( + productID: String, + subscriptionGroupID: SubscriptionGroupID + ) + case productTypeMismatch( + productID: String, + actual: Product.ProductType + ) + case subscriptionGroupMismatch( + productID: String, + expected: SubscriptionGroupID, + actual: String? + ) + + public var errorDescription: String? { get } +} + +public enum EntitlementStatus: Sendable { + case loading + case failed(any Error) + case ready +} + +@MainActor +@Observable +public final class TransactionStore +where Entitlement: Hashable & Sendable { + public var entitlementStatus: EntitlementStatus { get } + public var entitlements: StoreEntitlements? { get } + public var activeEntitlements: Set? { get } + + public func isEntitled(to entitlement: Entitlement) -> Bool + + public init( + subscriptionCatalog: SubscriptionCatalog, + handleTransaction: + @escaping @Sendable (StoreTransactionSnapshot) async throws -> Void, + reportFailure: + @escaping @Sendable (StoreTransactionBackgroundFailure) async -> Void + ) + + public func process( + _ result: Product.PurchaseResult + ) async throws -> StorePurchaseOutcome + + @discardableResult + public func refreshEntitlements() async throws -> StoreEntitlements + + public func history( + for productID: Product.ID + ) async throws -> [StoreTransactionSnapshot] + + @discardableResult + public func restorePurchases() async throws -> StoreEntitlements + + public func close() async throws +} +``` + +`SubscriptionGroup` is a client-conformance protocol because each app supplies +its own closed group definition. Its requirements remain intentionally small. +The protocol itself is not `Sendable`, and `ProductID` does not require +`Hashable` or `Sendable`: the catalog consumes `allCases` synchronously and +normalizes each case to its raw `String` during construction. No group instance, +group metatype, or typed Product ID is retained. + +Adding a protocol requirement after 1.0 would break client conformances. Future +optional metadata belongs in catalog initializers or configuration values, not +in a new `SubscriptionGroup` requirement. + +`SubscriptionCatalog` is an immutable value. `including(_:)` returns another +catalog and leaves the receiver unchanged. This keeps the one-group use case to +one line while supporting the StoreKit case where independent subscriptions +must live in separate groups. + +## Type-safety boundary + +The API provides compile-time safety for app-owned declarations: + +- `Plans.ProductID` cannot be passed where another group's nested Product ID is + expected. +- The exhaustive `switch` in `entitlement(for:)` maps every declared Product ID. +- `SubscriptionGroupID` prevents a group identifier from being confused with an + arbitrary Product ID at API boundaries. +- `TransactionStore` exposes the app's `Entitlement`, not raw Product IDs, to + feature-gating code. +- `isEntitled(to:)` expresses a feature gate without exposing optional-set + mechanics at each call site. + +The compiler cannot validate App Store Connect. Runtime validation is therefore +part of the catalog contract rather than a substitute source of truth. + +`SubscriptionGroupID.init(rawValue:)` preconditions that the raw value is not +empty. The identifier type owns that invariant because the value is also useful +outside the catalog, such as when passing `Plans.id.rawValue` to StoreKit UI. + +## Catalog construction + +Construction converts each group into normalized internal entries keyed by raw +Product ID. It also records the set of managed subscription group IDs. + +The following remaining source-defined configuration errors fail with a +`precondition` during catalog construction: + +- A group whose `ProductID.allCases` is empty. +- An empty Product ID raw value. +- A duplicate raw Product ID within one group or across included groups. +- A duplicate subscription group ID. + +These are programmer errors in static app configuration. A nonthrowing +initializer keeps the normal composition root free of `try!`; the behavior is +analogous to `Dictionary(uniqueKeysWithValues:)` rejecting duplicate keys. +Duplicate checks remain necessary because a manually implemented +`RawRepresentable` or `CaseIterable` can violate the guarantees normally +provided by a raw-value enum. + +Duplicate entitlement values are valid. Monthly and yearly products at one +access level are expected to produce the same entitlement, and independent +groups may grant the same app entitlement. + +`SubscriptionCatalogError.errorDescription` includes the Product ID and the +expected and actual metadata needed to diagnose App Store Connect drift. These +descriptions are developer diagnostics and are not end-user presentation copy. +The public cases remain distinct so diagnostics and contract tests can identify +whether the shipped catalog is missing a product, names the wrong product type, +or assigns a product to the wrong group. + +Catalog construction performs no network request and does not load `Product` +values. Product metadata is validated only when StoreKit supplies a verified +transaction snapshot. + +## Runtime projection and validation + +For each verified transaction in a candidate `StoreEntitlements` snapshot, the +catalog applies these rules before anything is published: + +1. A transaction with `isUpgraded == true` remains in raw `entitlements` and is + excluded from typed projection. It no longer grants access, so it does not + require a current catalog entry. +2. A declared Product ID must have `productType == .autoRenewable`. +3. A declared Product ID must have the subscription group ID declared by its + `SubscriptionGroup`. +4. An undeclared Product ID whose transaction belongs to a managed group fails + with `SubscriptionCatalogError.unknownProduct` because the framework cannot + infer its app entitlement. +5. An undeclared product outside every managed group remains in raw + `entitlements` and is ignored by the typed projection. +6. Successful mappings are collected into a `Set`, so multiple durations and + multiple groups may produce one typed entitlement value. + +The upgrade filter runs before catalog lookup, so an upgraded historical +transaction alone does not require its retired Product ID to remain in the +catalog. The ID must remain while any supported customer can still hold that +product as a non-upgraded current entitlement. Every non-upgraded known or +managed-group transaction is validated before publication. + +The catalog is a closed definition of every group it manages. Adding a Product +ID in App Store Connect can therefore make an older app binary report +`unknownProduct` after a user moves to that product. Product rollout must account +for supported older app versions; silently guessing a tier would risk granting +the wrong access. + +Composition is atomic, not failure-isolated. If one included group has a catalog +mismatch, the typed projection for every included group becomes unavailable. +Per-group availability would require a different public state model and is not +provided by this API. + +## Atomic publication owner + +Raw and typed entitlement values describe one StoreKit query and must commit +together. Catalog projection and validation therefore run inside the entitlement +refresh coordination boundary, after unfinished transactions have been handled +and before any of the following occur: + +- Updating the coordinator's current snapshot. +- Notifying the observable store. +- Completing a refresh receipt successfully. +- Returning a `StoreEntitlements` result to a caller. + +If a query or transaction handler fails before producing a verified candidate, +the previous complete snapshot remains current. A catalog failure is different: +the verified candidate contradicts the old typed projection. The coordinator +clears its complete publication, the observable store becomes `.failed`, and +both public projections become `nil`. Keeping the old typed set could continue +granting a higher tier after a user has moved to an unknown lower-tier product. + +The coordinator reports every physical query batch to the observable state +owner exactly once, before completing attached receipts: + +```swift +private struct EntitlementPublication: Sendable +where Entitlement: Hashable & Sendable { + let entitlements: StoreEntitlements + let activeEntitlements: Set +} + +private enum EntitlementRefreshOutcome: Sendable +where Entitlement: Hashable & Sendable { + case success(EntitlementPublication) + case transientFailure(any Error) + case catalogFailure(SubscriptionCatalogError) +} + +didComplete( + token: UInt64, + outcome: EntitlementRefreshOutcome +) +``` + +For a coalesced batch, `didComplete.token` is the last reservation token in that +batch. The coordinator delivers completions in physical token order. A single +`TransactionStore` reducer owns all availability transitions; startup, direct, +and background callers never write observable state themselves. + +`transientFailure` carries the normalized underlying error that belongs in +`entitlementStatus`. Any internal reporting-owner wrapper remains available to +receipts and reporting authority but does not leak into observable state. +Diagnostic reporting is a separate ownership decision, so `didComplete` does +not call `reportFailure`. + +Mapping in `TransactionStore.activeEntitlements` after raw publication would +violate atomicity and is not part of the design. + +## Observable state + +The three public properties are separate views of one private state value: + +```swift +private enum EntitlementAvailability { + case loading + case failed(any Error) + case ready( + entitlements: StoreEntitlements, + activeEntitlements: Set + ) +} +``` + +`entitlementStatus`, `entitlements`, and `activeEntitlements` are computed from +that value. The store never updates three independent stored properties. This +prevents Observation from rendering combinations such as `.ready` with a `nil` +typed set. + +The public meaning is: + +| Status | `entitlements` | `activeEntitlements` | Meaning | +| --- | --- | --- | --- | +| `.loading` | `nil` | `nil` | No readiness attempt has completed. | +| `.failed(error)` | `nil` | `nil` | No usable complete snapshot exists; inspect `error` for the reason. | +| `.ready` | non-`nil` | non-`nil` | A complete raw and typed snapshot is available. Empty values mean no entitlement. | + +`.loading` is only the initial state. The store does not return to it for later +refreshes. `.failed` means that no usable snapshot exists; it does not mean that +the most recent operation failed. + +State transitions are: + +| Event | Result | +| --- | --- | +| Initialization | `.loading` with both projections `nil`. | +| Any successful candidate | `.ready` with the new atomic snapshot. | +| Query or handler failure while `.loading` or `.failed` | `.failed(error)` with both projections `nil`. | +| Query or handler failure after `.ready` | Preserve the previous `.ready` snapshot. This includes a late startup failure after another refresh has already succeeded. | +| Catalog failure in a verified candidate | `.failed(error)` with both projections `nil`, even after `.ready`; stale typed access is invalidated. | +| Successful empty query | `.ready`; both collections are empty, not `nil`. | +| Unverified current-entitlement element | Omit and report that element; publish the verified remainder if the query otherwise succeeds. | +| Close | Preserve the last entitlement state; lifecycle errors are reported by operations, not by `EntitlementStatus`. | + +The current `startupError` property is removed. Its readiness role moves to +`entitlementStatus`, while operational diagnostics continue through thrown +errors and `reportFailure`. + +SwiftUI calls `isEntitled(to:)` directly. It does not copy the set or status into +`@State`, and normal app content does not wait for readiness. The method returns +`true` only when the current ready snapshot contains the requested entitlement; +it returns `false` while loading, after a readiness failure, and when a ready set +does not contain the value. Code that needs to distinguish those reasons reads +`entitlementStatus`. `activeEntitlements` remains available for consumers that +need the complete typed set. + +This is exact set membership. It does not infer that StoreKit group level 1 +contains level 2, or that one app entitlement includes another. If multiple plan +identities grant one feature, the app expresses that policy by checking each +accepted entitlement. The catalog continues to own only Product ID to app-value +translation. + +## Failure routing + +Failure delivery depends on ownership of the operation, not only on the error +type: + +| Failure | Observable state | Direct caller | Background diagnostics | +| --- | --- | --- | --- | +| Invalid source-defined catalog | Store is not created | None | `precondition` failure | +| Startup query or handler failure | Become `.failed` if no snapshot exists; otherwise preserve `.ready` | No startup caller | Report once when no other physical-work owner already reports it | +| Startup catalog failure | `.failed(error)` and invalidate any previous projection | No startup caller | Report once when no other physical-work owner already reports it | +| Explicit query or handler failure | Become or remain `.failed` without a snapshot; otherwise preserve `.ready` | Throw underlying error | Do not duplicate while a caller owns it | +| Explicit catalog failure | `.failed(error)` and invalidate any previous projection | Throw underlying error | Do not duplicate while a caller owns it | +| Background query or handler failure after `.ready` | Preserve `.ready` snapshot | None | Report once through `reportFailure` | +| Background catalog failure | `.failed(error)` and invalidate any previous projection | None | Report once through `reportFailure` | +| Current-entitlement verification failure for one element | Publish verified remainder | Attached operation may still succeed | Report omitted element once | + +A catalog projection error participates in the same physical-work ownership and +coalescing rules as a StoreKit query error, but its observable-state transition +is intentionally fail-closed. Background-owned catalog failures use +`StoreTransactionBackgroundFailure.Source.entitlementRefresh` with the public +`SubscriptionCatalogError` as `underlyingError`. + +Reservation role alone does not decide whether to report. Every startup, +background, and direct reservation in one physical batch shares one reporting +authority. Direct participation is registered as part of `reserve`, before the +worker can start, so a fast failure cannot race a later observer binding. + +The authority collects one background report candidate and all direct-caller +dispositions, then decides once: + +- If any attached direct caller receives the error, no background diagnostic is + sent. +- If every direct caller abandons the work, one background diagnostic is sent. +- If no direct caller participated, the startup or background physical work + sends one diagnostic. + +This is independent of whether background or direct work reserved first. State +completion still occurs exactly once through `didComplete`. + +## Product-type boundaries + +`SubscriptionCatalog` is intentionally specific: + +- Auto-renewable subscriptions are mapped by group and Product ID. +- Non-consumables may appear in raw `StoreEntitlements`, but this catalog does + not map them to typed app access. +- Non-renewing subscriptions may appear in raw current entitlements even after + their intended service period; app-owned expiry policy is outside this + catalog. +- Consumables never appear in `Transaction.currentEntitlements`. They still pass + through the durable `handleTransaction` path so the app can update its owned + balance before the transaction is finished. + +If a concrete consumer later needs typed non-consumable access, it requires a +separate design. It must not be represented as a member of +`SubscriptionGroup`, because StoreKit does not model it that way. + +## Ownership map + +| Responsibility | Owner | +| --- | --- | +| Group ID, Product ID cases, and Product ID to app-entitlement mapping | App-defined `SubscriptionGroup` conformance | +| Normalized lookup, managed-group membership, and catalog validation | `SubscriptionCatalog` | +| StoreKit query and unfinished-transaction reconciliation | `CurrentEntitlementReconciler` | +| Candidate projection, atomic publication, refresh coalescing, ordered completion, and receipt completion | Generic entitlement refresh coordinator | +| Observable availability reducer and process-lifetime facade | `TransactionStore` | +| Direct/background reporting authority and exactly-once diagnostic delivery | Runtime pipeline and failure reporter dispatcher | +| Durable business effect and idempotency across launches | App transaction handler | +| Product merchandising and subscription status presentation | App using StoreKit directly | + +No UI type owns semantic entitlement state. No second mapping is performed in a +view, callback, or computed property outside the catalog owner. + +## Lifecycle and concurrency + +- `TransactionStore` remains `@MainActor`, `@Observable`, and process-owned. +- `SubscriptionCatalog` is immutable and `Sendable` after normalization. Its + storage uses value semantics rather than shared mutable storage or + `@unchecked Sendable`. +- App-defined `Entitlement` values cross concurrency boundaries and must be + `Hashable & Sendable`. +- Group types and typed Product IDs are consumed synchronously during catalog + construction and do not cross concurrency boundaries. +- The store starts monitoring during initialization and retains the catalog for + every entitlement projection. +- Existing transaction handler, failure reporter, reentrancy, and `close()` + contracts remain unchanged except for the readiness reporting described + above. + +## Required contract tests + +### Catalog tests + +- Every declared monthly and yearly Product ID maps to its expected entitlement. +- Multiple groups compose into one catalog without type erasure at the call site. +- `including(_:)` does not change the original catalog or share mutable storage. +- An empty `SubscriptionGroupID` fails when the ID value is constructed. +- Empty Product IDs, empty groups, duplicate group IDs, and duplicate Product + IDs fail during catalog construction. +- Duplicate entitlement values remain valid. +- Each `SubscriptionCatalogError.errorDescription` identifies the Product ID and + relevant expected or actual metadata. +- Known Product ID with a wrong product type fails projection. +- Known Product ID with a wrong or missing group ID fails projection. +- Unknown Product ID inside a managed group fails projection. +- Unknown Product ID outside managed groups remains raw and is ignored by the + typed set. +- A catalog mismatch in one included group fails the complete composed + projection. +- Known and unknown upgraded transactions remain raw, do not require a current + catalog entry, and do not grant typed access. + +### State-owner tests + +- Initial state is `.loading` with both projections `nil`. +- A successful empty query produces `.ready` and two empty collections. +- Startup query, handler, and catalog failures produce `.failed` without a + partial candidate snapshot when no earlier query has succeeded. +- A later success recovers `.failed` to `.ready` atomically. +- A late startup query failure after a background success preserves `.ready`. +- Explicit and background query or handler failures after `.ready` preserve the + previous raw and typed snapshot. +- A verified known-tier to unknown-tier change produces `.failed`, clears both + public projections, and makes `isEntitled(to:)` return `false`. +- A coalesced catalog failure does not publish a partial raw or typed snapshot. +- Observation never publishes a status/projection combination outside the state + table. +- `withObservationTracking` observes `isEntitled(to:)` through the private + availability value. +- `isEntitled(to:)` returns `false` for `.loading`, `.failed`, and a ready set + without the value, and `true` only for a matching ready entitlement. + +### Coordination and reporting tests + +- Physical query completions reach the availability reducer once and in token + order before attached receipts complete. A coalesced completion uses the last + reservation token. +- Startup-owned plain query failure is reported once. +- Startup-owned catalog failure is reported once. +- Background-owner/direct-observer and direct-owner/background-observer catalog + failures each complete state once, deliver the error to the attached direct + caller, and send no background diagnostic. +- Background-owner/startup-observer and startup-owner/background-observer + failures each complete state and diagnostics once. +- A physical failure is reported once if every direct caller abandons it. + +### Integration and distribution tests + +- App-hosted StoreKit tests cover monthly/yearly mapping, upgrades, a known-tier + to unknown-tier transition, restore, revocation, and recovery without fixed + sleeps. +- The external consumer fixture builds the README story using only public API. +- Swift 6 strict-concurrency builds prove the primary-associated-type and + `Sendable` surface. +- DocC builds without warnings after symbol documentation is added. + +## Implementation transaction + +This public redesign is complete only when one change updates all of the +following: + +- Public source and symbol documentation. +- Unit and app-hosted StoreKit tests. +- The external consumer fixture. +- README and DocC examples. +- Any dependent app and its resolved package revision. + +No compatibility alias or deprecated initializer is planned while the package +is beta. + +## References + +- [Offer auto-renewable subscriptions](https://developer.apple.com/help/app-store-connect/manage-subscriptions/offer-auto-renewable-subscriptions/) +- [`Product.SubscriptionInfo.subscriptionGroupID`](https://developer.apple.com/documentation/storekit/product/subscriptioninfo/subscriptiongroupid) +- [`Product.SubscriptionInfo.groupLevel`](https://developer.apple.com/documentation/storekit/product/subscriptioninfo/grouplevel) +- [`Product.SubscriptionInfo.subscriptionPeriod`](https://developer.apple.com/documentation/storekit/product/subscriptioninfo/subscriptionperiod) +- [`Transaction.isUpgraded`](https://developer.apple.com/documentation/storekit/transaction/isupgraded) +- [`Transaction.currentEntitlements`](https://developer.apple.com/documentation/storekit/transaction/currententitlements) From b801e439b69f18d2604cdf32f0b14952335384b2 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Sat, 18 Jul 2026 01:37:52 +0900 Subject: [PATCH 06/26] docs(api): document optional transaction delegate --- Docs/SubscriptionCatalogAPI.md | 596 ++++++++++++++++++++++++++++++--- README.md | 212 +++++++----- 2 files changed, 672 insertions(+), 136 deletions(-) diff --git a/Docs/SubscriptionCatalogAPI.md b/Docs/SubscriptionCatalogAPI.md index 888779a..77ddac4 100644 --- a/Docs/SubscriptionCatalogAPI.md +++ b/Docs/SubscriptionCatalogAPI.md @@ -1,4 +1,4 @@ -# Subscription catalog API design +# Subscription catalog, delegate, override, and testing API design Status: Proposed for the next beta API. The README shows this design, but the source implementation and symbol documentation do not provide it yet. @@ -11,6 +11,10 @@ entitlement type. The translation must represent App Store Connect subscription groups accurately, reject configuration drift, and preserve the difference between unavailable entitlement state and a resolved empty set. +The same typed entitlement model must also support an app-selected StoreKit +bypass and deterministic app or ViewModel tests. Those paths must not invent +StoreKit transactions or fork production entitlement semantics. + The primary consumer is an app with one auto-renewable subscription group that contains multiple access levels and multiple durations at each level. A second supported consumer has multiple independent subscription groups whose products @@ -28,6 +32,14 @@ map into one app entitlement type. - Keep the surrounding app UI usable while entitlement state is unavailable. - Support one group in the common case and explicit composition for independent groups. +- Require an explicit `.finish` policy decision before StoreTransactionKit + finishes a verified transaction. +- Separate the finish decision from optional background-failure notification. +- Let the app construct a fixed entitlement override without environment + detection inside the framework. +- Let tests drive the real transaction and entitlement pipeline without a + `.storekit` configuration or timing guesses. +- Separate virtual time control from causal operation completion. ## Non-goals @@ -37,6 +49,13 @@ map into one app entitlement type. purchase UI, renewal UI, or `Product.SubscriptionInfo.Status`. - The framework does not infer app access from StoreKit `groupLevel`. - The framework does not infer an entitlement for an unknown product. +- The framework does not detect TestFlight, previews, debug builds, receipts, + or other distribution environments to select override mode. +- The no-configuration test harness does not validate StoreKit verification, + JWS, App Store Connect metadata, system purchase UI, or StoreKit renewal + scheduling. +- Advancing a test clock does not mean that the transaction pipeline is idle or + that an entitlement update has been published. - The design does not retain the current Product-ID-as-entitlement API for source compatibility. The package is still beta. @@ -97,14 +116,28 @@ enum Plans: SubscriptionGroup { let subscriptionCatalog = SubscriptionCatalog(Plans.self) let store = TransactionStore( - subscriptionCatalog: subscriptionCatalog, - handleTransaction: handleTransaction, - reportFailure: reportFailure + subscriptionCatalog: subscriptionCatalog ) let canExportPDF = store.isEntitled(to: .tier1) ``` +The app can use the same catalog and entitlement type for a fixed StoreKit +bypass: + +```swift +let store = TransactionStore( + subscriptionCatalog: subscriptionCatalog, + overridingEntitlements: [ + SubscriptionEntitlement.tier1, + .tier2, + ] +) +``` + +The app owns the condition that selects this initializer. Passing an empty +sequence explicitly selects override mode with no active entitlement. + An app with independent groups composes them without erasing their nested Product ID types: @@ -171,6 +204,51 @@ public enum EntitlementStatus: Sendable { case loading case failed(any Error) case ready + case overridden +} + +public enum StoreTransactionOperation: Sendable, Hashable { + case processPurchase + case currentEntitlements + case history + case restorePurchases + case close +} + +public enum StoreTransactionError: Error, Sendable, Hashable { + case closing + case closed + case unknownPurchaseResult + case reentrantOperation(operation: StoreTransactionOperation) + case operationUnavailableInOverride(operation: StoreTransactionOperation) +} + +public enum StoreTransactionHandlingPolicy: Sendable { + case finish + case keepUnfinished +} + +public protocol TransactionStoreDelegate: AnyObject, Sendable { + func transactionStore( + decidePolicyFor transaction: StoreTransactionSnapshot + ) async throws -> StoreTransactionHandlingPolicy + + func transactionStore( + didFailWith failure: StoreTransactionBackgroundFailure + ) async +} + +public extension TransactionStoreDelegate { + func transactionStore( + didFailWith failure: StoreTransactionBackgroundFailure + ) async {} +} + +public enum StorePurchaseOutcome: Sendable, Hashable { + case completed(StoreTransactionSnapshot) + case keptUnfinished(StoreTransactionSnapshot) + case pending + case userCancelled } @MainActor @@ -185,10 +263,12 @@ where Entitlement: Hashable & Sendable { public init( subscriptionCatalog: SubscriptionCatalog, - handleTransaction: - @escaping @Sendable (StoreTransactionSnapshot) async throws -> Void, - reportFailure: - @escaping @Sendable (StoreTransactionBackgroundFailure) async -> Void + delegate: (any TransactionStoreDelegate)? = nil + ) + + public init( + subscriptionCatalog: SubscriptionCatalog, + overridingEntitlements: some Sequence ) public func process( @@ -209,6 +289,90 @@ where Entitlement: Hashable & Sendable { } ``` +## Optional transaction delegate + +Omitting `delegate` selects a package-owned implementation that returns +`.finish` for every verified transaction and performs no app-specific failure +notification. This is the common subscription-app path: StoreTransactionKit +still verifies, orders, finishes, reconciles, and publishes the transaction, but +the app has no additional durable business effect. + +Omitting the delegate is an initializer-level choice, not an empty delegate +method. Once the app supplies a delegate, the package-owned policy is replaced +and every verified transaction requires an explicit app decision. + +Supplying a delegate replaces that built-in policy. The store strongly retains +the supplied value until `close()` finishes or the store is deinitialized. + +`TransactionStoreDelegate` follows the decision/notification split used by +`WKNavigationDelegate`: a method that grants permission for a consequential +operation returns a policy, while a method that reports an event that has +already occurred returns no policy. The transaction method is also `throws` +because failing to reach a decision is different from deliberately choosing a +normal policy. + +For an app-supplied delegate, `transactionStore(decidePolicyFor:)` has no +default implementation. Falling off the end of an empty method body can no +longer authorize `Transaction.finish()`; the app must return one of these +policies: + +- `.finish` means the app has durably applied this business event, or has + established from its idempotency ledger that the event was already applied. + StoreTransactionKit then calls `finish()`, records the exact transaction + revision as completed, refreshes current entitlements, and publishes the + resulting state. +- `.keepUnfinished` means the app reached an expected deferral decision and, + when its own model requires it, recorded that decision durably. It is not a + substitute for catching a processing error. StoreTransactionKit does not call + `finish()` or send a failure notification, but it continues the causal + entitlement refresh. A direct purchase returns + `.keptUnfinished(transaction)` after that refresh and MainActor publication + complete. +- Throwing means that the delegate could not establish either a durable + `.finish` decision or a valid `.keepUnfinished` decision. StoreTransactionKit + does not call `finish()`. A direct operation forwards the error to its caller; + background-owned work sends it to `transactionStore(didFailWith:)`. A later + independent attempt can redeliver the transaction. + +The transaction-processing coordinator owns one causal decision receipt for an +exact revision from admission through completion of the refresh caused by that +decision. Direct results, `Transaction.updates`, and +`Transaction.unfinished` reconciliation all attach to that receipt instead of +invoking the delegate again. Coalesced reservations share the same receipt, and +the reconciler seeds its exact-revision exclusion set from every receipt in the +physical refresh. The receipt completes only after the refresh succeeds or +fails and all attached callers and reporting owners receive that result. + +`.keepUnfinished` is not added to the process-lifetime completed set or the +failed-attempt set. The causal receipt is discarded when its physical refresh +completes, after which a non-coalesced update, status change, explicit refresh, +restore, or startup attempt may present the exact revision again. +StoreTransactionKit does not schedule a timer or backoff retry. Coalescing uses +exact revision identity rather than transaction ID, so a later revocation or +another revised business event is not suppressed. The delegate must remain +idempotent for both normal policies. + +`transactionStore(didFailWith:)` is an optional observation hook with a default +no-op implementation. It cannot change a transaction decision, request a retry, +or suppress a thrown error. Admitted notifications are delivered serially with +backpressure, and `close()` waits for each invocation to return. Direct errors +that reach an attached caller are not duplicated as background notifications. + +A weak app delegate would allow the finish authority to disappear after +initialization. The delegate must not retain or call back into the same store, +directly or through an awaited child or detached task. The methods intentionally +omit a store parameter because such calls are unsupported and the delegate does +not depend on the store's generic entitlement type. + +The protocol is class-bound and `Sendable`, but it is not actor-bound. A +delegate that owns mutable state directly can be an actor. A checked-Sendable +`final class` with immutable `Sendable` dependencies is equally valid. Runtime +delivery ordering does not make an otherwise unsynchronized mutable class safe. + +`StoreTransactionOperation` is the existing closed diagnostic vocabulary rather +than a free-form string. The new error case reuses it so an override-mode +failure identifies the rejected operation without parsing text. + `SubscriptionGroup` is a client-conformance protocol because each app supplies its own closed group definition. Its requirements remain intentionally small. The protocol itself is not `Sendable`, and `ProductID` does not require @@ -317,6 +481,45 @@ mismatch, the typed projection for every included group becomes unavailable. Per-group availability would require a different public state model and is not provided by this API. +## Fixed entitlement override + +`overridingEntitlements` is a composition-root choice, not mutable runtime +state. The initializer consumes `some Sequence`, normalizes it once +to a `Set`, and publishes `.overridden` immediately. An array literal is the +common spelling; an existing `Set` or another finite sequence is equally valid. + +An empty sequence means “override with no active entitlement.” It is observably +different from selecting the live initializer, which begins in `.loading`. +There is no Boolean “unlock everything” form because the framework does not +know the app's complete entitlement universe or inclusion policy. + +An override store has these contracts: + +- It does not create a StoreKit source, start update or status monitors, query + current entitlements, process transactions, retain a delegate, or invoke + delegate methods. +- `activeEntitlements` is the normalized override set and + `isEntitled(to:)` performs exact membership against it. +- `entitlements` is `nil`. The framework does not synthesize raw transactions + to make the override look like a verified StoreKit snapshot. +- `process(_:)`, `refreshEntitlements()`, `history(for:)`, and + `restorePurchases()` throw + `StoreTransactionError.operationUnavailableInOverride(operation:)` before + starting any work. +- `close()` is successful and idempotent even though no runtime work exists. + +The override initializer does not accept a delegate; accepting one that can +never receive a decision or notification would create a false contract. The app +owns whether a preview, internal build, TestFlight build, UI test, or another +environment uses this initializer. StoreTransactionKit does not inspect the +receipt or build configuration to make that decision. + +The catalog remains part of the initializer so the override uses the same +`Entitlement` domain as the live store and the app has one composition shape. +No Product ID is reverse-mapped from an entitlement: monthly and yearly +products may intentionally grant the same value, so such a reverse mapping is +not well-defined. + ## Atomic publication owner Raw and typed entitlement values describe one StoreKit query and must commit @@ -329,7 +532,7 @@ and before any of the following occur: - Completing a refresh receipt successfully. - Returning a `StoreEntitlements` result to a caller. -If a query or transaction handler fails before producing a verified candidate, +If a query or transaction delegate throws before producing a verified candidate, the previous complete snapshot remains current. A catalog failure is different: the verified candidate contradicts the old typed projection. The coordinator clears its complete publication, the observable store becomes `.failed`, and @@ -368,7 +571,7 @@ and background callers never write observable state themselves. `entitlementStatus`. Any internal reporting-owner wrapper remains available to receipts and reporting authority but does not leak into observable state. Diagnostic reporting is a separate ownership decision, so `didComplete` does -not call `reportFailure`. +not call `transactionStore(didFailWith:)`. Mapping in `TransactionStore.activeEntitlements` after raw publication would violate atomicity and is not part of the design. @@ -385,6 +588,7 @@ private enum EntitlementAvailability { entitlements: StoreEntitlements, activeEntitlements: Set ) + case overridden(activeEntitlements: Set) } ``` @@ -400,35 +604,38 @@ The public meaning is: | `.loading` | `nil` | `nil` | No readiness attempt has completed. | | `.failed(error)` | `nil` | `nil` | No usable complete snapshot exists; inspect `error` for the reason. | | `.ready` | non-`nil` | non-`nil` | A complete raw and typed snapshot is available. Empty values mean no entitlement. | +| `.overridden` | `nil` | non-`nil` | StoreKit is bypassed and the app-supplied typed set is authoritative. An empty set means no entitlement. | -`.loading` is only the initial state. The store does not return to it for later -refreshes. `.failed` means that no usable snapshot exists; it does not mean that -the most recent operation failed. +`.loading` is only the initial state of a live store. The store does not return +to it for later refreshes. `.failed` means that no usable live snapshot exists; +it does not mean that the most recent operation failed. `.overridden` is the +only state of an override store. State transitions are: | Event | Result | | --- | --- | -| Initialization | `.loading` with both projections `nil`. | +| Live initialization | `.loading` with both projections `nil`. | +| Override initialization | `.overridden` with raw `entitlements == nil` and the normalized typed set. | | Any successful candidate | `.ready` with the new atomic snapshot. | -| Query or handler failure while `.loading` or `.failed` | `.failed(error)` with both projections `nil`. | -| Query or handler failure after `.ready` | Preserve the previous `.ready` snapshot. This includes a late startup failure after another refresh has already succeeded. | +| Query or thrown delegate error while `.loading` or `.failed` | `.failed(error)` with both projections `nil`. | +| Query or thrown delegate error after `.ready` | Preserve the previous `.ready` snapshot. This includes a late startup failure after another refresh has already succeeded. | | Catalog failure in a verified candidate | `.failed(error)` with both projections `nil`, even after `.ready`; stale typed access is invalidated. | | Successful empty query | `.ready`; both collections are empty, not `nil`. | | Unverified current-entitlement element | Omit and report that element; publish the verified remainder if the query otherwise succeeds. | -| Close | Preserve the last entitlement state; lifecycle errors are reported by operations, not by `EntitlementStatus`. | +| Close | Preserve the last entitlement state; lifecycle errors are reported by operations, not by `EntitlementStatus`. Closing an override is an idempotent success. | The current `startupError` property is removed. Its readiness role moves to `entitlementStatus`, while operational diagnostics continue through thrown -errors and `reportFailure`. +errors and `transactionStore(didFailWith:)`. SwiftUI calls `isEntitled(to:)` directly. It does not copy the set or status into `@State`, and normal app content does not wait for readiness. The method returns -`true` only when the current ready snapshot contains the requested entitlement; -it returns `false` while loading, after a readiness failure, and when a ready set -does not contain the value. Code that needs to distinguish those reasons reads -`entitlementStatus`. `activeEntitlements` remains available for consumers that -need the complete typed set. +`true` when a ready or overridden set contains the requested entitlement. It +returns `false` while loading, after a readiness failure, and when the available +set does not contain the value. Code that needs to distinguish those reasons or +identify the source reads `entitlementStatus`. `activeEntitlements` remains +available for consumers that need the complete typed set. This is exact set membership. It does not infer that StoreKit group level 1 contains level 2, or that one app entitlement includes another. If multiple plan @@ -441,16 +648,17 @@ translation. Failure delivery depends on ownership of the operation, not only on the error type: -| Failure | Observable state | Direct caller | Background diagnostics | +| Failure | Observable state | Direct caller | Background notification | | --- | --- | --- | --- | | Invalid source-defined catalog | Store is not created | None | `precondition` failure | -| Startup query or handler failure | Become `.failed` if no snapshot exists; otherwise preserve `.ready` | No startup caller | Report once when no other physical-work owner already reports it | -| Startup catalog failure | `.failed(error)` and invalidate any previous projection | No startup caller | Report once when no other physical-work owner already reports it | -| Explicit query or handler failure | Become or remain `.failed` without a snapshot; otherwise preserve `.ready` | Throw underlying error | Do not duplicate while a caller owns it | +| StoreKit operation requested from an override store | Preserve `.overridden` | Throw `operationUnavailableInOverride(operation:)` | None | +| Startup query or thrown delegate error | Become `.failed` if no snapshot exists; otherwise preserve `.ready` | No startup caller | Notify once when no other physical-work owner already reports it | +| Startup catalog failure | `.failed(error)` and invalidate any previous projection | No startup caller | Notify once when no other physical-work owner already reports it | +| Explicit query or thrown delegate error | Become or remain `.failed` without a snapshot; otherwise preserve `.ready` | Throw underlying error | Do not duplicate while a caller owns it | | Explicit catalog failure | `.failed(error)` and invalidate any previous projection | Throw underlying error | Do not duplicate while a caller owns it | -| Background query or handler failure after `.ready` | Preserve `.ready` snapshot | None | Report once through `reportFailure` | -| Background catalog failure | `.failed(error)` and invalidate any previous projection | None | Report once through `reportFailure` | -| Current-entitlement verification failure for one element | Publish verified remainder | Attached operation may still succeed | Report omitted element once | +| Background query or thrown delegate error after `.ready` | Preserve `.ready` snapshot | None | Notify once through `transactionStore(didFailWith:)` | +| Background catalog failure | `.failed(error)` and invalidate any previous projection | None | Notify once through `transactionStore(didFailWith:)` | +| Current-entitlement verification failure for one element | Publish verified remainder | Attached operation may still succeed | Notify once for the omitted element | A catalog projection error participates in the same physical-work ownership and coalescing rules as a StoreKit query error, but its observable-state transition @@ -486,8 +694,9 @@ completion still occurs exactly once through `didComplete`. their intended service period; app-owned expiry policy is outside this catalog. - Consumables never appear in `Transaction.currentEntitlements`. They still pass - through the durable `handleTransaction` path so the app can update its owned - balance before the transaction is finished. + through the transaction decision path. An app that owns a consumable balance + supplies a delegate and returns `.finish` only after updating that balance + durably. If a concrete consumer later needs typed non-consumable access, it requires a separate design. It must not be represented as a member of @@ -498,16 +707,22 @@ separate design. It must not be represented as a member of | Responsibility | Owner | | --- | --- | | Group ID, Product ID cases, and Product ID to app-entitlement mapping | App-defined `SubscriptionGroup` conformance | +| Choosing whether a particular app composition bypasses StoreKit | App composition root | | Normalized lookup, managed-group membership, and catalog validation | `SubscriptionCatalog` | +| Normalizing and publishing a fixed override set | `TransactionStore` override initializer and availability reducer | | StoreKit query and unfinished-transaction reconciliation | `CurrentEntitlementReconciler` | +| Exact-revision admission, causal decision receipts, and policy completion | Transaction processing coordinator | | Candidate projection, atomic publication, refresh coalescing, ordered completion, and receipt completion | Generic entitlement refresh coordinator | | Observable availability reducer and process-lifetime facade | `TransactionStore` | -| Direct/background reporting authority and exactly-once diagnostic delivery | Runtime pipeline and failure reporter dispatcher | -| Durable business effect and idempotency across launches | App transaction handler | +| Direct/background reporting authority and exactly-once diagnostic delivery | Runtime pipeline and failure notification dispatcher | +| Optional durable business effect, idempotency, and finish policy | App `TransactionStoreDelegate`, or the package-owned `.finish` delegate when omitted | | Product merchandising and subscription status presentation | App using StoreKit directly | +| Synthetic transaction source, command admission, and action acknowledgement in tests | `TransactionStoreTestHarness` in `StoreTransactionKitTesting` | +| Time policy in app tests | The app component that performs the timed work, through an injected `Clock` | +| Virtual time and sleeper-registration barriers | `TransactionStoreTestClock` in `StoreTransactionKitTesting` | No UI type owns semantic entitlement state. No second mapping is performed in a -view, callback, or computed property outside the catalog owner. +view, delegate method, or computed property outside the catalog owner. ## Lifecycle and concurrency @@ -519,11 +734,247 @@ view, callback, or computed property outside the catalog owner. `Hashable & Sendable`. - Group types and typed Product IDs are consumed synchronously during catalog construction and do not cross concurrency boundaries. -- The store starts monitoring during initialization and retains the catalog for - every entitlement projection. -- Existing transaction handler, failure reporter, reentrancy, and `close()` - contracts remain unchanged except for the readiness reporting described - above. +- A live store starts monitoring during initialization and retains the catalog + for every entitlement projection. +- A live store owns one delegate for lifecycle completion: either the + package-owned `.finish` implementation or a strongly retained app value. + Transaction decisions are serialized. Failure notifications are also + serialized, but decision and notification delivery are independent and may + overlap. +- An override store starts no asynchronous task. Its normalized entitlement set + is immutable for the store's lifetime, and `close()` is idempotent. +- `close()` stops new admission and waits for every admitted delegate decision + and failure notification to return. Reentrant store operations from either + delegate method fail with `reentrantOperation(operation:)`. + +## Deterministic consumer testing + +The package adds a second public SwiftPM product with a one-way dependency: + +```text +StoreTransactionKitTesting + ↓ +StoreTransactionKit +``` + +Production targets import only `StoreTransactionKit`. App test targets import +`StoreTransactionKitTesting`, which builds a real `TransactionStore` around a +package-scoped synthetic StoreKit source. The production module owns the +transaction pipeline, catalog projection, availability reducer, and public +store type; the testing module does not reimplement any of them. + +The initial testing surface is: + +```swift +public final class TransactionStoreTestClock: Clock, Sendable { + public typealias Duration = Swift.Duration + + public struct Instant: InstantProtocol, Sendable { + public typealias Duration = Swift.Duration + + public static let zero: Instant + + public func advanced(by duration: Duration) -> Instant + public func duration(to other: Instant) -> Duration + + public static func < (lhs: Instant, rhs: Instant) -> Bool + } + + public var now: Instant { get } + public var minimumResolution: Duration { get } + + public init(now: Instant = .zero) + + public func sleep( + until deadline: Instant, + tolerance: Duration? + ) async throws + + public func advance(by duration: Duration) + + public func waitUntilPendingSleepCount( + reaches count: Int + ) async throws +} + +@MainActor +public final class TransactionStoreTestHarness +where Entitlement: Hashable & Sendable { + public let store: TransactionStore + public private(set) var reportedFailures: + [StoreTransactionBackgroundFailure] { get } + + public init( + subscriptionCatalog: SubscriptionCatalog, + delegate: (any TransactionStoreDelegate)? = nil + ) async throws + + @discardableResult + public func purchase( + _ productID: Group.ProductID, + in groupType: Group.Type + ) async throws -> StorePurchaseOutcome + where Group: SubscriptionGroup + + public func close() async throws +} +``` + +The harness does not accept a Clock. None of the production work it drives owns +a delay, deadline, retry interval, or other time policy, so injecting a Clock +there would be unused ceremony. When `delegate` is omitted, the common +no-subscription → purchase → entitled test uses the package-owned `.finish` +policy and relies on the command's causal receipt. Supplying a delegate +exercises an app's real durable effect and policy decision. + +`TransactionStoreTestClock` is a separate testing primitive for the component +that actually owns a time dependency, such as an app transaction delegate or +ViewModel. That component accepts `any Clock` using Swift's +primary-associated-type syntax. The test retains the concrete clock so it can +observe registered sleepers and advance virtual time. The clock uses +synchronized checked storage, such as `Synchronization.Mutex`; its synchronous +`Clock` requirements are not actor-isolated. Its independent virtual `Instant` +cannot be mixed with a `ContinuousClock.Instant` deadline. + +The test harness consumes the nested typed Product ID and the group type. It +validates that the group is present in the supplied catalog, then uses the +catalog's normalized raw Product ID and group ID. It never accepts an +`Entitlement` as a purchase command because mapping an entitlement back to one +monthly or yearly Product ID is not defined. Direct entitlement sets belong to +the fixed override initializer, not the full-pipeline harness. + +Initialization completes an empty current-entitlement query before returning. +The initial public state is therefore `.ready` with empty raw and typed +collections, not a racing `.loading` state. A test that needs to inspect loading +or failure transitions uses a lower-level package contract test rather than +adding timing hooks to app code. + +The initial public command surface contains only `purchase`. Expiration and +revocation are not aliases for removing a Product ID from the fake current set: +a natural expiration is a status/current-entitlement transition, while a +revocation is a revised durable transaction delivery. Each needs an explicit +transaction-identity, delegate-policy, finish, and missing-active-transaction +contract before it can become public. + +### Causal action acknowledgement + +Every mutating harness method is its own completion receipt. For example, +`purchase(_:,in:)` returns only after all work caused by that command has +completed: + +1. The synthetic transaction is admitted to the source. +2. The command attaches a direct-operation receipt and reporting authority to + the production runtime rather than yielding through the background update + stream. +3. The transaction delegate returns a normal policy; throwing terminates the + command through the failure-routing contract. +4. A `.finish` decision acknowledges the synthetic transaction; a + `.keepUnfinished` decision leaves it available to a later attempt. +5. Current entitlements are queried and reconciled without deciding the same + exact revision again in this causal attempt. +6. The subscription catalog validates and projects the candidate. +7. `TransactionStore` commits the resulting availability on `@MainActor`. + +The command returns `.completed(transaction)` for `.finish` and +`.keptUnfinished(transaction)` for `.keepUnfinished`. Both outcomes are +returned after MainActor publication, so a ViewModel property computed directly +from `store.isEntitled(to:)` can be read immediately. A thrown delegate error +instead fails the command and produces neither outcome. The receipt does not +guarantee a SwiftUI render pass or completion of an unstructured consumer `Task` +launched by an observation callback; that work needs its own owner-provided +acknowledgement. + +The harness does not expose “wait until globally idle.” StoreKit-style monitors +are intentionally long-lived, so process-wide quiescence is not a meaningful +state. A future batch API may expose a cutoff receipt for commands admitted +before a sequence number, but it must not define completion as all producer +tasks exiting. + +### Clock contract + +The Clock controls a real time-dependent suspension in the code under test; it +does not manufacture completion for an otherwise immediate harness command. +For example, an app can inject `any Clock` into its transaction +delegate. The test supplies `TransactionStoreTestClock`, retains the concrete +value, and synchronizes advancement with its explicit registration barrier: + +```swift +final class DelayedTransactionDelegate: TransactionStoreDelegate { + private let clock: any Clock + + init(clock: any Clock) { + self.clock = clock + } + + func transactionStore( + decidePolicyFor transaction: StoreTransactionSnapshot + ) async throws -> StoreTransactionHandlingPolicy { + try await clock.sleep(for: .seconds(30)) + return .finish + } +} + +let clock = TransactionStoreTestClock() +let delegate = DelayedTransactionDelegate(clock: clock) +let harness = try await TransactionStoreTestHarness( + subscriptionCatalog: subscriptionCatalog, + delegate: delegate +) +let viewModel = NotesViewModel(store: harness.store) + +let purchase = Task { @MainActor in + try await harness.purchase( + .tier1_Monthly, + in: Plans.self + ) +} + +try await clock.waitUntilPendingSleepCount(reaches: 1) + +#expect(!viewModel.canExportPDF) + +clock.advance(by: .seconds(30)) +try await purchase.value + +#expect(viewModel.canExportPDF) +``` + +`waitUntilPendingSleepCount(reaches:)` returns when at least that many pending +sleeps have registered. This is the same boundary as waiting until a dependency +has reached its controlled suspension point before asserting intermediate +state. The implementation uses an awaitable continuation-backed barrier rather +than a fixed sleep or a guessed number of `Task.yield()` calls. Cancelling the +barrier throws `CancellationError`. + +Advancing the Clock only makes due sleepers runnable; `purchase.value` remains +the pipeline receipt. Negative clock advances and negative sleeper counts are +programmer errors and fail immediately. Cancelling a sleeping task removes its +sleeper and throws `CancellationError` according to the standard Clock +contract. + +### Harness lifecycle and coverage boundary + +`close()` stops command admission and drains already admitted work before +closing the underlying store. It is terminal and idempotent. Cancellation of +the task awaiting an already admitted command does not silently cancel durable +transaction decisions; the caller can still use `close()` to establish terminal +completion. + +The harness captures background failures in `reportedFailures` and forwards +explicit command errors to the command caller without also appending them as a +background failure. To preserve that ownership rule, `StoreTransactionKit` +exposes a package-scoped Session/TransactionStore seam that stages the synthetic +current state and delegates the command to the runtime's attached direct +`process(_:leases:)` path. It does not use `StoreTransactionSource.runUpdates` +for explicit commands. The harness's forwarding delegate captures background +notifications and, when supplied, forwards decisions and notifications to the +app delegate. No public raw transaction-source protocol or fake +`TransactionStore` is required. + +This layer proves the app catalog, StoreTransactionKit pipeline, and consumer +state integration. The app-hosted `.storekit` suite remains the owner of the +live StoreKit adapter, verification results, StoreKit Test session behavior, +and system integration. ## Required contract tests @@ -551,13 +1002,21 @@ view, callback, or computed property outside the catalog owner. ### State-owner tests - Initial state is `.loading` with both projections `nil`. +- Override initialization publishes `.overridden`, leaves raw `entitlements` + `nil`, and normalizes duplicate input values into one typed set. +- An empty override sequence publishes `.overridden` with an empty, non-`nil` + `activeEntitlements` set. +- Override membership queries return exact set membership. +- Every StoreKit-specific operation on an override store throws + `operationUnavailableInOverride(operation:)` without changing state or + invoking a delegate method; repeated `close()` calls succeed. - A successful empty query produces `.ready` and two empty collections. -- Startup query, handler, and catalog failures produce `.failed` without a - partial candidate snapshot when no earlier query has succeeded. +- Startup query, thrown delegate, and catalog failures produce `.failed` + without a partial candidate snapshot when no earlier query has succeeded. - A later success recovers `.failed` to `.ready` atomically. - A late startup query failure after a background success preserves `.ready`. -- Explicit and background query or handler failures after `.ready` preserve the - previous raw and typed snapshot. +- Explicit and background query or thrown delegate errors after `.ready` + preserve the previous raw and typed snapshot. - A verified known-tier to unknown-tier change produces `.failed`, clears both public projections, and makes `isEntitled(to:)` return `false`. - A coalesced catalog failure does not publish a partial raw or typed snapshot. @@ -565,11 +1024,24 @@ view, callback, or computed property outside the catalog owner. table. - `withObservationTracking` observes `isEntitled(to:)` through the private availability value. -- `isEntitled(to:)` returns `false` for `.loading`, `.failed`, and a ready set - without the value, and `true` only for a matching ready entitlement. +- `isEntitled(to:)` returns `false` for `.loading`, `.failed`, and an available + set without the value, and `true` for a matching ready or overridden + entitlement. ### Coordination and reporting tests +- `.finish` is the only policy that calls StoreKit `finish()` and records an + exact revision in the process-lifetime completed set. +- `.keepUnfinished` calls neither `finish()` nor + `transactionStore(didFailWith:)`, returns `.keptUnfinished` to a direct + caller, and still completes the causal entitlement publication. +- A revision kept unfinished is decided at most once while its causal receipt is + active across direct, update, and unfinished delivery paths, including + coalesced reservations, and can be decided again only after that receipt + completes. +- A thrown decision error stops candidate publication, reaches an attached + direct caller without a duplicate notification, or reaches + `transactionStore(didFailWith:)` once for background-owned work. - Physical query completions reach the availability reducer once and in token order before attached receipts complete. A coalesced completion uses the last reservation token. @@ -581,6 +1053,10 @@ view, callback, or computed property outside the catalog owner. - Background-owner/startup-observer and startup-owner/background-observer failures each complete state and diagnostics once. - A physical failure is reported once if every direct caller abandons it. +- The store retains its delegate until lifecycle completion, and `close()` + drains admitted decisions and notifications. +- The default no-op `transactionStore(didFailWith:)` does not block the runtime + or alter transaction policy. ### Integration and distribution tests @@ -588,8 +1064,24 @@ view, callback, or computed property outside the catalog owner. to unknown-tier transition, restore, revocation, and recovery without fixed sleeps. - The external consumer fixture builds the README story using only public API. +- A second external fixture imports `StoreTransactionKitTesting`, starts from a + ready empty set, purchases a typed Product ID, and observes the ViewModel + change immediately after the command returns without a `.storekit` file. +- A harness purchase uses the attached direct-operation path: a thrown delegate + or catalog failure reaches the command caller and is not duplicated in + `reportedFailures`; `.completed` and `.keptUnfinished` return only after + MainActor publication. +- A time-dependent consumer dependency reaches a registered Clock sleeper, + exposes its intermediate state, advances virtual time, and still waits for + the harness command's MainActor publication receipt. The test contains no + fixed sleeps or guessed `Task.yield()` counts. +- Cancellation before command admission creates no transaction; cancellation + after admission still allows `close()` to establish terminal completion. +- The testing product cannot be imported transitively by a consumer that + depends only on the production product. - Swift 6 strict-concurrency builds prove the primary-associated-type and - `Sendable` surface. + `Clock` existential, class and actor delegate conformances, and `Sendable` + surfaces. - DocC builds without warnings after symbol documentation is added. ## Implementation transaction @@ -599,7 +1091,9 @@ following: - Public source and symbol documentation. - Unit and app-hosted StoreKit tests. -- The external consumer fixture. +- The `StoreTransactionKitTesting` product, its one-way target dependency, and + package-scoped production seams used by its synthetic source. +- Production and testing external consumer fixtures. - README and DocC examples. - Any dependent app and its resolved package revision. @@ -614,3 +1108,9 @@ is beta. - [`Product.SubscriptionInfo.subscriptionPeriod`](https://developer.apple.com/documentation/storekit/product/subscriptioninfo/subscriptionperiod) - [`Transaction.isUpgraded`](https://developer.apple.com/documentation/storekit/transaction/isupgraded) - [`Transaction.currentEntitlements`](https://developer.apple.com/documentation/storekit/transaction/currententitlements) +- [`WKNavigationDelegate`](https://developer.apple.com/documentation/webkit/wknavigationdelegate) +- [`WKNavigationResponsePolicy`](https://developer.apple.com/documentation/webkit/wknavigationresponsepolicy) +- [`Clock`](https://developer.apple.com/documentation/swift/clock) +- [SE-0329: Clock, Instant, and Duration](https://github.com/swiftlang/swift-evolution/blob/main/proposals/0329-clock-instant-duration.md) +- [Using Continuations and Clock for deterministic Swift concurrency tests](https://zenn.dev/kntk/articles/2e8d1925b0bb6b) +- [StoreKit 2 subscription implementation walkthrough](https://www.revenuecat.com/blog/engineering/ios-in-app-subscription-tutorial-with-storekit-2-and-swift-jp/) diff --git a/README.md b/README.md index 5e47765..919d210 100644 --- a/README.md +++ b/README.md @@ -20,10 +20,11 @@ The store owns the durable transaction path for the process lifetime: - Monitoring: `Transaction.updates`, `Transaction.unfinished` reconciliation, and subscription status changes -- Verification: only verified transactions reach your code; unverified - deliveries surface as thrown errors or reported failures -- Ordering: durable handling first, then `finish()`, with at-least-once - delivery to an idempotent handler and exact-revision deduplication +- Verification: only verified transactions reach policy code; direct failures + throw, and an optional delegate can receive background failures +- Ordering: verification, an optional app policy decision, then `finish()` only + when permitted. Finished revisions are deduplicated for the process lifetime, + while unfinished decisions are coalesced only through their causal attempt. - State: the observable current-entitlement projection, restore synchronization, background failure delivery, and explicit shutdown @@ -31,7 +32,7 @@ Your app owns everything the user sees and everything it persists: - Paywall and purchase UI (StoreKit views or the platform-appropriate StoreKit purchase action) -- The durable ledger that the transaction handler writes to +- Any app-specific durable ledger used by a transaction delegate - Subscription status presentation (`Product.SubscriptionInfo.Status`) - Purchases that begin outside the app on platforms that provide `PurchaseIntent.intents` @@ -75,53 +76,12 @@ enum Plans: SubscriptionGroup { } let subscriptionCatalog = SubscriptionCatalog(Plans.self) - -actor PurchaseLedger { - func apply(_ transaction: StoreTransactionSnapshot) async throws { - if let revocationDate = transaction.revocationDate { - try await database.revokePurchase( - transactionID: transaction.id, - productID: transaction.productID, - signedDate: transaction.signedDate, - revocationDate: revocationDate - ) - } else { - try await database.commitPurchase( - transactionID: transaction.id, - productID: transaction.productID, - signedDate: transaction.signedDate - ) - } - } -} - -actor StoreDiagnostics { - func record(_ failure: StoreTransactionBackgroundFailure) { - logger.error("StoreKit background failure: \(failure.underlyingError)") - } -} - -@MainActor -func makeStore( - ledger: PurchaseLedger, - diagnostics: StoreDiagnostics -) -> TransactionStore { - TransactionStore( - subscriptionCatalog: subscriptionCatalog, - handleTransaction: { transaction in - try await ledger.apply(transaction) - }, - reportFailure: { failure in - await diagnostics.record(failure) - } - ) -} ``` `TransactionStore` is `@MainActor` and `@Observable`. It starts monitoring -during initialization. Create the app-owned dependencies once at the -process-lifetime composition root, retain one store with SwiftUI state, and -inject that same instance into the environment: +during initialization. Create one store at the process-lifetime composition +root, retain it with SwiftUI state, and inject that same instance into the +environment: ```swift import StoreTransactionKit @@ -132,12 +92,9 @@ struct ExampleApp: App { @State private var store: TransactionStore init() { - let ledger = PurchaseLedger() - let diagnostics = StoreDiagnostics() _store = State( - initialValue: makeStore( - ledger: ledger, - diagnostics: diagnostics + initialValue: TransactionStore( + subscriptionCatalog: subscriptionCatalog ) ) } @@ -215,67 +172,113 @@ monitors. If you add a non-`nil` completion action, it replaces that default *and* StoreKit's failure alert — pass each `.success` value to `store.process(_:)` and own `.failure` presentation yourself. -## The callback contracts +## Override entitlements -`handleTransaction` owns the app's durable transaction correctness: +For previews, internal builds, or other app-defined environments that should +bypass StoreKit, provide the exact app entitlements to enable: + +```swift +let store = TransactionStore( + subscriptionCatalog: subscriptionCatalog, + overridingEntitlements: [ + SubscriptionEntitlement.tier1, + .tier2, + ] +) +``` + +## Transaction delegate + +Without a delegate, the store uses its built-in `.finish` policy for verified +transactions. Supply a delegate when the app must apply its own durable effect +or receive background failure notifications: + +```swift +final class AppTransactionDelegate: TransactionStoreDelegate { + func transactionStore( + decidePolicyFor transaction: StoreTransactionSnapshot + ) async throws -> StoreTransactionHandlingPolicy { + try await persist(transaction) + return .finish + } + + func transactionStore( + didFailWith failure: StoreTransactionBackgroundFailure + ) async { + await record(failure) + } +} + +let store = TransactionStore( + subscriptionCatalog: subscriptionCatalog, + delegate: AppTransactionDelegate() +) +``` + +`transactionStore(decidePolicyFor:)` owns the app's durable transaction +correctness: - **Be idempotent.** Delivery is at least once; key the ledger on transaction identity plus the business event it applies. - **Treat purchase and revocation as distinct events.** A refund or family-sharing revocation arrives as the same transaction with `revocationDate` set; transaction ID alone is not a sufficient key. -- **Return only after the business effect is durable.** The store calls - `finish()` after the handler returns. Throwing keeps the transaction - unfinished, and a later refresh retries it. -- **Never call back into the same store** from `handleTransaction` or - `reportFailure`, even through an awaited detached task — doing so creates a - dependency cycle with the work being handled. +- **Return `.finish` only after the business effect is durable.** The store then + calls `finish()`. Return `.keepUnfinished` only after deliberately choosing + to leave the transaction eligible for a later attempt; throw when neither + decision can be completed. +- **Never call back into the same store** from either delegate method, even + through an awaited detached task — doing so creates a dependency cycle with + the work being handled. -`reportFailure` is also a liveness boundary. StoreTransactionKit delivers -admitted failures serially with backpressure and waits for each callback to -return; `close()` waits for those callbacks too. Record or enqueue the failure -promptly instead of performing work that can wait indefinitely. +`transactionStore(didFailWith:)` is an optional notification with a default +no-op implementation. Its return cannot change the transaction decision. -Both callback contracts are documented on `TransactionStore.init`. +The full decision, redelivery, and failure-routing contracts are documented in +the [API design](Docs/SubscriptionCatalogAPI.md). ## How entitlement availability behaves -- `entitlementStatus` is `.loading` before the first readiness result, +- A live store reports `.loading` before the first readiness result, `.failed(error)` when no usable catalog projection is available, and `.ready` - when raw and typed entitlement state is available. + when raw and typed entitlement state is available. A store created with + `overridingEntitlements` reports `.overridden` immediately. - `activeEntitlements` is `nil` while `entitlementStatus` is `.loading` or - `.failed`. When the status is `.ready`, an empty set means no catalog - entitlement is active. + `.failed`. It is non-`nil` for `.ready` and `.overridden`; an empty set means + no app entitlement is active. +- `entitlements` contains a verified StoreKit snapshot only for `.ready`. It is + `nil` in override mode because an override does not invent StoreKit + transactions. - Gate paid features with `isEntitled(to:)` without blocking the surrounding UI. - Consult `entitlementStatus` only when the app needs to explain why the - entitlement set is unavailable. + The query checks exact set membership in both `.ready` and `.overridden`. + Consult `entitlementStatus` only when the app needs to explain where the + entitlement set came from or why it is unavailable. - A successful refresh after `.failed` publishes `.ready` and the new active - entitlement set. A background query or transaction-handler failure after - `.ready` preserves the last active set and reports the failure through - `reportFailure`. + entitlement set. A background query or thrown delegate error after `.ready` + preserves the last active set and reports the failure through + `transactionStore(didFailWith:)`. - A verified catalog mismatch fails closed: it changes the status to `.failed` and clears both entitlement projections instead of preserving stale access. - Startup and every refresh reconcile `Transaction.unfinished` — including - consumables — before publishing state. A handler failure fails that refresh; - the next refresh retries the unfinished work. + consumables — before publishing state. A thrown delegate error fails that + refresh; the next refresh retries the unfinished work. - Transactions superseded by a subscription upgrade stay in `entitlements` but don't appear in `activeEntitlements`. - Unverified current-entitlement elements are omitted and reported to - `reportFailure` with source `.currentEntitlementVerification`. + `transactionStore(didFailWith:)` with source + `.currentEntitlementVerification`. - Product IDs mapped to the same app entitlement appear as the same typed value. - `SubscriptionCatalog` maps auto-renewable subscriptions only. Other product types don't belong to subscription groups; consumables remain part of transaction handling and never appear in current entitlements. -For the full delivery, reconciliation, and failure-reporting model, see -[Understanding transaction handling][understanding]. - ## Beyond the basics - **Custom purchase UI** — load products and start the purchase with StoreKit views, SwiftUI's `PurchaseAction`, or the platform-appropriate `Product` purchase API. Pass the resulting `Product.PurchaseResult` to - `store.process(_:)`; `.pending` outcomes arrive later through the handler. + `store.process(_:)`; after `.pending`, a later completion may arrive through + transaction monitoring and the delegate decision path. - **Restore** — call `store.restorePurchases()` only from an explicit user action; `AppStore.sync()` presents authentication UI, and `StoreKitError.userCancelled` is a normal outcome, not a diagnostic failure. @@ -296,20 +299,53 @@ explains which purchase entry point to use for each UI framework and platform. ## API design -See [Subscription catalog API design](Docs/SubscriptionCatalogAPI.md) for the +See [StoreTransactionKit API design](Docs/SubscriptionCatalogAPI.md) for the proposed public interface, validation rules, ownership boundaries, and state transition contract behind the Quick start. ## Testing -The app-hosted StoreKit integration suite runs with `xcodebuild`. See -[Tools/TestApp/README.md](Tools/TestApp/README.md) for the scenarios and -command. +App and ViewModel tests can use `StoreTransactionKitTesting` without creating a +`.storekit` configuration: + +```swift +import StoreTransactionKitTesting +import Testing + +@Test +@MainActor +func subscriptionUpdatesViewModel() async throws { + let harness = try await TransactionStoreTestHarness( + subscriptionCatalog: subscriptionCatalog + ) + let viewModel = NotesViewModel(store: harness.store) + + #expect(!viewModel.canExportPDF) + + try await harness.purchase( + .tier1_Monthly, + in: Plans.self + ) + + #expect(viewModel.canExportPDF) + + try await harness.close() +} +``` + +`purchase(_:,in:)` returns after the policy decision, reconciliation, catalog +projection, and the `@MainActor` store publication it caused, so this test does +not need a Clock. Time-driven scenarios inject `TransactionStoreTestClock` into +the app component that owns the delay or deadline. + +The harness tests app state and the StoreTransactionKit pipeline. The separate +app-hosted StoreKit integration suite continues to test the live StoreKit +adapter with `xcodebuild` and a shared configuration. See +[Tools/TestApp/README.md](Tools/TestApp/README.md) for its scenarios and command. ## License StoreTransactionKit is available under the MIT License. -[understanding]: https://lynnswap.github.io/StoreTransactionKit/documentation/storetransactionkit/understandingtransactionhandling [subscription-setup]: https://developer.apple.com/help/app-store-connect/manage-subscriptions/offer-auto-renewable-subscriptions [storekit-testing]: https://developer.apple.com/documentation/xcode/setting-up-storekit-testing-in-xcode From 345fee2dc571d43697a8dad027d7f7ab4eceae22 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Sun, 19 Jul 2026 14:01:37 +0900 Subject: [PATCH 07/26] docs(api): scope catalog to auto-renewable subscriptions --- ...=> AutoRenewableSubscriptionCatalogAPI.md} | 406 +++++++++++------- README.md | 94 ++-- 2 files changed, 316 insertions(+), 184 deletions(-) rename Docs/{SubscriptionCatalogAPI.md => AutoRenewableSubscriptionCatalogAPI.md} (72%) diff --git a/Docs/SubscriptionCatalogAPI.md b/Docs/AutoRenewableSubscriptionCatalogAPI.md similarity index 72% rename from Docs/SubscriptionCatalogAPI.md rename to Docs/AutoRenewableSubscriptionCatalogAPI.md index 77ddac4..d3810ad 100644 --- a/Docs/SubscriptionCatalogAPI.md +++ b/Docs/AutoRenewableSubscriptionCatalogAPI.md @@ -1,4 +1,4 @@ -# Subscription catalog, delegate, override, and testing API design +# Auto-renewable subscription catalog, delegate, override, and testing API design Status: Proposed for the next beta API. The README shows this design, but the source implementation and symbol documentation do not provide it yet. @@ -32,8 +32,9 @@ map into one app entitlement type. - Keep the surrounding app UI usable while entitlement state is unavailable. - Support one group in the common case and explicit composition for independent groups. -- Require an explicit `.finish` policy decision before StoreTransactionKit - finishes a verified transaction. +- Finish automatically only when a transaction is a validated auto-renewable + subscription managed by the catalog; require an app decision for every other + product. - Separate the finish decision from optional background-failure notification. - Let the app construct a fixed entitlement override without environment detection inside the framework. @@ -88,7 +89,7 @@ enum SubscriptionEntitlement: Hashable, Sendable { case tier2 } -enum Plans: SubscriptionGroup { +enum Plans: AutoRenewableSubscriptionGroup { static let id = SubscriptionGroupID( rawValue: "YOUR_SUBSCRIPTION_GROUP_ID" ) @@ -113,7 +114,7 @@ enum Plans: SubscriptionGroup { } } -let subscriptionCatalog = SubscriptionCatalog(Plans.self) +let subscriptionCatalog = AutoRenewableSubscriptionCatalog(Plans.self) let store = TransactionStore( subscriptionCatalog: subscriptionCatalog @@ -142,7 +143,7 @@ An app with independent groups composes them without erasing their nested Product ID types: ```swift -let subscriptionCatalog = SubscriptionCatalog(Plans.self) +let subscriptionCatalog = AutoRenewableSubscriptionCatalog(Plans.self) .including(ChannelSubscriptions.self) ``` @@ -159,7 +160,7 @@ public struct SubscriptionGroupID: public init(rawValue: String) } -public protocol SubscriptionGroup { +public protocol AutoRenewableSubscriptionGroup { associatedtype Entitlement: Hashable & Sendable associatedtype ProductID: RawRepresentable & CaseIterable @@ -171,28 +172,28 @@ public protocol SubscriptionGroup { ) -> Entitlement } -public struct SubscriptionCatalog: Sendable +public struct AutoRenewableSubscriptionCatalog: Sendable where Entitlement: Hashable & Sendable { public init(_ groupType: Group.Type) - where Group: SubscriptionGroup + where Group: AutoRenewableSubscriptionGroup public func including( _ groupType: Group.Type - ) -> SubscriptionCatalog - where Group: SubscriptionGroup + ) -> Self + where Group: AutoRenewableSubscriptionGroup } -public enum SubscriptionCatalogError: LocalizedError, Sendable { +public enum AutoRenewableSubscriptionCatalogError: LocalizedError, Sendable { case unknownProduct( - productID: String, + productID: Product.ID, subscriptionGroupID: SubscriptionGroupID ) case productTypeMismatch( - productID: String, + productID: Product.ID, actual: Product.ProductType ) case subscriptionGroupMismatch( - productID: String, + productID: Product.ID, expected: SubscriptionGroupID, actual: String? ) @@ -219,11 +220,16 @@ public enum StoreTransactionError: Error, Sendable, Hashable { case closing case closed case unknownPurchaseResult + case unhandledTransaction( + productID: Product.ID, + productType: Product.ProductType + ) case reentrantOperation(operation: StoreTransactionOperation) case operationUnavailableInOverride(operation: StoreTransactionOperation) } -public enum StoreTransactionHandlingPolicy: Sendable { +public enum StoreTransactionHandlingPolicy: Sendable, Hashable { + case automatic case finish case keepUnfinished } @@ -239,6 +245,12 @@ public protocol TransactionStoreDelegate: AnyObject, Sendable { } public extension TransactionStoreDelegate { + func transactionStore( + decidePolicyFor transaction: StoreTransactionSnapshot + ) async throws -> StoreTransactionHandlingPolicy { + .automatic + } + func transactionStore( didFailWith failure: StoreTransactionBackgroundFailure ) async {} @@ -262,12 +274,12 @@ where Entitlement: Hashable & Sendable { public func isEntitled(to entitlement: Entitlement) -> Bool public init( - subscriptionCatalog: SubscriptionCatalog, + subscriptionCatalog: AutoRenewableSubscriptionCatalog, delegate: (any TransactionStoreDelegate)? = nil ) public init( - subscriptionCatalog: SubscriptionCatalog, + subscriptionCatalog: AutoRenewableSubscriptionCatalog, overridingEntitlements: some Sequence ) @@ -291,18 +303,9 @@ where Entitlement: Hashable & Sendable { ## Optional transaction delegate -Omitting `delegate` selects a package-owned implementation that returns -`.finish` for every verified transaction and performs no app-specific failure -notification. This is the common subscription-app path: StoreTransactionKit -still verifies, orders, finishes, reconciles, and publishes the transaction, but -the app has no additional durable business effect. - -Omitting the delegate is an initializer-level choice, not an empty delegate -method. Once the app supplies a delegate, the package-owned policy is replaced -and every verified transaction requires an explicit app decision. - -Supplying a delegate replaces that built-in policy. The store strongly retains -the supplied value until `close()` finishes or the store is deinitialized. +Omitting `delegate` uses `.automatic` handling and performs no app-specific +failure notification. The store strongly retains a supplied delegate until +`close()` finishes or the store is deinitialized. `TransactionStoreDelegate` follows the decision/notification split used by `WKNavigationDelegate`: a method that grants permission for a consequential @@ -311,11 +314,33 @@ already occurred returns no policy. The transaction method is also `throws` because failing to reach a decision is different from deliberately choosing a normal policy. -For an app-supplied delegate, `transactionStore(decidePolicyFor:)` has no -default implementation. Falling off the end of an empty method body can no -longer authorize `Transaction.finish()`; the app must return one of these -policies: - +Both methods have default implementations. A delegate interested only in +background diagnostics implements `transactionStore(didFailWith:)` and inherits +`.automatic` transaction handling. A delegate interested only in transaction +policy implements the decision method and inherits the no-op notification. + +Before applying a policy, the catalog classifies each verified transaction: + +- A **managed** transaction has a declared Product ID, `productType == + .autoRenewable`, and the declared subscription group ID. A superseded + transaction with `isUpgraded == true`, `.autoRenewable` type, and a managed + group is also managed for finishing even when its retired Product ID is no + longer declared; it cannot grant typed access. A Product ID that is still + declared must match its declaration in either case. +- An **invalid** transaction uses a declared Product ID with the wrong type or + group, or an undeclared non-upgraded Product ID inside a managed group. It + fails with `AutoRenewableSubscriptionCatalogError` before the delegate is + called and is never finished. +- An **unmanaged** transaction belongs outside the catalog: a consumable, + non-consumable, non-renewing subscription, or an auto-renewable subscription + in another group. + +The decision method is called for managed and unmanaged transactions. Its +policies mean: + +- `.automatic` is the safe default. It finishes a managed transaction and + throws `StoreTransactionError.unhandledTransaction` for an unmanaged one. It + is not an alias for unconditional `.finish`. - `.finish` means the app has durably applied this business event, or has established from its idempotency ledger that the event was already applied. StoreTransactionKit then calls `finish()`, records the exact transaction @@ -334,6 +359,13 @@ policies: background-owned work sends it to `transactionStore(didFailWith:)`. A later independent attempt can redeliver the transaction. +This keeps subscription-only apps concise without allowing the default path to +finish a consumable or another product that has no handling owner. An app that +also sells such products implements the decision method, persists the product's +business effect, and returns `.finish`. An app that only wants diagnostics can +implement the notification method alone; an unmanaged background transaction +then arrives there as an `unhandledTransaction` failure. + The transaction-processing coordinator owns one causal decision receipt for an exact revision from admission through completion of the refresh caused by that decision. Direct results, `Transaction.updates`, and @@ -343,14 +375,20 @@ the reconciler seeds its exact-revision exclusion set from every receipt in the physical refresh. The receipt completes only after the refresh succeeds or fails and all attached callers and reporting owners receive that result. -`.keepUnfinished` is not added to the process-lifetime completed set or the +`.keepUnfinished` is not added to the bounded completed-revision cache or the failed-attempt set. The causal receipt is discarded when its physical refresh completes, after which a non-coalesced update, status change, explicit refresh, restore, or startup attempt may present the exact revision again. StoreTransactionKit does not schedule a timer or backoff retry. Coalescing uses exact revision identity rather than transaction ID, so a later revocation or -another revised business event is not suppressed. The delegate must remain -idempotent for both normal policies. +another revised business event is not suppressed. Any app-owned effect performed +before returning a policy must remain idempotent. + +Completed revisions enter a bounded process-local cache. The cache prevents +nearby duplicate delivery from repeating policy work, but it is not a durable +business ledger and does not promise process-lifetime retention. Eviction may +allow an exact revision to be presented again, so an app delegate's durable +effect remains idempotent. `transactionStore(didFailWith:)` is an optional observation hook with a default no-op implementation. It cannot change a transaction decision, request a retry, @@ -358,11 +396,16 @@ or suppress a thrown error. Admitted notifications are delivered serially with backpressure, and `close()` waits for each invocation to return. Direct errors that reach an attached caller are not duplicated as background notifications. -A weak app delegate would allow the finish authority to disappear after -initialization. The delegate must not retain or call back into the same store, -directly or through an awaited child or detached task. The methods intentionally -omit a store parameter because such calls are unsupported and the delegate does -not depend on the store's generic entitlement type. +A weak app delegate would allow its policy and diagnostic receiver to disappear +after initialization. The store therefore retains its delegate strongly, and a +delegate that keeps a reference back to the store must make that reference weak. +The delegate must not start an admission-bearing operation on the same store — +`process(_:)`, `refreshEntitlements()`, `history(for:)`, `restorePurchases()`, or +`close()` — directly or through an awaited child or detached task. Read-only +entitlement inspection does not enter the transaction coordinator and is not a +`reentrantOperation`. The methods omit a store parameter because policy normally +depends on the supplied transaction, not on the store's generic entitlement +type. The protocol is class-bound and `Sendable`, but it is not actor-bound. A delegate that owns mutable state directly can be an actor. A checked-Sendable @@ -373,21 +416,21 @@ delivery ordering does not make an otherwise unsynchronized mutable class safe. than a free-form string. The new error case reuses it so an override-mode failure identifies the rejected operation without parsing text. -`SubscriptionGroup` is a client-conformance protocol because each app supplies -its own closed group definition. Its requirements remain intentionally small. -The protocol itself is not `Sendable`, and `ProductID` does not require -`Hashable` or `Sendable`: the catalog consumes `allCases` synchronously and -normalizes each case to its raw `String` during construction. No group instance, -group metatype, or typed Product ID is retained. +`AutoRenewableSubscriptionGroup` is a client-conformance protocol because each +app supplies its own closed group definition. Its requirements remain +intentionally small. The protocol itself is not `Sendable`, and `ProductID` does +not require `Hashable` or `Sendable`: the catalog consumes `allCases` +synchronously and normalizes each case to its raw `String` during construction. +No group instance, group metatype, or typed Product ID is retained. Adding a protocol requirement after 1.0 would break client conformances. Future optional metadata belongs in catalog initializers or configuration values, not -in a new `SubscriptionGroup` requirement. +in a new `AutoRenewableSubscriptionGroup` requirement. -`SubscriptionCatalog` is an immutable value. `including(_:)` returns another -catalog and leaves the receiver unchanged. This keeps the one-group use case to -one line while supporting the StoreKit case where independent subscriptions -must live in separate groups. +`AutoRenewableSubscriptionCatalog` is an immutable value. `including(_:)` +returns another catalog and leaves the receiver unchanged. This keeps the +one-group use case to one line while supporting the StoreKit case where +independent subscriptions must live in separate groups. ## Type-safety boundary @@ -406,6 +449,14 @@ The API provides compile-time safety for app-owned declarations: The compiler cannot validate App Store Connect. Runtime validation is therefore part of the catalog contract rather than a substitute source of truth. +The identifier remains `SubscriptionGroupID`, rather than +`AutoRenewableSubscriptionGroupID`, because it mirrors StoreKit's +`subscriptionGroupID` vocabulary and StoreKit subscription groups already imply +auto-renewable subscriptions. The longer qualifier belongs on the app-defined +group protocol and catalog, where it distinguishes their product scope. +Initializer labels remain `subscriptionCatalog:` because the argument's static +type already carries the `AutoRenewable` qualifier. + `SubscriptionGroupID.init(rawValue:)` preconditions that the raw value is not empty. The identifier type owns that invariant because the value is also useful outside the catalog, such as when passing `Plans.id.rawValue` to StoreKit UI. @@ -434,12 +485,12 @@ Duplicate entitlement values are valid. Monthly and yearly products at one access level are expected to produce the same entitlement, and independent groups may grant the same app entitlement. -`SubscriptionCatalogError.errorDescription` includes the Product ID and the -expected and actual metadata needed to diagnose App Store Connect drift. These -descriptions are developer diagnostics and are not end-user presentation copy. -The public cases remain distinct so diagnostics and contract tests can identify -whether the shipped catalog is missing a product, names the wrong product type, -or assigns a product to the wrong group. +`AutoRenewableSubscriptionCatalogError.errorDescription` includes the Product +ID and the expected and actual metadata needed to diagnose App Store Connect +drift. These descriptions are developer diagnostics and are not end-user +presentation copy. The public cases remain distinct so diagnostics and contract +tests can identify whether the shipped catalog is missing a product, names the +wrong product type, or assigns a product to the wrong group. Catalog construction performs no network request and does not load `Product` values. Product metadata is validated only when StoreKit supplies a verified @@ -455,10 +506,10 @@ catalog applies these rules before anything is published: require a current catalog entry. 2. A declared Product ID must have `productType == .autoRenewable`. 3. A declared Product ID must have the subscription group ID declared by its - `SubscriptionGroup`. + `AutoRenewableSubscriptionGroup`. 4. An undeclared Product ID whose transaction belongs to a managed group fails - with `SubscriptionCatalogError.unknownProduct` because the framework cannot - infer its app entitlement. + with `AutoRenewableSubscriptionCatalogError.unknownProduct` because the + framework cannot infer its app entitlement. 5. An undeclared product outside every managed group remains in raw `entitlements` and is ignored by the typed projection. 6. Successful mappings are collected into a `Set`, so multiple durations and @@ -532,12 +583,13 @@ and before any of the following occur: - Completing a refresh receipt successfully. - Returning a `StoreEntitlements` result to a caller. -If a query or transaction delegate throws before producing a verified candidate, -the previous complete snapshot remains current. A catalog failure is different: -the verified candidate contradicts the old typed projection. The coordinator -clears its complete publication, the observable store becomes `.failed`, and -both public projections become `nil`. Keeping the old typed set could continue -granting a higher tier after a user has moved to an unknown lower-tier product. +If a query or non-catalog transaction-handling error occurs before producing a +verified candidate, the previous complete snapshot remains current. A catalog +failure is different: the verified candidate contradicts the old typed +projection. The coordinator clears its complete publication, the observable +store becomes `.failed`, and both public projections become `nil`. Keeping the +old typed set could continue granting a higher tier after a user has moved to an +unknown lower-tier product. The coordinator reports every physical query batch to the observable state owner exactly once, before completing attached receipts: @@ -553,7 +605,7 @@ private enum EntitlementRefreshOutcome: Sendable where Entitlement: Hashable & Sendable { case success(EntitlementPublication) case transientFailure(any Error) - case catalogFailure(SubscriptionCatalogError) + case catalogFailure(AutoRenewableSubscriptionCatalogError) } didComplete( @@ -618,8 +670,8 @@ State transitions are: | Live initialization | `.loading` with both projections `nil`. | | Override initialization | `.overridden` with raw `entitlements == nil` and the normalized typed set. | | Any successful candidate | `.ready` with the new atomic snapshot. | -| Query or thrown delegate error while `.loading` or `.failed` | `.failed(error)` with both projections `nil`. | -| Query or thrown delegate error after `.ready` | Preserve the previous `.ready` snapshot. This includes a late startup failure after another refresh has already succeeded. | +| Query or non-catalog transaction-handling error while `.loading` or `.failed` | `.failed(error)` with both projections `nil`. | +| Query or non-catalog transaction-handling error after `.ready` | Preserve the previous `.ready` snapshot. This includes a late startup failure after another refresh has already succeeded. | | Catalog failure in a verified candidate | `.failed(error)` with both projections `nil`, even after `.ready`; stale typed access is invalidated. | | Successful empty query | `.ready`; both collections are empty, not `nil`. | | Unverified current-entitlement element | Omit and report that element; publish the verified remainder if the query otherwise succeeds. | @@ -652,11 +704,11 @@ type: | --- | --- | --- | --- | | Invalid source-defined catalog | Store is not created | None | `precondition` failure | | StoreKit operation requested from an override store | Preserve `.overridden` | Throw `operationUnavailableInOverride(operation:)` | None | -| Startup query or thrown delegate error | Become `.failed` if no snapshot exists; otherwise preserve `.ready` | No startup caller | Notify once when no other physical-work owner already reports it | +| Startup query or non-catalog transaction-handling error | Become `.failed` if no snapshot exists; otherwise preserve `.ready` | No startup caller | Notify once when no other physical-work owner already reports it | | Startup catalog failure | `.failed(error)` and invalidate any previous projection | No startup caller | Notify once when no other physical-work owner already reports it | -| Explicit query or thrown delegate error | Become or remain `.failed` without a snapshot; otherwise preserve `.ready` | Throw underlying error | Do not duplicate while a caller owns it | +| Explicit query or non-catalog transaction-handling error | Become or remain `.failed` without a snapshot; otherwise preserve `.ready` | Throw underlying error | Do not duplicate while a caller owns it | | Explicit catalog failure | `.failed(error)` and invalidate any previous projection | Throw underlying error | Do not duplicate while a caller owns it | -| Background query or thrown delegate error after `.ready` | Preserve `.ready` snapshot | None | Notify once through `transactionStore(didFailWith:)` | +| Background query or non-catalog transaction-handling error after `.ready` | Preserve `.ready` snapshot | None | Notify once through `transactionStore(didFailWith:)` | | Background catalog failure | `.failed(error)` and invalidate any previous projection | None | Notify once through `transactionStore(didFailWith:)` | | Current-entitlement verification failure for one element | Publish verified remainder | Attached operation may still succeed | Notify once for the omitted element | @@ -664,7 +716,7 @@ A catalog projection error participates in the same physical-work ownership and coalescing rules as a StoreKit query error, but its observable-state transition is intentionally fail-closed. Background-owned catalog failures use `StoreTransactionBackgroundFailure.Source.entitlementRefresh` with the public -`SubscriptionCatalogError` as `underlyingError`. +`AutoRenewableSubscriptionCatalogError` as `underlyingError`. Reservation role alone does not decide whether to report. Every startup, background, and direct reservation in one physical batch shares one reporting @@ -685,7 +737,7 @@ completion still occurs exactly once through `didComplete`. ## Product-type boundaries -`SubscriptionCatalog` is intentionally specific: +`AutoRenewableSubscriptionCatalog` is intentionally specific: - Auto-renewable subscriptions are mapped by group and Product ID. - Non-consumables may appear in raw `StoreEntitlements`, but this catalog does @@ -698,26 +750,33 @@ completion still occurs exactly once through `didComplete`. supplies a delegate and returns `.finish` only after updating that balance durably. +`TransactionStore` remains the single process-wide transaction monitor and +finish authority across product types. The product-specific catalog changes +typed projection and automatic handling; it does not create a second StoreKit +listener. `.automatic` fails for every transaction outside this catalog, so an +app must provide the handling owner before it can finish one. + If a concrete consumer later needs typed non-consumable access, it requires a separate design. It must not be represented as a member of -`SubscriptionGroup`, because StoreKit does not model it that way. +`AutoRenewableSubscriptionGroup`, because StoreKit does not model it that way. ## Ownership map | Responsibility | Owner | | --- | --- | -| Group ID, Product ID cases, and Product ID to app-entitlement mapping | App-defined `SubscriptionGroup` conformance | +| Group ID, Product ID cases, and Product ID to app-entitlement mapping | App-defined `AutoRenewableSubscriptionGroup` conformance | | Choosing whether a particular app composition bypasses StoreKit | App composition root | -| Normalized lookup, managed-group membership, and catalog validation | `SubscriptionCatalog` | +| Normalized lookup, managed-group membership, and catalog validation | `AutoRenewableSubscriptionCatalog` | | Normalizing and publishing a fixed override set | `TransactionStore` override initializer and availability reducer | | StoreKit query and unfinished-transaction reconciliation | `CurrentEntitlementReconciler` | | Exact-revision admission, causal decision receipts, and policy completion | Transaction processing coordinator | | Candidate projection, atomic publication, refresh coalescing, ordered completion, and receipt completion | Generic entitlement refresh coordinator | | Observable availability reducer and process-lifetime facade | `TransactionStore` | | Direct/background reporting authority and exactly-once diagnostic delivery | Runtime pipeline and failure notification dispatcher | -| Optional durable business effect, idempotency, and finish policy | App `TransactionStoreDelegate`, or the package-owned `.finish` delegate when omitted | +| Default handling for validated auto-renewable transactions | `AutoRenewableSubscriptionCatalog` classification and `.automatic` policy | +| Optional durable business effect, idempotency, and handling policy | App `TransactionStoreDelegate` | | Product merchandising and subscription status presentation | App using StoreKit directly | -| Synthetic transaction source, command admission, and action acknowledgement in tests | `TransactionStoreTestHarness` in `StoreTransactionKitTesting` | +| Synthetic source, command admission, action acknowledgement, and test lifecycle | `withTransactionStoreTestHarness` and its `TransactionStoreTestHarness` in `StoreTransactionKitTesting` | | Time policy in app tests | The app component that performs the timed work, through an injected `Clock` | | Virtual time and sleeper-registration barriers | `TransactionStoreTestClock` in `StoreTransactionKitTesting` | @@ -727,25 +786,35 @@ view, delegate method, or computed property outside the catalog owner. ## Lifecycle and concurrency - `TransactionStore` remains `@MainActor`, `@Observable`, and process-owned. -- `SubscriptionCatalog` is immutable and `Sendable` after normalization. Its - storage uses value semantics rather than shared mutable storage or - `@unchecked Sendable`. +- `AutoRenewableSubscriptionCatalog` is immutable and `Sendable` after + normalization. Its storage uses value semantics rather than shared mutable + storage or `@unchecked Sendable`. - App-defined `Entitlement` values cross concurrency boundaries and must be `Hashable & Sendable`. - Group types and typed Product IDs are consumed synchronously during catalog construction and do not cross concurrency boundaries. - A live store starts monitoring during initialization and retains the catalog for every entitlement projection. -- A live store owns one delegate for lifecycle completion: either the - package-owned `.finish` implementation or a strongly retained app value. - Transaction decisions are serialized. Failure notifications are also - serialized, but decision and notification delivery are independent and may - overlap. +- A live store strongly retains an app delegate when supplied. Without one, it + uses `.automatic` and has no app-specific failure receiver. Transaction + decisions are serialized. Failure notifications are also serialized, but + decision and notification delivery are independent and may overlap. - An override store starts no asynchronous task. Its normalized entitlement set is immutable for the store's lifetime, and `close()` is idempotent. - `close()` stops new admission and waits for every admitted delegate decision - and failure notification to return. Reentrant store operations from either - delegate method fail with `reentrantOperation(operation:)`. + and failure notification to return. A direct call, or a `Task {}` call, that + inherits the delegate's callback context and starts an admission-bearing store + operation fails with + `reentrantOperation(operation:)`. +- `Task.detached` does not inherit task-local callback context, so the framework + cannot identify that call as delegate-originated. Awaiting detached work that + calls the same store is still unsupported because it can create the same + dependency cycle. Actor isolation, `@isolated(any)`, `sending`, + `SendableMetatype`, and `@_inheritActorContext` do not encode parent-task + ancestry or make an instance method unavailable only to detached tasks. +- A process-wide “delegate callback is active” gate is not used to simulate that + provenance. It would also reject unrelated UI or lifecycle operations that + happen to overlap a suspended delegate callback. ## Deterministic consumer testing @@ -804,28 +873,46 @@ where Entitlement: Hashable & Sendable { public private(set) var reportedFailures: [StoreTransactionBackgroundFailure] { get } - public init( - subscriptionCatalog: SubscriptionCatalog, - delegate: (any TransactionStoreDelegate)? = nil - ) async throws - @discardableResult public func purchase( _ productID: Group.ProductID, in groupType: Group.Type ) async throws -> StorePurchaseOutcome - where Group: SubscriptionGroup - - public func close() async throws + where Group: AutoRenewableSubscriptionGroup } + +@MainActor +public func withTransactionStoreTestHarness( + subscriptionCatalog: AutoRenewableSubscriptionCatalog, + delegate: (any TransactionStoreDelegate)? = nil, + _ operation: @MainActor ( + TransactionStoreTestHarness + ) async throws -> Result +) async throws -> Result +where Entitlement: Hashable & Sendable ``` The harness does not accept a Clock. None of the production work it drives owns a delay, deadline, retry interval, or other time policy, so injecting a Clock there would be unused ceremony. When `delegate` is omitted, the common -no-subscription → purchase → entitled test uses the package-owned `.finish` -policy and relies on the command's causal receipt. Supplying a delegate -exercises an app's real durable effect and policy decision. +no-subscription → purchase → entitled test uses `.automatic`, which resolves to +finish for the catalog-validated synthetic subscription, and relies on the +command's causal receipt. Supplying a delegate exercises an app's real durable +effect and policy decision. + +`withTransactionStoreTestHarness` is the public construction and lifecycle +boundary. It initializes the harness, invokes `operation`, and drains and closes +framework-owned work before returning on success, failure, or cancellation. If +`operation` throws, its error is rethrown after cleanup. Harness construction +and mandatory final cleanup remain module-owned, so a test cannot forget +cleanup. A harness value retained beyond the closure is already closed. + +The module-owned cleanup path is nonthrowing and invokes cleanup from the scope +task rather than from a store delegate callback. A test may call the public +`harness.store.close()` earlier; final cleanup then relies on the store's +idempotent close contract. Public `TransactionStore.close()` keeps its +reentrancy error for general lifecycle owners; that error is not part of the +scoped testing API's final cleanup. `TransactionStoreTestClock` is a separate testing primitive for the component that actually owns a time dependency, such as an app transaction delegate or @@ -866,23 +953,24 @@ completed: 2. The command attaches a direct-operation receipt and reporting authority to the production runtime rather than yielding through the background update stream. -3. The transaction delegate returns a normal policy; throwing terminates the +3. The delegate or default resolver produces a policy; throwing terminates the command through the failure-routing contract. -4. A `.finish` decision acknowledges the synthetic transaction; a - `.keepUnfinished` decision leaves it available to a later attempt. +4. `.automatic` resolves from catalog classification. A resolved or explicit + `.finish` acknowledges the synthetic transaction; `.keepUnfinished` leaves it + available to a later attempt. 5. Current entitlements are queried and reconciled without deciding the same exact revision again in this causal attempt. 6. The subscription catalog validates and projects the candidate. 7. `TransactionStore` commits the resulting availability on `@MainActor`. -The command returns `.completed(transaction)` for `.finish` and -`.keptUnfinished(transaction)` for `.keepUnfinished`. Both outcomes are -returned after MainActor publication, so a ViewModel property computed directly -from `store.isEntitled(to:)` can be read immediately. A thrown delegate error -instead fails the command and produces neither outcome. The receipt does not -guarantee a SwiftUI render pass or completion of an unstructured consumer `Task` -launched by an observation callback; that work needs its own owner-provided -acknowledgement. +The command returns `.completed(transaction)` for resolved or explicit +`.finish`, and `.keptUnfinished(transaction)` for `.keepUnfinished`. Both +outcomes are returned after MainActor publication, so a ViewModel property +computed directly from `store.isEntitled(to:)` can be read immediately. A +thrown delegate or automatic-handling error instead fails the command and +produces neither outcome. The receipt does not guarantee a SwiftUI render pass +or completion of an unstructured consumer `Task` launched by an observation +callback; that work needs its own owner-provided acknowledgement. The harness does not expose “wait until globally idle.” StoreKit-style monitors are intentionally long-lived, so process-wide quiescence is not a meaningful @@ -916,27 +1004,28 @@ final class DelayedTransactionDelegate: TransactionStoreDelegate { let clock = TransactionStoreTestClock() let delegate = DelayedTransactionDelegate(clock: clock) -let harness = try await TransactionStoreTestHarness( +try await withTransactionStoreTestHarness( subscriptionCatalog: subscriptionCatalog, delegate: delegate -) -let viewModel = NotesViewModel(store: harness.store) - -let purchase = Task { @MainActor in - try await harness.purchase( - .tier1_Monthly, - in: Plans.self - ) -} +) { harness in + let viewModel = NotesViewModel(store: harness.store) + + let purchase = Task { @MainActor in + try await harness.purchase( + .tier1_Monthly, + in: Plans.self + ) + } -try await clock.waitUntilPendingSleepCount(reaches: 1) + try await clock.waitUntilPendingSleepCount(reaches: 1) -#expect(!viewModel.canExportPDF) + #expect(!viewModel.canExportPDF) -clock.advance(by: .seconds(30)) -try await purchase.value + clock.advance(by: .seconds(30)) + try await purchase.value -#expect(viewModel.canExportPDF) + #expect(viewModel.canExportPDF) +} ``` `waitUntilPendingSleepCount(reaches:)` returns when at least that many pending @@ -954,11 +1043,13 @@ contract. ### Harness lifecycle and coverage boundary -`close()` stops command admission and drains already admitted work before -closing the underlying store. It is terminal and idempotent. Cancellation of -the task awaiting an already admitted command does not silently cancel durable -transaction decisions; the caller can still use `close()` to establish terminal -completion. +The scoped function stops command admission and drains already admitted work +before closing the underlying store. Cancellation of the task awaiting an +already admitted command does not silently cancel durable transaction decisions; +scope cleanup still establishes terminal completion. The scope drains only +framework-owned work. A consumer task started inside `operation` remains owned +by that operation and must reach its own terminal state before the closure +returns. The harness captures background failures in `reportedFailures` and forwards explicit command errors to the command caller without also appending them as a @@ -987,11 +1078,11 @@ and system integration. - Empty Product IDs, empty groups, duplicate group IDs, and duplicate Product IDs fail during catalog construction. - Duplicate entitlement values remain valid. -- Each `SubscriptionCatalogError.errorDescription` identifies the Product ID and - relevant expected or actual metadata. +- Each `AutoRenewableSubscriptionCatalogError.errorDescription` identifies the + Product ID and relevant expected or actual metadata. - Known Product ID with a wrong product type fails projection. - Known Product ID with a wrong or missing group ID fails projection. -- Unknown Product ID inside a managed group fails projection. +- Unknown non-upgraded Product ID inside a managed group fails projection. - Unknown Product ID outside managed groups remains raw and is ignored by the typed set. - A catalog mismatch in one included group fails the complete composed @@ -1011,11 +1102,11 @@ and system integration. `operationUnavailableInOverride(operation:)` without changing state or invoking a delegate method; repeated `close()` calls succeed. - A successful empty query produces `.ready` and two empty collections. -- Startup query, thrown delegate, and catalog failures produce `.failed` +- Startup query, transaction-handling, and catalog failures produce `.failed` without a partial candidate snapshot when no earlier query has succeeded. - A later success recovers `.failed` to `.ready` atomically. - A late startup query failure after a background success preserves `.ready`. -- Explicit and background query or thrown delegate errors after `.ready` +- Explicit and background query or transaction-handling errors after `.ready` preserve the previous raw and typed snapshot. - A verified known-tier to unknown-tier change produces `.failed`, clears both public projections, and makes `isEntitled(to:)` return `false`. @@ -1030,8 +1121,16 @@ and system integration. ### Coordination and reporting tests -- `.finish` is the only policy that calls StoreKit `finish()` and records an - exact revision in the process-lifetime completed set. +- `.automatic` calls StoreKit `finish()` only for a catalog-validated managed + transaction; an explicit `.finish` is the only other path to `finish()`. +- `.automatic` also finishes an upgraded transaction in a managed group after + validating `.autoRenewable`, without requiring its retired Product ID to + remain declared or granting typed access. +- `.automatic` throws `unhandledTransaction` for every unmanaged product, while + invalid catalog metadata for a non-upgraded transaction fails before the + delegate is called. Neither path finishes the transaction. +- Completed-revision suppression is bounded; an evicted exact revision can be + decided again and therefore still requires app-level idempotency. - `.keepUnfinished` calls neither `finish()` nor `transactionStore(didFailWith:)`, returns `.keptUnfinished` to a direct caller, and still completes the causal entitlement publication. @@ -1055,6 +1154,13 @@ and system integration. - A physical failure is reported once if every direct caller abandons it. - The store retains its delegate until lifecycle completion, and `close()` drains admitted decisions and notifications. +- A delegate that implements only `transactionStore(didFailWith:)` inherits + `.automatic` and receives an unmanaged background transaction as an + `unhandledTransaction` failure. +- Propagated callback context rejects direct and `Task {}` reentry into + admission-bearing store operations. A detached task does not inherit that + context; the contract test documents the detection boundary without claiming + that detached reentry throws. - The default no-op `transactionStore(didFailWith:)` does not block the runtime or alter transaction policy. @@ -1067,16 +1173,21 @@ and system integration. - A second external fixture imports `StoreTransactionKitTesting`, starts from a ready empty set, purchases a typed Product ID, and observes the ViewModel change immediately after the command returns without a `.storekit` file. -- A harness purchase uses the attached direct-operation path: a thrown delegate - or catalog failure reaches the command caller and is not duplicated in - `reportedFailures`; `.completed` and `.keptUnfinished` return only after - MainActor publication. +- A harness purchase uses the attached direct-operation path: a delegate, + automatic-handling, or catalog failure reaches the command caller and is not + duplicated in `reportedFailures`; `.completed` and `.keptUnfinished` return + only after MainActor publication. - A time-dependent consumer dependency reaches a registered Clock sleeper, exposes its intermediate state, advances virtual time, and still waits for the harness command's MainActor publication receipt. The test contains no fixed sleeps or guessed `Task.yield()` counts. +- `withTransactionStoreTestHarness` drains and closes after normal return, + operation failure, and cancellation; an operation failure is rethrown only + after cleanup. +- Final scoped cleanup succeeds idempotently when the operation already called + `harness.store.close()`. - Cancellation before command admission creates no transaction; cancellation - after admission still allows `close()` to establish terminal completion. + after admission is still drained by scoped cleanup. - The testing product cannot be imported transitively by a consumer that depends only on the production product. - Swift 6 strict-concurrency builds prove the primary-associated-type and @@ -1110,6 +1221,9 @@ is beta. - [`Transaction.currentEntitlements`](https://developer.apple.com/documentation/storekit/transaction/currententitlements) - [`WKNavigationDelegate`](https://developer.apple.com/documentation/webkit/wknavigationdelegate) - [`WKNavigationResponsePolicy`](https://developer.apple.com/documentation/webkit/wknavigationresponsepolicy) +- [`TaskLocal`](https://developer.apple.com/documentation/swift/tasklocal) +- [`Task.detached(priority:operation:)`](https://developer.apple.com/documentation/swift/task/detached(priority:operation:)) +- [`SendableMetatype`](https://developer.apple.com/documentation/swift/sendablemetatype) - [`Clock`](https://developer.apple.com/documentation/swift/clock) - [SE-0329: Clock, Instant, and Duration](https://github.com/swiftlang/swift-evolution/blob/main/proposals/0329-clock-instant-duration.md) - [Using Continuations and Clock for deterministic Swift concurrency tests](https://zenn.dev/kntk/articles/2e8d1925b0bb6b) diff --git a/README.md b/README.md index 919d210..4e3a3dc 100644 --- a/README.md +++ b/README.md @@ -23,8 +23,9 @@ The store owns the durable transaction path for the process lifetime: - Verification: only verified transactions reach policy code; direct failures throw, and an optional delegate can receive background failures - Ordering: verification, an optional app policy decision, then `finish()` only - when permitted. Finished revisions are deduplicated for the process lifetime, - while unfinished decisions are coalesced only through their causal attempt. + when permitted. A bounded process-local cache suppresses recent completed + revisions, while unfinished decisions are coalesced only through their causal + attempt. - State: the observable current-entitlement projection, restore synchronization, background failure delivery, and explicit shutdown @@ -39,8 +40,11 @@ Your app owns everything the user sees and everything it persists: ## Quick start -Define the app entitlements, then describe one App Store Connect subscription -group with its Product IDs: +The API in this Quick start is proposed for the next beta and is not implemented +in the current source yet. + +Define the app entitlements, then describe one App Store Connect auto-renewable +subscription group with its Product IDs: ```swift import StoreTransactionKit @@ -50,7 +54,7 @@ enum SubscriptionEntitlement: Hashable, Sendable { case tier2 } -enum Plans: SubscriptionGroup { +enum Plans: AutoRenewableSubscriptionGroup { static let id = SubscriptionGroupID( rawValue: "YOUR_SUBSCRIPTION_GROUP_ID" ) @@ -75,7 +79,7 @@ enum Plans: SubscriptionGroup { } } -let subscriptionCatalog = SubscriptionCatalog(Plans.self) +let subscriptionCatalog = AutoRenewableSubscriptionCatalog(Plans.self) ``` `TransactionStore` is `@MainActor` and `@Observable`. It starts monitoring @@ -161,7 +165,8 @@ struct ContentView: View { Replace `Plans.id` and the nested Product ID raw values with the identifiers configured in [App Store Connect][subscription-setup]. Map monthly and yearly products that grant the same access level to the same app entitlement. StoreKit -remains the source of truth for levels and durations. +owns upgrade and downgrade ordering and each product's renewal period; the +catalog owns the app-access meaning of each Product ID. For local StoreKit Testing, use the same values in the active `.storekit` configuration. See [Setting up StoreKit Testing in Xcode][storekit-testing]. @@ -189,15 +194,19 @@ let store = TransactionStore( ## Transaction delegate -Without a delegate, the store uses its built-in `.finish` policy for verified -transactions. Supply a delegate when the app must apply its own durable effect -or receive background failure notifications: +Without a delegate, `.automatic` handling finishes only catalog-validated +auto-renewable subscriptions. Supply a delegate when the app handles other +product types, applies another durable effect, or needs background diagnostics: ```swift final class AppTransactionDelegate: TransactionStoreDelegate { func transactionStore( decidePolicyFor transaction: StoreTransactionSnapshot ) async throws -> StoreTransactionHandlingPolicy { + guard transaction.productType == .consumable else { + return .automatic + } + try await persist(transaction) return .finish } @@ -215,8 +224,11 @@ let store = TransactionStore( ) ``` -`transactionStore(decidePolicyFor:)` owns the app's durable transaction -correctness: +Both delegate methods are optional through default implementations. The decision +defaults to `.automatic`; the failure notification defaults to a no-op. + +When the app implements `transactionStore(decidePolicyFor:)`, it owns the +durable correctness of every non-automatic decision: - **Be idempotent.** Delivery is at least once; key the ledger on transaction identity plus the business event it applies. @@ -227,15 +239,22 @@ correctness: calls `finish()`. Return `.keepUnfinished` only after deliberately choosing to leave the transaction eligible for a later attempt; throw when neither decision can be completed. -- **Never call back into the same store** from either delegate method, even - through an awaited detached task — doing so creates a dependency cycle with - the work being handled. +- **Don't start another operation on the same store** from either delegate + method, even through an awaited detached task. Calling `process(_:)`, + `refreshEntitlements()`, `history(for:)`, `restorePurchases()`, or `close()` + there can create a dependency cycle with the work being handled. The store + retains its delegate, so any delegate reference back to the store must also be + weak. + +`.automatic` is not unconditional finish: a catalog mismatch fails before the +delegate is called, and a catalog-external product fails as unhandled unless the +delegate explicitly decides how to process it. -`transactionStore(didFailWith:)` is an optional notification with a default -no-op implementation. Its return cannot change the transaction decision. +`transactionStore(didFailWith:)` is a notification. Its return cannot change the +transaction decision. The full decision, redelivery, and failure-routing contracts are documented in -the [API design](Docs/SubscriptionCatalogAPI.md). +the [API design](Docs/AutoRenewableSubscriptionCatalogAPI.md). ## How entitlement availability behaves @@ -254,13 +273,13 @@ the [API design](Docs/SubscriptionCatalogAPI.md). Consult `entitlementStatus` only when the app needs to explain where the entitlement set came from or why it is unavailable. - A successful refresh after `.failed` publishes `.ready` and the new active - entitlement set. A background query or thrown delegate error after `.ready` - preserves the last active set and reports the failure through + entitlement set. A background query or transaction-handling error after + `.ready` preserves the last active set and reports the failure through `transactionStore(didFailWith:)`. - A verified catalog mismatch fails closed: it changes the status to `.failed` and clears both entitlement projections instead of preserving stale access. - Startup and every refresh reconcile `Transaction.unfinished` — including - consumables — before publishing state. A thrown delegate error fails that + consumables — before publishing state. A transaction-handling error fails that refresh; the next refresh retries the unfinished work. - Transactions superseded by a subscription upgrade stay in `entitlements` but don't appear in `activeEntitlements`. @@ -268,9 +287,9 @@ the [API design](Docs/SubscriptionCatalogAPI.md). `transactionStore(didFailWith:)` with source `.currentEntitlementVerification`. - Product IDs mapped to the same app entitlement appear as the same typed value. -- `SubscriptionCatalog` maps auto-renewable subscriptions only. Other product - types don't belong to subscription groups; consumables remain part of - transaction handling and never appear in current entitlements. +- `AutoRenewableSubscriptionCatalog` maps auto-renewable subscriptions only. + Other product types don't belong to subscription groups; consumables remain + part of transaction handling and never appear in current entitlements. ## Beyond the basics @@ -299,9 +318,9 @@ explains which purchase entry point to use for each UI framework and platform. ## API design -See [StoreTransactionKit API design](Docs/SubscriptionCatalogAPI.md) for the -proposed public interface, validation rules, ownership boundaries, and state -transition contract behind the Quick start. +See [StoreTransactionKit API design](Docs/AutoRenewableSubscriptionCatalogAPI.md) +for the proposed public interface, validation rules, ownership boundaries, and +state transition contract behind the Quick start. ## Testing @@ -315,21 +334,20 @@ import Testing @Test @MainActor func subscriptionUpdatesViewModel() async throws { - let harness = try await TransactionStoreTestHarness( + try await withTransactionStoreTestHarness( subscriptionCatalog: subscriptionCatalog - ) - let viewModel = NotesViewModel(store: harness.store) - - #expect(!viewModel.canExportPDF) + ) { harness in + let viewModel = NotesViewModel(store: harness.store) - try await harness.purchase( - .tier1_Monthly, - in: Plans.self - ) + #expect(!viewModel.canExportPDF) - #expect(viewModel.canExportPDF) + try await harness.purchase( + .tier1_Monthly, + in: Plans.self + ) - try await harness.close() + #expect(viewModel.canExportPDF) + } } ``` From 089ef82dd16d0d9ebb123eb41bbd1ea473be4afb Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Wed, 22 Jul 2026 17:19:11 +0900 Subject: [PATCH 08/26] docs(api): refine subscription catalog contracts --- Docs/AutoRenewableSubscriptionCatalogAPI.md | 1566 +++++++++---------- README.md | 327 +--- 2 files changed, 823 insertions(+), 1070 deletions(-) diff --git a/Docs/AutoRenewableSubscriptionCatalogAPI.md b/Docs/AutoRenewableSubscriptionCatalogAPI.md index d3810ad..cd51694 100644 --- a/Docs/AutoRenewableSubscriptionCatalogAPI.md +++ b/Docs/AutoRenewableSubscriptionCatalogAPI.md @@ -1,89 +1,94 @@ -# Auto-renewable subscription catalog, delegate, override, and testing API design +# Auto-renewable subscription API design -Status: Proposed for the next beta API. The README shows this design, but the -source implementation and symbol documentation do not provide it yet. +Status: Proposed for the next beta API. + +This document is the source of truth for the proposal only. The public source, +README, and symbol documentation continue to describe the currently released +API until the implementation transaction is complete. After implementation, +the public contracts move to symbol DocC and a consumer article, and this +temporary design document is removed. ## Purpose StoreTransactionKit needs to translate StoreKit Product IDs into the app's feature-access vocabulary without making Product IDs themselves the public -entitlement type. The translation must represent App Store Connect -subscription groups accurately, reject configuration drift, and preserve the -difference between unavailable entitlement state and a resolved empty set. - -The same typed entitlement model must also support an app-selected StoreKit -bypass and deterministic app or ViewModel tests. Those paths must not invent -StoreKit transactions or fork production entitlement semantics. +entitlement type. The first consumer is an app with one App Store Connect +auto-renewable subscription group containing multiple access levels and +multiple durations at each level. -The primary consumer is an app with one auto-renewable subscription group that -contains multiple access levels and multiple durations at each level. A second -supported consumer has multiple independent subscription groups whose products -map into one app entitlement type. +The same entitlement domain must support an app-selected StoreKit bypass and +deterministic app or ViewModel tests. Those paths use the production state and +transaction pipeline without inventing StoreKit transactions in app code. ## Goals -- Scope each Product ID type to one App Store Connect subscription group. -- Map multiple billing durations at the same access level to one app - entitlement. -- Keep StoreKit's subscription group level and duration metadata in StoreKit. -- Validate the remote transaction metadata that the app's static catalog can - know about without loading products eagerly. +- Scope the Product ID type to one auto-renewable subscription group. +- Map multiple billing durations at one access level to one app entitlement. +- Keep StoreKit group levels and renewal periods in StoreKit rather than copying + them into the catalog. +- Validate every piece of verified transaction metadata that the static catalog + can know. - Publish raw and typed entitlement state as one atomic snapshot. -- Keep the surrounding app UI usable while entitlement state is unavailable. -- Support one group in the common case and explicit composition for independent - groups. -- Finish automatically only when a transaction is a validated auto-renewable - subscription managed by the catalog; require an app decision for every other - product. -- Separate the finish decision from optional background-failure notification. -- Let the app construct a fixed entitlement override without environment - detection inside the framework. -- Let tests drive the real transaction and entitlement pipeline without a - `.storekit` configuration or timing guesses. -- Separate virtual time control from causal operation completion. +- Distinguish unavailable entitlement state from an available empty set. +- Keep normal app UI usable while entitlement state is unavailable. +- Finish automatically only for a catalog-validated auto-renewable transaction. +- Give other product types an explicit app-owned handling decision. +- Make every background-owned failure observable without requiring a delegate. +- Allow fixed entitlement overrides without framework-owned environment checks. +- Let tests drive the production pipeline without a `.storekit` file or timing + guesses. +- Make terminal shutdown and the single-live-store invariant enforceable. ## Non-goals +- The initial catalog does not compose multiple subscription groups. Supporting + that requires per-group availability and failure isolation rather than one + all-or-nothing entitlement projection. - The catalog does not describe consumables, non-consumables, or non-renewing subscriptions. -- The catalog does not own product merchandising, prices, localized names, - purchase UI, renewal UI, or `Product.SubscriptionInfo.Status`. +- The catalog does not own prices, localized merchandising, purchase UI, + renewal UI, or `Product.SubscriptionInfo.Status`. - The framework does not infer app access from StoreKit `groupLevel`. -- The framework does not infer an entitlement for an unknown product. +- The framework does not infer an entitlement for an undeclared Product ID. - The framework does not detect TestFlight, previews, debug builds, receipts, - or other distribution environments to select override mode. + or other environments to select override mode. - The no-configuration test harness does not validate StoreKit verification, JWS, App Store Connect metadata, system purchase UI, or StoreKit renewal scheduling. - Advancing a test clock does not mean that the transaction pipeline is idle or - that an entitlement update has been published. -- The design does not retain the current Product-ID-as-entitlement API for - source compatibility. The package is still beta. + that an entitlement publication has completed. +- Source compatibility with the current Product-ID-as-entitlement API is not a + goal while the package is beta. ## StoreKit model -An App Store Connect subscription group contains auto-renewable subscriptions -with different access levels and durations. A customer can hold one -subscription product in a group at a time. Products at one level may have -monthly and yearly variants. +An App Store Connect subscription group contains auto-renewable products with +different access levels and durations. A customer holds one subscription +product in a group at a time. Products at one level may have monthly and yearly +variants. StoreKit owns these facts: - `Product.SubscriptionInfo.subscriptionGroupID` identifies the group. -- `Product.SubscriptionInfo.groupLevel` ranks upgrade and downgrade paths; +- `Product.SubscriptionInfo.groupLevel` orders upgrade and downgrade paths; level `1` is the highest service level. - `Product.SubscriptionInfo.subscriptionPeriod` describes the renewal period. - `Transaction.currentEntitlements` includes current non-consumables, qualifying auto-renewable subscriptions, and non-renewing subscriptions. It excludes consumables. -The app owns the meaning of access. `SubscriptionEntitlement.tier1` is an app -domain value; it is not a copy of StoreKit `groupLevel == 1`. The explicit -Product ID mapping is the boundary between the two models. +The app owns access meaning. `SubscriptionEntitlement.tier1` is an app-domain +value, not a copy of `groupLevel == 1`. The explicit Product ID mapping is the +boundary between those models. ## Consumer story +Define the app entitlements and the Product IDs belonging to one subscription +group: + ```swift +import StoreTransactionKit + enum SubscriptionEntitlement: Hashable, Sendable { case tier1 case tier2 @@ -115,16 +120,93 @@ enum Plans: AutoRenewableSubscriptionGroup { } let subscriptionCatalog = AutoRenewableSubscriptionCatalog(Plans.self) +``` -let store = TransactionStore( - subscriptionCatalog: subscriptionCatalog -) +Create one live store at the process composition root and inject that same +instance into SwiftUI: + +```swift +import StoreTransactionKit +import SwiftUI + +@main +struct ExampleApp: App { + @State private var store: TransactionStore + + init() { + _store = State( + initialValue: TransactionStore( + subscriptionCatalog: subscriptionCatalog + ) + ) + } -let canExportPDF = store.isEntitled(to: .tier1) + var body: some Scene { + WindowGroup { + NavigationStack { + ContentView() + } + .environment(store) + } + } +} ``` -The app can use the same catalog and entitlement type for a fixed StoreKit -bypass: +Gate only the paid feature. Loading or a failed entitlement query does not +replace the rest of the view: + +```swift +import StoreKit +import StoreTransactionKit +import SwiftUI + +struct ContentView: View { + @Environment(TransactionStore.self) private var store + @State private var isShowingPaywall = false + + private var canExportPDF: Bool { + store.isEntitled(to: .tier1) + } + + var body: some View { + List { + Section { + NavigationLink("All notes") { + NotesView() + } + } + + Section { + Button("Export as PDF") { + exportPDF() + } + .disabled(!canExportPDF) + + Button("Plans and subscriptions") { + isShowingPaywall = true + } + } header: { + Text("Premium") + } + } + .sheet(isPresented: $isShowingPaywall) { + SubscriptionStoreView(groupID: Plans.id.rawValue) + } + } +} +``` + +Monthly and yearly products granting the same access map to the same +entitlement. StoreKit owns upgrade and downgrade ordering. If several plan +identities grant one feature, the app checks the accepted entitlement values; +the catalog does not infer tier inclusion. + +No active subscription is represented by `.ready` with an empty +`activeEntitlements` set. It is distinct from `.loading` or `.failed`, where +`activeEntitlements` is `nil`. + +For an app-defined environment that bypasses StoreKit, provide the exact set to +activate: ```swift let store = TransactionStore( @@ -136,16 +218,8 @@ let store = TransactionStore( ) ``` -The app owns the condition that selects this initializer. Passing an empty -sequence explicitly selects override mode with no active entitlement. - -An app with independent groups composes them without erasing their nested -Product ID types: - -```swift -let subscriptionCatalog = AutoRenewableSubscriptionCatalog(Plans.self) - .including(ChannelSubscriptions.self) -``` +The app owns the condition selecting this initializer. An empty sequence means +override mode with no active entitlement. ## Proposed public interface @@ -176,15 +250,10 @@ public struct AutoRenewableSubscriptionCatalog: Sendable where Entitlement: Hashable & Sendable { public init(_ groupType: Group.Type) where Group: AutoRenewableSubscriptionGroup - - public func including( - _ groupType: Group.Type - ) -> Self - where Group: AutoRenewableSubscriptionGroup } public enum AutoRenewableSubscriptionCatalogError: LocalizedError, Sendable { - case unknownProduct( + case undeclaredProduct( productID: Product.ID, subscriptionGroupID: SubscriptionGroupID ) @@ -210,13 +279,18 @@ public enum EntitlementStatus: Sendable { public enum StoreTransactionOperation: Sendable, Hashable { case processPurchase - case currentEntitlements + case refreshEntitlements case history case restorePurchases case close } -public enum StoreTransactionError: Error, Sendable, Hashable { +public enum StoreTransactionError: Error, Sendable { + public enum CompletedOperation: Sendable, Hashable { + case finishedTransaction(StoreTransactionSnapshot) + case synchronizedPurchases + } + case closing case closed case unknownPurchaseResult @@ -225,40 +299,44 @@ public enum StoreTransactionError: Error, Sendable, Hashable { productType: Product.ProductType ) case reentrantOperation(operation: StoreTransactionOperation) - case operationUnavailableInOverride(operation: StoreTransactionOperation) + case operationUnavailableInOverride( + operation: StoreTransactionOperation + ) + case entitlementRefreshFailed( + after: CompletedOperation, + underlyingError: any Error + ) } public enum StoreTransactionHandlingPolicy: Sendable, Hashable { case automatic case finish - case keepUnfinished } public protocol TransactionStoreDelegate: AnyObject, Sendable { - func transactionStore( - decidePolicyFor transaction: StoreTransactionSnapshot + func decidePolicy( + for transaction: StoreTransactionSnapshot ) async throws -> StoreTransactionHandlingPolicy - func transactionStore( - didFailWith failure: StoreTransactionBackgroundFailure + func didFail( + with failure: StoreTransactionBackgroundFailure ) async } public extension TransactionStoreDelegate { - func transactionStore( - decidePolicyFor transaction: StoreTransactionSnapshot + func decidePolicy( + for transaction: StoreTransactionSnapshot ) async throws -> StoreTransactionHandlingPolicy { .automatic } - func transactionStore( - didFailWith failure: StoreTransactionBackgroundFailure + func didFail( + with failure: StoreTransactionBackgroundFailure ) async {} } public enum StorePurchaseOutcome: Sendable, Hashable { case completed(StoreTransactionSnapshot) - case keptUnfinished(StoreTransactionSnapshot) case pending case userCancelled } @@ -301,298 +379,207 @@ where Entitlement: Hashable & Sendable { } ``` -## Optional transaction delegate - -Omitting `delegate` uses `.automatic` handling and performs no app-specific -failure notification. The store strongly retains a supplied delegate until -`close()` finishes or the store is deinitialized. - -`TransactionStoreDelegate` follows the decision/notification split used by -`WKNavigationDelegate`: a method that grants permission for a consequential -operation returns a policy, while a method that reports an event that has -already occurred returns no policy. The transaction method is also `throws` -because failing to reach a decision is different from deliberately choosing a -normal policy. - -Both methods have default implementations. A delegate interested only in -background diagnostics implements `transactionStore(didFailWith:)` and inherits -`.automatic` transaction handling. A delegate interested only in transaction -policy implements the decision method and inherits the no-op notification. - -Before applying a policy, the catalog classifies each verified transaction: - -- A **managed** transaction has a declared Product ID, `productType == - .autoRenewable`, and the declared subscription group ID. A superseded - transaction with `isUpgraded == true`, `.autoRenewable` type, and a managed - group is also managed for finishing even when its retired Product ID is no - longer declared; it cannot grant typed access. A Product ID that is still - declared must match its declaration in either case. -- An **invalid** transaction uses a declared Product ID with the wrong type or - group, or an undeclared non-upgraded Product ID inside a managed group. It - fails with `AutoRenewableSubscriptionCatalogError` before the delegate is - called and is never finished. -- An **unmanaged** transaction belongs outside the catalog: a consumable, - non-consumable, non-renewing subscription, or an auto-renewable subscription - in another group. - -The decision method is called for managed and unmanaged transactions. Its -policies mean: - -- `.automatic` is the safe default. It finishes a managed transaction and - throws `StoreTransactionError.unhandledTransaction` for an unmanaged one. It - is not an alias for unconditional `.finish`. -- `.finish` means the app has durably applied this business event, or has - established from its idempotency ledger that the event was already applied. - StoreTransactionKit then calls `finish()`, records the exact transaction - revision as completed, refreshes current entitlements, and publishes the - resulting state. -- `.keepUnfinished` means the app reached an expected deferral decision and, - when its own model requires it, recorded that decision durably. It is not a - substitute for catching a processing error. StoreTransactionKit does not call - `finish()` or send a failure notification, but it continues the causal - entitlement refresh. A direct purchase returns - `.keptUnfinished(transaction)` after that refresh and MainActor publication - complete. -- Throwing means that the delegate could not establish either a durable - `.finish` decision or a valid `.keepUnfinished` decision. StoreTransactionKit - does not call `finish()`. A direct operation forwards the error to its caller; - background-owned work sends it to `transactionStore(didFailWith:)`. A later - independent attempt can redeliver the transaction. - -This keeps subscription-only apps concise without allowing the default path to -finish a consumable or another product that has no handling owner. An app that -also sells such products implements the decision method, persists the product's -business effect, and returns `.finish`. An app that only wants diagnostics can -implement the notification method alone; an unmanaged background transaction -then arrives there as an `unhandledTransaction` failure. - -The transaction-processing coordinator owns one causal decision receipt for an -exact revision from admission through completion of the refresh caused by that -decision. Direct results, `Transaction.updates`, and -`Transaction.unfinished` reconciliation all attach to that receipt instead of -invoking the delegate again. Coalesced reservations share the same receipt, and -the reconciler seeds its exact-revision exclusion set from every receipt in the -physical refresh. The receipt completes only after the refresh succeeds or -fails and all attached callers and reporting owners receive that result. - -`.keepUnfinished` is not added to the bounded completed-revision cache or the -failed-attempt set. The causal receipt is discarded when its physical refresh -completes, after which a non-coalesced update, status change, explicit refresh, -restore, or startup attempt may present the exact revision again. -StoreTransactionKit does not schedule a timer or backoff retry. Coalescing uses -exact revision identity rather than transaction ID, so a later revocation or -another revised business event is not suppressed. Any app-owned effect performed -before returning a policy must remain idempotent. - -Completed revisions enter a bounded process-local cache. The cache prevents -nearby duplicate delivery from repeating policy work, but it is not a durable -business ledger and does not promise process-lifetime retention. Eviction may -allow an exact revision to be presented again, so an app delegate's durable -effect remains idempotent. - -`transactionStore(didFailWith:)` is an optional observation hook with a default -no-op implementation. It cannot change a transaction decision, request a retry, -or suppress a thrown error. Admitted notifications are delivered serially with -backpressure, and `close()` waits for each invocation to return. Direct errors -that reach an attached caller are not duplicated as background notifications. - -A weak app delegate would allow its policy and diagnostic receiver to disappear -after initialization. The store therefore retains its delegate strongly, and a -delegate that keeps a reference back to the store must make that reference weak. -The delegate must not start an admission-bearing operation on the same store — -`process(_:)`, `refreshEntitlements()`, `history(for:)`, `restorePurchases()`, or -`close()` — directly or through an awaited child or detached task. Read-only -entitlement inspection does not enter the transaction coordinator and is not a -`reentrantOperation`. The methods omit a store parameter because policy normally -depends on the supplied transaction, not on the store's generic entitlement -type. - -The protocol is class-bound and `Sendable`, but it is not actor-bound. A -delegate that owns mutable state directly can be an actor. A checked-Sendable -`final class` with immutable `Sendable` dependencies is equally valid. Runtime -delivery ordering does not make an otherwise unsynchronized mutable class safe. - -`StoreTransactionOperation` is the existing closed diagnostic vocabulary rather -than a free-form string. The new error case reuses it so an override-mode -failure identifies the rejected operation without parsing text. - -`AutoRenewableSubscriptionGroup` is a client-conformance protocol because each -app supplies its own closed group definition. Its requirements remain -intentionally small. The protocol itself is not `Sendable`, and `ProductID` does -not require `Hashable` or `Sendable`: the catalog consumes `allCases` -synchronously and normalizes each case to its raw `String` during construction. -No group instance, group metatype, or typed Product ID is retained. - -Adding a protocol requirement after 1.0 would break client conformances. Future -optional metadata belongs in catalog initializers or configuration values, not -in a new `AutoRenewableSubscriptionGroup` requirement. - -`AutoRenewableSubscriptionCatalog` is an immutable value. `including(_:)` -returns another catalog and leaves the receiver unchanged. This keeps the -one-group use case to one line while supporting the StoreKit case where -independent subscriptions must live in separate groups. - -## Type-safety boundary - -The API provides compile-time safety for app-owned declarations: - -- `Plans.ProductID` cannot be passed where another group's nested Product ID is - expected. -- The exhaustive `switch` in `entitlement(for:)` maps every declared Product ID. -- `SubscriptionGroupID` prevents a group identifier from being confused with an - arbitrary Product ID at API boundaries. -- `TransactionStore` exposes the app's `Entitlement`, not raw Product IDs, to - feature-gating code. -- `isEntitled(to:)` expresses a feature gate without exposing optional-set - mechanics at each call site. +`StoreTransactionError` is not `Hashable`: the post-completion failure preserves +an arbitrary underlying error, and no consumer requires errors as collection +keys. -The compiler cannot validate App Store Connect. Runtime validation is therefore -part of the catalog contract rather than a substitute source of truth. +## Catalog contract -The identifier remains `SubscriptionGroupID`, rather than -`AutoRenewableSubscriptionGroupID`, because it mirrors StoreKit's -`subscriptionGroupID` vocabulary and StoreKit subscription groups already imply -auto-renewable subscriptions. The longer qualifier belongs on the app-defined -group protocol and catalog, where it distinguishes their product scope. -Initializer labels remain `subscriptionCatalog:` because the argument's static -type already carries the `AutoRenewable` qualifier. +### Type-safety boundary -`SubscriptionGroupID.init(rawValue:)` preconditions that the raw value is not -empty. The identifier type owns that invariant because the value is also useful -outside the catalog, such as when passing `Plans.id.rawValue` to StoreKit UI. +The nested Product ID type prevents a Product ID declared for another group +from being passed to a group-specific API. The exhaustive +`entitlement(for:)` switch maps every declared Product ID, and +`SubscriptionGroupID` prevents group IDs from being confused with Product IDs. +Feature code sees the app's `Entitlement`, not raw identifiers. -## Catalog construction +The compiler cannot validate App Store Connect. Runtime validation is therefore +part of the catalog contract. The name `SubscriptionGroupID` mirrors StoreKit's +`subscriptionGroupID`; the auto-renewable qualifier belongs on the group and +catalog types that define product scope. -Construction converts each group into normalized internal entries keyed by raw -Product ID. It also records the set of managed subscription group IDs. +The client-conformance protocol stays intentionally small. The catalog consumes +`ProductID.allCases` synchronously and stores normalized strings plus the +`ObjectIdentifier` of the declaring group type. It retains no group instance, +group metatype, or typed Product ID. The declaration identity is used only to +prevent a testing command from substituting another conformance with the same +raw identifiers but a different mapping. Future optional metadata belongs in a +configuration value or catalog initializer rather than a new protocol +requirement. -The following remaining source-defined configuration errors fail with a -`precondition` during catalog construction: +### Construction -- A group whose `ProductID.allCases` is empty. -- An empty Product ID raw value. -- A duplicate raw Product ID within one group or across included groups. -- A duplicate subscription group ID. +`SubscriptionGroupID.init(rawValue:)` preconditions that its value is not empty. +Catalog construction performs no StoreKit request and preconditions that: -These are programmer errors in static app configuration. A nonthrowing -initializer keeps the normal composition root free of `try!`; the behavior is -analogous to `Dictionary(uniqueKeysWithValues:)` rejecting duplicate keys. -Duplicate checks remain necessary because a manually implemented -`RawRepresentable` or `CaseIterable` can violate the guarantees normally -provided by a raw-value enum. +- `ProductID.allCases` is not empty. +- Every Product ID raw value is nonempty. +- No raw Product ID is repeated within the group. -Duplicate entitlement values are valid. Monthly and yearly products at one -access level are expected to produce the same entitlement, and independent -groups may grant the same app entitlement. +These are static programmer errors, so the initializer remains nonthrowing. +Duplicate entitlement values are valid and expected for monthly and yearly +products at one access level. -`AutoRenewableSubscriptionCatalogError.errorDescription` includes the Product -ID and the expected and actual metadata needed to diagnose App Store Connect -drift. These descriptions are developer diagnostics and are not end-user -presentation copy. The public cases remain distinct so diagnostics and contract -tests can identify whether the shipped catalog is missing a product, names the -wrong product type, or assigns a product to the wrong group. +### Runtime validation and projection -Catalog construction performs no network request and does not load `Product` -values. Product metadata is validated only when StoreKit supplies a verified -transaction snapshot. +For each verified transaction in a candidate `StoreEntitlements` snapshot, the +catalog applies these rules before publication: + +1. A declared Product ID must have `productType == .autoRenewable`. +2. A declared Product ID must have the catalog's subscription group ID. +3. A declared, non-upgraded transaction maps to its typed entitlement. +4. A declared transaction with `isUpgraded == true` remains raw but grants no + typed access. +5. An undeclared, non-upgraded Product ID in the catalog's group fails with + `undeclaredProduct`; the framework cannot infer its access meaning. +6. An undeclared upgraded transaction in the catalog's group is accepted only + when its type is `.autoRenewable`; any other type fails with + `productTypeMismatch`. A valid upgraded transaction remains raw, can be + finished by `.automatic`, and grants no typed access. This permits retiring + a Product ID after no supported customer can hold it as current. +7. A product outside the catalog's group remains raw and is ignored by the + typed projection. + +Every applicable transaction is validated before anything is published. +Successful mappings form a `Set`, so multiple durations can produce one +entitlement value. + +The catalog is a closed declaration of the group it manages. Adding a product +in App Store Connect can therefore make an older binary report +`undeclaredProduct` after a customer moves to it. Product rollout must account +for supported older app versions; guessing a tier could grant the wrong access. + +## Transaction handling policy + +The catalog classifies each verified transaction before the delegate runs: + +- A **managed** transaction is a catalog-declared, metadata-valid + auto-renewable transaction. An undeclared upgraded transaction in the managed + group is also managed for finishing after its type and group are validated, + but it cannot grant typed access. +- An **invalid** transaction is declared with the wrong type or group, is an + undeclared non-upgraded product inside the managed group, or is an undeclared + upgraded product in that group whose type is not `.autoRenewable`. It fails + before the delegate runs and is never finished. +- An **unmanaged** transaction belongs outside the catalog, such as a + consumable, non-consumable, non-renewing subscription, or an auto-renewable + subscription in another group. + +The delegate decision is requested for managed and unmanaged transactions: + +- `.automatic` finishes a managed transaction and throws + `unhandledTransaction` for an unmanaged one. It is not unconditional finish. +- `.finish` means the app has durably applied this business event, or its + idempotency ledger proves that the event was already applied. The framework + then calls `finish()`. +- If `decidePolicy(for:)` throws, the framework does not call `finish()` and + does not run the causal entitlement refresh. A direct operation throws the + error; background-owned work reports it. A later independent StoreKit + delivery can present the exact revision again. The framework starts no timer + or backoff retry. + +There is no normal “keep unfinished” policy. StoreKit may still include an +unfinished auto-renewable transaction in `Transaction.currentEntitlements`, so +not calling `finish()` does not guarantee that access is withheld. A future +deferral feature would need transaction suppression and a corresponding public +availability state, not only another policy case. StoreKit purchase deferral is +already represented by `StorePurchaseOutcome.pending`. + +The decision/notification split follows the same structure as +`WKNavigationDelegate`: one method returns policy before a consequential action; +the other reports a failure that has already occurred and returns no policy. +Both requirements have defaults, so the delegate is optional and may implement +only the behavior it owns. + +The protocol is class-bound and `Sendable`, but not actor-bound. Its public +contract describes isolation requirements rather than prescribing whether a +consumer uses an actor or a synchronized class. + +### Exact-revision ownership + +Direct purchase results, `Transaction.updates`, and `Transaction.unfinished` +reconciliation attach to one causal decision receipt for an exact transaction +revision. Coalesced deliveries share that receipt instead of repeating delegate +work. The receipt stays active through policy, `finish()`, causal refresh, and +ordered MainActor publication. + +After `finish()` succeeds, the exact revision enters a bounded process-local +completed cache. The cache suppresses nearby duplicate deliveries but is not a +durable business ledger. Eviction may allow the revision to be presented again, +so every app-owned effect remains idempotent. Revision identity includes changes +such as revocation; transaction ID alone is not sufficient. + +### Failure after a completed action + +If `process(_:)` finishes a transaction and its causal entitlement refresh then +fails, it throws: -## Runtime projection and validation +```swift +StoreTransactionError.entitlementRefreshFailed( + after: .finishedTransaction(transaction), + underlyingError: error +) +``` -For each verified transaction in a candidate `StoreEntitlements` snapshot, the -catalog applies these rules before anything is published: - -1. A transaction with `isUpgraded == true` remains in raw `entitlements` and is - excluded from typed projection. It no longer grants access, so it does not - require a current catalog entry. -2. A declared Product ID must have `productType == .autoRenewable`. -3. A declared Product ID must have the subscription group ID declared by its - `AutoRenewableSubscriptionGroup`. -4. An undeclared Product ID whose transaction belongs to a managed group fails - with `AutoRenewableSubscriptionCatalogError.unknownProduct` because the - framework cannot infer its app entitlement. -5. An undeclared product outside every managed group remains in raw - `entitlements` and is ignored by the typed projection. -6. Successful mappings are collected into a `Set`, so multiple durations and - multiple groups may produce one typed entitlement value. - -The upgrade filter runs before catalog lookup, so an upgraded historical -transaction alone does not require its retired Product ID to remain in the -catalog. The ID must remain while any supported customer can still hold that -product as a non-upgraded current entitlement. Every non-upgraded known or -managed-group transaction is validated before publication. - -The catalog is a closed definition of every group it manages. Adding a Product -ID in App Store Connect can therefore make an older app binary report -`unknownProduct` after a user moves to that product. Product rollout must account -for supported older app versions; silently guessing a tier would risk granting -the wrong access. - -Composition is atomic, not failure-isolated. If one included group has a catalog -mismatch, the typed projection for every included group becomes unavailable. -Per-group availability would require a different public state model and is not -provided by this API. +The exact revision is already recorded as completed. The consumer must not +reapply its business effect, repeat a purchase, or rerun `process(_:)` only to +recover. Its next operation is `refreshEntitlements()`. + +`StorePurchaseOutcome.completed` is returned only after policy, finish, refresh, +catalog projection, and atomic MainActor publication all complete. A +post-finish refresh failure returns no outcome and throws the typed error above. + +If `AppStore.sync()` itself fails, `restorePurchases()` throws the original +error. If synchronization succeeds and the following refresh fails, it throws +`entitlementRefreshFailed(after: .synchronizedPurchases, underlyingError:)`. +The consumer retries `refreshEntitlements()` rather than immediately presenting +restore authentication again. + +The physical refresh coordinator completes with the root refresh or catalog +error only. Each attached direct receipt adds its own completed-action context: +a process receipt whose finish succeeded creates `.finishedTransaction`, a +restore receipt whose sync succeeded creates `.synchronizedPurchases`, and a +plain refresh receipt returns the root error unchanged. This remains correct +when those callers coalesce into one physical batch. + +If the physical failure is background-owned, the entitlement-refresh background +failure also stores the root error rather than one caller's completed-action +wrapper. Observable `EntitlementStatus` stores that same root error because it +explains readiness. Completed revisions and restore completion remain recorded +by their respective operation owners. ## Fixed entitlement override `overridingEntitlements` is a composition-root choice, not mutable runtime -state. The initializer consumes `some Sequence`, normalizes it once -to a `Set`, and publishes `.overridden` immediately. An array literal is the -common spelling; an existing `Set` or another finite sequence is equally valid. - -An empty sequence means “override with no active entitlement.” It is observably -different from selecting the live initializer, which begins in `.loading`. -There is no Boolean “unlock everything” form because the framework does not -know the app's complete entitlement universe or inclusion policy. - -An override store has these contracts: - -- It does not create a StoreKit source, start update or status monitors, query - current entitlements, process transactions, retain a delegate, or invoke - delegate methods. -- `activeEntitlements` is the normalized override set and - `isEntitled(to:)` performs exact membership against it. -- `entitlements` is `nil`. The framework does not synthesize raw transactions - to make the override look like a verified StoreKit snapshot. -- `process(_:)`, `refreshEntitlements()`, `history(for:)`, and - `restorePurchases()` throw - `StoreTransactionError.operationUnavailableInOverride(operation:)` before - starting any work. -- `close()` is successful and idempotent even though no runtime work exists. - -The override initializer does not accept a delegate; accepting one that can -never receive a decision or notification would create a false contract. The app -owns whether a preview, internal build, TestFlight build, UI test, or another -environment uses this initializer. StoreTransactionKit does not inspect the -receipt or build configuration to make that decision. - -The catalog remains part of the initializer so the override uses the same -`Entitlement` domain as the live store and the app has one composition shape. -No Product ID is reverse-mapped from an entitlement: monthly and yearly -products may intentionally grant the same value, so such a reverse mapping is -not well-defined. - -## Atomic publication owner - -Raw and typed entitlement values describe one StoreKit query and must commit -together. Catalog projection and validation therefore run inside the entitlement -refresh coordination boundary, after unfinished transactions have been handled -and before any of the following occur: - -- Updating the coordinator's current snapshot. -- Notifying the observable store. -- Completing a refresh receipt successfully. -- Returning a `StoreEntitlements` result to a caller. - -If a query or non-catalog transaction-handling error occurs before producing a -verified candidate, the previous complete snapshot remains current. A catalog -failure is different: the verified candidate contradicts the old typed -projection. The coordinator clears its complete publication, the observable -store becomes `.failed`, and both public projections become `nil`. Keeping the -old typed set could continue granting a higher tier after a user has moved to an -unknown lower-tier product. - -The coordinator reports every physical query batch to the observable state -owner exactly once, before completing attached receipts: +state. The initializer consumes a finite sequence, normalizes it once to a +`Set`, and publishes `.overridden` immediately. An empty sequence is an +authoritative empty set; it does not select live StoreKit behavior. + +An override store: + +- Starts no StoreKit source, monitor, query, or transaction processing. +- Does not retain or invoke a delegate. +- Publishes the normalized typed set and answers exact membership queries. +- Keeps raw `entitlements == nil`; it invents no StoreKit snapshots. +- Throws `operationUnavailableInOverride(operation:)` from every StoreKit + operation before starting work. +- Makes `close()` successful and idempotent. + +The app owns whether a preview, internal build, TestFlight build, UI test, or +another environment uses this initializer. There is no “unlock everything” +Boolean because the framework does not know the app's complete entitlement +universe. + +The catalog remains an initializer argument so live and override composition +share the same entitlement domain. No entitlement is reverse-mapped to a +Product ID because several products can intentionally grant the same value. + +## Atomic entitlement publication + +Raw and typed entitlement values describe one StoreKit query and commit +together. Projection and validation run inside the entitlement refresh +coordinator after unfinished processing and before any snapshot is exposed or +any receipt completes. ```swift private struct EntitlementPublication: Sendable @@ -607,30 +594,9 @@ where Entitlement: Hashable & Sendable { case transientFailure(any Error) case catalogFailure(AutoRenewableSubscriptionCatalogError) } - -didComplete( - token: UInt64, - outcome: EntitlementRefreshOutcome -) ``` -For a coalesced batch, `didComplete.token` is the last reservation token in that -batch. The coordinator delivers completions in physical token order. A single -`TransactionStore` reducer owns all availability transitions; startup, direct, -and background callers never write observable state themselves. - -`transientFailure` carries the normalized underlying error that belongs in -`entitlementStatus`. Any internal reporting-owner wrapper remains available to -receipts and reporting authority but does not leak into observable state. -Diagnostic reporting is a separate ownership decision, so `didComplete` does -not call `transactionStore(didFailWith:)`. - -Mapping in `TransactionStore.activeEntitlements` after raw publication would -violate atomicity and is not part of the design. - -## Observable state - -The three public properties are separate views of one private state value: +One reducer owns the public state: ```swift private enum EntitlementAvailability { @@ -645,176 +611,226 @@ private enum EntitlementAvailability { ``` `entitlementStatus`, `entitlements`, and `activeEntitlements` are computed from -that value. The store never updates three independent stored properties. This -prevents Observation from rendering combinations such as `.ready` with a `nil` -typed set. - -The public meaning is: +that value. There are no independently mutated mirror properties. | Status | `entitlements` | `activeEntitlements` | Meaning | | --- | --- | --- | --- | | `.loading` | `nil` | `nil` | No readiness attempt has completed. | -| `.failed(error)` | `nil` | `nil` | No usable complete snapshot exists; inspect `error` for the reason. | -| `.ready` | non-`nil` | non-`nil` | A complete raw and typed snapshot is available. Empty values mean no entitlement. | -| `.overridden` | `nil` | non-`nil` | StoreKit is bypassed and the app-supplied typed set is authoritative. An empty set means no entitlement. | - -`.loading` is only the initial state of a live store. The store does not return -to it for later refreshes. `.failed` means that no usable live snapshot exists; -it does not mean that the most recent operation failed. `.overridden` is the -only state of an override store. +| `.failed(error)` | `nil` | `nil` | No usable complete snapshot exists. | +| `.ready` | non-`nil` | non-`nil` | A complete live snapshot is available; empty means no entitlement. | +| `.overridden` | `nil` | non-`nil` | The app-supplied set is authoritative; empty means no entitlement. | State transitions are: | Event | Result | | --- | --- | -| Live initialization | `.loading` with both projections `nil`. | -| Override initialization | `.overridden` with raw `entitlements == nil` and the normalized typed set. | -| Any successful candidate | `.ready` with the new atomic snapshot. | -| Query or non-catalog transaction-handling error while `.loading` or `.failed` | `.failed(error)` with both projections `nil`. | -| Query or non-catalog transaction-handling error after `.ready` | Preserve the previous `.ready` snapshot. This includes a late startup failure after another refresh has already succeeded. | -| Catalog failure in a verified candidate | `.failed(error)` with both projections `nil`, even after `.ready`; stale typed access is invalidated. | -| Successful empty query | `.ready`; both collections are empty, not `nil`. | -| Unverified current-entitlement element | Omit and report that element; publish the verified remainder if the query otherwise succeeds. | -| Close | Preserve the last entitlement state; lifecycle errors are reported by operations, not by `EntitlementStatus`. Closing an override is an idempotent success. | - -The current `startupError` property is removed. Its readiness role moves to -`entitlementStatus`, while operational diagnostics continue through thrown -errors and `transactionStore(didFailWith:)`. - -SwiftUI calls `isEntitled(to:)` directly. It does not copy the set or status into -`@State`, and normal app content does not wait for readiness. The method returns -`true` when a ready or overridden set contains the requested entitlement. It -returns `false` while loading, after a readiness failure, and when the available -set does not contain the value. Code that needs to distinguish those reasons or -identify the source reads `entitlementStatus`. `activeEntitlements` remains -available for consumers that need the complete typed set. - -This is exact set membership. It does not infer that StoreKit group level 1 -contains level 2, or that one app entitlement includes another. If multiple plan -identities grant one feature, the app expresses that policy by checking each -accepted entitlement. The catalog continues to own only Product ID to app-value -translation. - -## Failure routing - -Failure delivery depends on ownership of the operation, not only on the error -type: - -| Failure | Observable state | Direct caller | Background notification | +| Live initialization | `.loading`. | +| Override initialization | `.overridden` with the normalized set. | +| Successful candidate | `.ready` with one new atomic snapshot. | +| Query or transaction-handling failure with no prior snapshot | `.failed(error)`. | +| Query or transaction-handling failure after `.ready` | Preserve the last `.ready` snapshot. | +| Catalog failure | `.failed(error)` and clear both projections, even after `.ready`. | +| Successful empty query | `.ready` with two empty collections. | +| Unverified current-entitlement element | Omit it, report it, and publish the verified remainder. | +| Close | Preserve the last entitlement state. | + +A catalog contradiction fails closed because preserving an older typed set could +continue granting a higher tier after a move to an undeclared lower-tier +product. A transient query failure preserves a known-good ready snapshot. + +`isEntitled(to:)` performs exact membership in `.ready` and `.overridden`. It +returns `false` while loading, after a readiness failure, or when the available +set does not contain the value. Consumers inspect `entitlementStatus` only when +they need to explain the reason. + +## Failure routing and observability + +Failure delivery follows ownership of the physical work: + +| Failure | Observable state | Direct caller | Background owner | | --- | --- | --- | --- | -| Invalid source-defined catalog | Store is not created | None | `precondition` failure | -| StoreKit operation requested from an override store | Preserve `.overridden` | Throw `operationUnavailableInOverride(operation:)` | None | -| Startup query or non-catalog transaction-handling error | Become `.failed` if no snapshot exists; otherwise preserve `.ready` | No startup caller | Notify once when no other physical-work owner already reports it | -| Startup catalog failure | `.failed(error)` and invalidate any previous projection | No startup caller | Notify once when no other physical-work owner already reports it | -| Explicit query or non-catalog transaction-handling error | Become or remain `.failed` without a snapshot; otherwise preserve `.ready` | Throw underlying error | Do not duplicate while a caller owns it | -| Explicit catalog failure | `.failed(error)` and invalidate any previous projection | Throw underlying error | Do not duplicate while a caller owns it | -| Background query or non-catalog transaction-handling error after `.ready` | Preserve `.ready` snapshot | None | Notify once through `transactionStore(didFailWith:)` | -| Background catalog failure | `.failed(error)` and invalidate any previous projection | None | Notify once through `transactionStore(didFailWith:)` | -| Current-entitlement verification failure for one element | Publish verified remainder | Attached operation may still succeed | Notify once for the omitted element | - -A catalog projection error participates in the same physical-work ownership and -coalescing rules as a StoreKit query error, but its observable-state transition -is intentionally fail-closed. Background-owned catalog failures use -`StoreTransactionBackgroundFailure.Source.entitlementRefresh` with the public -`AutoRenewableSubscriptionCatalogError` as `underlyingError`. - -Reservation role alone does not decide whether to report. Every startup, -background, and direct reservation in one physical batch shares one reporting -authority. Direct participation is registered as part of `reserve`, before the -worker can start, so a fast failure cannot race a later observer binding. - -The authority collects one background report candidate and all direct-caller -dispositions, then decides once: - -- If any attached direct caller receives the error, no background diagnostic is - sent. -- If every direct caller abandons the work, one background diagnostic is sent. -- If no direct caller participated, the startup or background physical work - sends one diagnostic. - -This is independent of whether background or direct work reserved first. State -completion still occurs exactly once through `didComplete`. - -## Product-type boundaries - -`AutoRenewableSubscriptionCatalog` is intentionally specific: - -- Auto-renewable subscriptions are mapped by group and Product ID. -- Non-consumables may appear in raw `StoreEntitlements`, but this catalog does - not map them to typed app access. -- Non-renewing subscriptions may appear in raw current entitlements even after - their intended service period; app-owned expiry policy is outside this - catalog. -- Consumables never appear in `Transaction.currentEntitlements`. They still pass - through the transaction decision path. An app that owns a consumable balance - supplies a delegate and returns `.finish` only after updating that balance - durably. - -`TransactionStore` remains the single process-wide transaction monitor and -finish authority across product types. The product-specific catalog changes -typed projection and automatic handling; it does not create a second StoreKit -listener. `.automatic` fails for every transaction outside this catalog, so an -app must provide the handling owner before it can finish one. - -If a concrete consumer later needs typed non-consumable access, it requires a -separate design. It must not be represented as a member of -`AutoRenewableSubscriptionGroup`, because StoreKit does not model it that way. +| Invalid static catalog | Store is not created | None | `precondition` failure | +| StoreKit operation in override mode | Preserve `.overridden` | Throw `operationUnavailableInOverride` | None | +| Explicit query or handling failure | Fail or preserve according to the state table | Throw | No duplicate report while attached | +| Startup or background query/handling failure | Fail or preserve according to the state table | None | Record and optionally notify once | +| Explicit catalog failure | Invalidate projections | Throw | No duplicate report while attached | +| Startup or background catalog failure | Invalidate projections | None | Record and optionally notify once | +| Current-entitlement verification failure | Publish verified remainder | Attached operation may succeed | Record and optionally notify once | +| Post-finish or post-sync refresh failure | Apply the underlying refresh transition | Throw typed completed-action error | Record and optionally notify once when background-owned | + +Every physical batch has one reporting authority. Direct participation is bound +at admission, before work can fail. If any attached direct caller receives the +error, it is not also a background failure. If all direct callers abandon the +work, ownership transfers to the background authority and the error is reported +once. + +Every background-owned failure is first recorded through a package-owned +unified `Logger`, whether or not a delegate exists. A supplied delegate then +receives the same failure through `didFail(with:)`. Internal logging is +best-effort observability: it cannot change policy or completion, has no public +injection surface, and never records JWS data. Direct errors returned to an +attached caller are not logged as background failures. + +For a failure that changes observable entitlement state, the reducer commit +completes before logging and before `didFail(with:)` begins. A delegate may +therefore inspect the corresponding state, but it cannot alter that state or +request retry by returning a value. + +Background notifications are serialized with backpressure. Decisions are also +serialized, but decision and notification delivery are independent and may +overlap. `close()` drains both. + +## Product-type boundary + +`AutoRenewableSubscriptionCatalog` maps only auto-renewable subscriptions: + +- Non-consumables and non-renewing subscriptions may appear in raw + `StoreEntitlements` but do not produce typed catalog entitlements. +- Consumables never appear in `Transaction.currentEntitlements`; they still + reach transaction handling. An app that owns a consumable balance supplies a + delegate and returns `.finish` only after applying that balance durably. +- `.automatic` rejects every unmanaged product, so the default path never + finishes a product with no business-effect owner. + +`TransactionStore` remains the process-wide transaction monitor and finish +authority across product types. A future typed non-consumable catalog is a +separate design, not another member of `AutoRenewableSubscriptionGroup`. ## Ownership map | Responsibility | Owner | | --- | --- | -| Group ID, Product ID cases, and Product ID to app-entitlement mapping | App-defined `AutoRenewableSubscriptionGroup` conformance | -| Choosing whether a particular app composition bypasses StoreKit | App composition root | -| Normalized lookup, managed-group membership, and catalog validation | `AutoRenewableSubscriptionCatalog` | -| Normalizing and publishing a fixed override set | `TransactionStore` override initializer and availability reducer | -| StoreKit query and unfinished-transaction reconciliation | `CurrentEntitlementReconciler` | -| Exact-revision admission, causal decision receipts, and policy completion | Transaction processing coordinator | -| Candidate projection, atomic publication, refresh coalescing, ordered completion, and receipt completion | Generic entitlement refresh coordinator | -| Observable availability reducer and process-lifetime facade | `TransactionStore` | -| Direct/background reporting authority and exactly-once diagnostic delivery | Runtime pipeline and failure notification dispatcher | -| Default handling for validated auto-renewable transactions | `AutoRenewableSubscriptionCatalog` classification and `.automatic` policy | -| Optional durable business effect, idempotency, and handling policy | App `TransactionStoreDelegate` | -| Product merchandising and subscription status presentation | App using StoreKit directly | -| Synthetic source, command admission, action acknowledgement, and test lifecycle | `withTransactionStoreTestHarness` and its `TransactionStoreTestHarness` in `StoreTransactionKitTesting` | -| Time policy in app tests | The app component that performs the timed work, through an injected `Clock` | -| Virtual time and sleeper-registration barriers | `TransactionStoreTestClock` in `StoreTransactionKitTesting` | - -No UI type owns semantic entitlement state. No second mapping is performed in a -view, delegate method, or computed property outside the catalog owner. +| Group ID, Product IDs, and Product ID to app-entitlement mapping | App-defined `AutoRenewableSubscriptionGroup` | +| Catalog lookup and verified metadata validation | `AutoRenewableSubscriptionCatalog` | +| Choosing live or override mode | App composition root | +| Fixed override normalization and publication | `TransactionStore` availability owner | +| StoreKit query and unfinished reconciliation | `CurrentEntitlementReconciler` | +| Exact-revision admission, decision receipt, and completed cache | Transaction processing coordinator | +| Projection, refresh coalescing, ordered completion, and atomic publication | Entitlement refresh coordinator | +| Observable availability | `TransactionStore` reducer | +| Process-wide live-monitoring lease, admission, and shared close completion | Non-generic internal lifecycle authority | +| Exactly-once direct/background failure selection | Runtime reporting authority | +| Unified background logging | Runtime reporting authority and package logger | +| App-specific policy, durable effect, and failure reaction | App `TransactionStoreDelegate` | +| Product merchandising and subscription-status presentation | App using StoreKit directly | +| Synthetic source, command receipt, and test lifecycle | `StoreTransactionKitTesting` harness | +| Timed app behavior | The app component with an injected `Clock` | +| Virtual time and sleep-registration barriers | `TransactionStoreTestClock` | + +No UI type owns semantic entitlement state, and no second Product ID mapping is +performed outside the catalog. ## Lifecycle and concurrency -- `TransactionStore` remains `@MainActor`, `@Observable`, and process-owned. -- `AutoRenewableSubscriptionCatalog` is immutable and `Sendable` after - normalization. Its storage uses value semantics rather than shared mutable - storage or `@unchecked Sendable`. -- App-defined `Entitlement` values cross concurrency boundaries and must be - `Hashable & Sendable`. -- Group types and typed Product IDs are consumed synchronously during catalog - construction and do not cross concurrency boundaries. -- A live store starts monitoring during initialization and retains the catalog - for every entitlement projection. -- A live store strongly retains an app delegate when supplied. Without one, it - uses `.automatic` and has no app-specific failure receiver. Transaction - decisions are serialized. Failure notifications are also serialized, but - decision and notification delivery are independent and may overlap. -- An override store starts no asynchronous task. Its normalized entitlement set - is immutable for the store's lifetime, and `close()` is idempotent. -- `close()` stops new admission and waits for every admitted delegate decision - and failure notification to return. A direct call, or a `Task {}` call, that - inherits the delegate's callback context and starts an admission-bearing store - operation fails with - `reentrantOperation(operation:)`. -- `Task.detached` does not inherit task-local callback context, so the framework - cannot identify that call as delegate-originated. Awaiting detached work that - calls the same store is still unsupported because it can create the same - dependency cycle. Actor isolation, `@isolated(any)`, `sending`, - `SendableMetatype`, and `@_inheritActorContext` do not encode parent-task - ancestry or make an instance method unavailable only to detached tasks. -- A process-wide “delegate callback is active” gate is not used to simulate that - provenance. It would also reject unrelated UI or lifecycle operations that - happen to overlap a suspended delegate callback. +### Live-store lease + +The live initializer synchronously acquires one process-wide exclusive lease +before retaining a delegate or creating a StoreKit producer. A second live +initializer while that lease is active is a precondition failure. The lease is +shared across every generic specialization of `TransactionStore`. + +The lease is held by a non-generic internal lifetime authority, not only by the +observable facade. `close()` releases it after terminal shutdown. Override +stores and synthetic stores created by `StoreTransactionKitTesting` do not +acquire it because they neither monitor live StoreKit sequences nor own live +`finish()` authority. + +Dropping a store is not an awaitable replacement protocol. Code that needs a +different live store first awaits `close()`. + +### Admission and cancellation + +Each admission-bearing operation — `process(_:)`, `refreshEntitlements()`, +`history(for:)`, and `restorePurchases()` — checks cancellation immediately +before acquiring its operation lease. Successful lease acquisition is the +admission boundary. + +- Cancellation before admission throws `CancellationError` and starts no + operation-specific StoreKit work. +- Cancellation after admission abandons only that caller's wait. The physical + decision, finish, refresh, publication, and failure routing continue to + terminal completion. +- If the cancelled caller was the last direct observer of a later failure, that + failure becomes background-owned and is reported once. +- `.pending` and `.userCancelled` results create no durable transaction work and + check cancellation before returning. +- `close()` is the exception: it begins or joins terminal shutdown even when the + caller is already cancelled, and every caller waits for the shared completion. + +Admission-bearing operations accepted while running complete. New +admission-bearing operations after shutdown has been sealed throw `.closing`; +after terminal completion they throw `.closed`. Repeated `close()` calls still +succeed, and the last observable entitlement state remains readable. + +### Delegate reentrancy + +The store strongly retains its delegate until terminal shutdown. A delegate +that references the store must hold that reference weakly. + +A delegate must not start an admission-bearing operation on the same store from +either callback. Inherited callback context lets the runtime reject direct calls +and `Task {}` child calls with +`reentrantOperation(operation:)`. `Task.detached` intentionally drops task-local +context, so Swift's actor isolation, `@isolated(any)`, `sending`, +`SendableMetatype`, and actor-context inheritance cannot prove that detached +call's ancestry. Starting a detached operation remains unsupported whether or +not the callback awaits it: awaiting can create a dependency cycle, while +fire-and-forget work escapes callback ownership. The contract does not claim +detached provenance is detectable. + +A process-wide “callback active” gate is not used because it would reject +unrelated operations that merely overlap a suspended callback. + +### Shared close + +The first accepted `close()` publishes one shared, noncancellable completion +before suspending. Concurrent callers join it; calls after closure return +successfully without effect. + +Before awaiting `AsyncSequence.next()`, each live producer acquires an iteration +lease. Sealing producer admission prevents a new `next()` call but does not +invalidate a lease already waiting for or handling an element. If an element is +returned while close races with that wait, the producer hands it to a processing +coordinator-owned, noncancellable terminal receipt before observing cancellation +and waits for that receipt without propagating producer cancellation into it. +Producer task cancellation interrupts the sequence wait, not physical work +admitted from a returned element. + +Terminal shutdown executes in this order: + +1. Transition to `closing`; seal public-operation admission and new producer + iteration admission. +2. Cancel the startup waiter and StoreKit producer tasks. +3. Await producer termination; callbacks admitted before sealing remain + admitted. +4. Await every admitted direct operation, decision, `finish()`, entitlement + refresh, ordered publication, and causal receipt. +5. Seal and drain background-failure delivery. +6. Release the strongly retained delegate. +7. Release the live-store lease and enter `closed`. + +After `close()` returns, no framework-owned task, StoreKit producer, delegate +invocation, or publication from that store remains active. The last entitlement +state remains readable. + +Calling `close()` from a callback owned by the same store throws +`reentrantOperation(operation: .close)` because waiting for that callback is +part of close completion. + +### Deinitialization backstop + +`TransactionStore` uses `isolated deinit` only for synchronous containment. It +must synchronously seal public and producer admission, then signal cancellation +to startup, producers, and finite framework tasks. It does not start an +unstructured cleanup task, await callbacks, claim shutdown completion, or +release the live lease directly. + +Runtime-owned work retains the lifetime token until every task admitted before +that seal terminates. Deinitialization does not promise a successful drain; +admitted work may instead reach terminal cancellation and background-failure +routing. Constructing another live store immediately after dropping an unclosed +one may therefore still fail the lease precondition. Explicit `close()` is the +only awaitable replacement boundary. ## Deterministic consumer testing @@ -826,13 +842,10 @@ StoreTransactionKitTesting StoreTransactionKit ``` -Production targets import only `StoreTransactionKit`. App test targets import -`StoreTransactionKitTesting`, which builds a real `TransactionStore` around a -package-scoped synthetic StoreKit source. The production module owns the -transaction pipeline, catalog projection, availability reducer, and public -store type; the testing module does not reimplement any of them. - -The initial testing surface is: +Production targets import `StoreTransactionKit`. Test targets may import +`StoreTransactionKitTesting`, which creates a real `TransactionStore` around a +package-scoped synthetic source. It does not reimplement transaction handling, +catalog projection, or observable state. ```swift public final class TransactionStoreTestClock: Clock, Sendable { @@ -870,17 +883,36 @@ public final class TransactionStoreTestClock: Clock, Sendable { public final class TransactionStoreTestHarness where Entitlement: Hashable & Sendable { public let store: TransactionStore - public private(set) var reportedFailures: - [StoreTransactionBackgroundFailure] { get } @discardableResult public func purchase( _ productID: Group.ProductID, in groupType: Group.Type - ) async throws -> StorePurchaseOutcome + ) async throws -> StoreTransactionSnapshot where Group: AutoRenewableSubscriptionGroup } +public enum TransactionStoreTestHarnessError: + LocalizedError, + Sendable, + Hashable +{ + case subscriptionGroupMismatch( + expected: SubscriptionGroupID, + actual: SubscriptionGroupID + ) + case subscriptionGroupTypeMismatch( + subscriptionGroupID: SubscriptionGroupID + ) + case undeclaredProduct( + productID: String, + subscriptionGroupID: SubscriptionGroupID + ) + case operationUnavailable(operation: StoreTransactionOperation) + + public var errorDescription: String? { get } +} + @MainActor public func withTransactionStoreTestHarness( subscriptionCatalog: AutoRenewableSubscriptionCatalog, @@ -892,99 +924,84 @@ public func withTransactionStoreTestHarness( where Entitlement: Hashable & Sendable ``` -The harness does not accept a Clock. None of the production work it drives owns -a delay, deadline, retry interval, or other time policy, so injecting a Clock -there would be unused ceremony. When `delegate` is omitted, the common -no-subscription → purchase → entitled test uses `.automatic`, which resolves to -finish for the catalog-validated synthetic subscription, and relies on the -command's causal receipt. Supplying a delegate exercises an app's real durable -effect and policy decision. - -`withTransactionStoreTestHarness` is the public construction and lifecycle -boundary. It initializes the harness, invokes `operation`, and drains and closes -framework-owned work before returning on success, failure, or cancellation. If -`operation` throws, its error is rethrown after cleanup. Harness construction -and mandatory final cleanup remain module-owned, so a test cannot forget -cleanup. A harness value retained beyond the closure is already closed. - -The module-owned cleanup path is nonthrowing and invokes cleanup from the scope -task rather than from a store delegate callback. A test may call the public -`harness.store.close()` earlier; final cleanup then relies on the store's -idempotent close contract. Public `TransactionStore.close()` keeps its -reentrancy error for general lifecycle owners; that error is not part of the -scoped testing API's final cleanup. - -`TransactionStoreTestClock` is a separate testing primitive for the component -that actually owns a time dependency, such as an app transaction delegate or -ViewModel. That component accepts `any Clock` using Swift's -primary-associated-type syntax. The test retains the concrete clock so it can -observe registered sleepers and advance virtual time. The clock uses -synchronized checked storage, such as `Synchronization.Mutex`; its synchronous -`Clock` requirements are not actor-isolated. Its independent virtual `Instant` -cannot be mixed with a `ContinuousClock.Instant` deadline. - -The test harness consumes the nested typed Product ID and the group type. It -validates that the group is present in the supplied catalog, then uses the -catalog's normalized raw Product ID and group ID. It never accepts an -`Entitlement` as a purchase command because mapping an entitlement back to one -monthly or yearly Product ID is not defined. Direct entitlement sets belong to -the fixed override initializer, not the full-pipeline harness. - -Initialization completes an empty current-entitlement query before returning. -The initial public state is therefore `.ready` with empty raw and typed -collections, not a racing `.loading` state. A test that needs to inspect loading -or failure transitions uses a lower-level package contract test rather than -adding timing hooks to app code. - -The initial public command surface contains only `purchase`. Expiration and -revocation are not aliases for removing a Product ID from the fake current set: -a natural expiration is a status/current-entitlement transition, while a -revocation is a revised durable transaction delivery. Each needs an explicit -transaction-identity, delegate-policy, finish, and missing-active-transaction -contract before it can become public. - -### Causal action acknowledgement - -Every mutating harness method is its own completion receipt. For example, -`purchase(_:,in:)` returns only after all work caused by that command has -completed: - -1. The synthetic transaction is admitted to the source. -2. The command attaches a direct-operation receipt and reporting authority to - the production runtime rather than yielding through the background update - stream. -3. The delegate or default resolver produces a policy; throwing terminates the - command through the failure-routing contract. -4. `.automatic` resolves from catalog classification. A resolved or explicit - `.finish` acknowledges the synthetic transaction; `.keepUnfinished` leaves it - available to a later attempt. -5. Current entitlements are queried and reconciled without deciding the same - exact revision again in this causal attempt. -6. The subscription catalog validates and projects the candidate. -7. `TransactionStore` commits the resulting availability on `@MainActor`. - -The command returns `.completed(transaction)` for resolved or explicit -`.finish`, and `.keptUnfinished(transaction)` for `.keepUnfinished`. Both -outcomes are returned after MainActor publication, so a ViewModel property -computed directly from `store.isEntitled(to:)` can be read immediately. A -thrown delegate or automatic-handling error instead fails the command and -produces neither outcome. The receipt does not guarantee a SwiftUI render pass -or completion of an unstructured consumer `Task` launched by an observation -callback; that work needs its own owner-provided acknowledgement. - -The harness does not expose “wait until globally idle.” StoreKit-style monitors -are intentionally long-lived, so process-wide quiescence is not a meaningful -state. A future batch API may expose a cutoff receipt for commands admitted -before a sequence number, but it must not define completion as all producer -tasks exiting. +The scoped function owns construction and cleanup. It closes and drains the +store before returning on success, failure, or cancellation, then rethrows the +operation error. A retained harness value is already closed after the scope. + +Construction completes an empty synthetic entitlement query before invoking the +closure, so the initial state is `.ready` with empty raw and typed collections. +The harness validates that the supplied group and Product ID belong to the +catalog. It accepts a Product ID rather than an entitlement because an +entitlement cannot be reverse-mapped to one monthly or yearly product. + +A group whose ID differs from the catalog throws +`subscriptionGroupMismatch(expected:actual:)`. A different group declaration +that reuses the same ID throws +`subscriptionGroupTypeMismatch(subscriptionGroupID:)`; the catalog declaration +and mapping remain authoritative. A raw Product ID absent from that declaration +throws `undeclaredProduct(productID:subscriptionGroupID:)`. All checks complete +before synthetic transaction admission, invoke no delegate method, and leave +state unchanged. + +`purchase(_:,in:)` returns only after: + +1. The synthetic transaction is admitted through the production direct path. +2. The delegate or `.automatic` resolver returns a policy. +3. The synthetic transaction is acknowledged as finished. +4. Current synthetic entitlements are reconciled and projected. +5. `TransactionStore` publishes the resulting state on `@MainActor`. + +It returns the completed `StoreTransactionSnapshot` only after that receipt +completes. Pre-admission harness validation and delegate decision failures throw +directly and are not duplicated as background failures. A refresh or projection +failure after synthetic acknowledgement follows the production +`entitlementRefreshFailed(after: .finishedTransaction(...))` contract. +Supplying a delegate tests the app's real policy decision; the harness does not +add a second public failure-capture state. + +A later `purchase` of another Product ID in the same group removes the prior +snapshot from the synthetic current-entitlement set before the new causal +refresh. It neither retains that snapshot nor marks it `isUpgraded`. This +supports a deterministic tier1-to-tier2 ViewModel test while explicitly +modeling only an immediately effective active product, not App Store scheduling +or metadata for upgrades, downgrades, renewals, or billing retry. Expiration, +revocation, and superseded-transaction projection remain app-hosted or +package-level StoreKit scenarios until they have independent public command +contracts. + +The harness exposes no “wait until globally idle”: monitoring tasks are +long-lived, so global quiescence is not meaningful. Each mutating command is its +own completion receipt and does not promise a SwiftUI render pass or completion +of consumer-owned unstructured tasks. + +### Synthetic store operation matrix + +The harness exposes its `TransactionStore` so production ViewModels use their +real dependency. That does not give the lease-exempt synthetic store live +StoreKit authority: + +| Store surface | Synthetic behavior | +| --- | --- | +| Entitlement properties and `isEntitled(to:)` | Read the production availability reducer. | +| `refreshEntitlements()` | Reconcile and publish the synthetic current-entitlement set. | +| `close()` | Drain the synthetic runtime; repeated calls succeed. | +| `process(_:)` | Throw `operationUnavailable(operation: .processPurchase)` before inspecting or finishing a live transaction. | +| `history(for:)` | Throw `operationUnavailable(operation: .history)` before source work. | +| `restorePurchases()` | Throw `operationUnavailable(operation: .restorePurchases)` without calling `AppStore.sync()`. | + +`purchase(_:,in:)` is the only public synthetic mutation command in the initial +surface. Unsupported store operations leave state unchanged and do not invoke +the delegate. A synthetic purchase exercises the production finish-decision +boundary against a synthetic acknowledgement; it never calls +`Transaction.finish()` on a live StoreKit value. ### Clock contract -The Clock controls a real time-dependent suspension in the code under test; it -does not manufacture completion for an otherwise immediate harness command. -For example, an app can inject `any Clock` into its transaction -delegate. The test supplies `TransactionStoreTestClock`, retains the concrete -value, and synchronizes advancement with its explicit registration barrier: +The harness itself accepts no Clock because its production transaction work has +no delay, timeout, or retry policy. `TransactionStoreTestClock` is injected into +the app component that owns time, such as a delegate or ViewModel. Clock +advancement releases sleepers; the purchase receipt still proves entitlement +publication. ```swift final class DelayedTransactionDelegate: TransactionStoreDelegate { @@ -994,8 +1011,8 @@ final class DelayedTransactionDelegate: TransactionStoreDelegate { self.clock = clock } - func transactionStore( - decidePolicyFor transaction: StoreTransactionSnapshot + func decidePolicy( + for transaction: StoreTransactionSnapshot ) async throws -> StoreTransactionHandlingPolicy { try await clock.sleep(for: .seconds(30)) return .finish @@ -1004,6 +1021,7 @@ final class DelayedTransactionDelegate: TransactionStoreDelegate { let clock = TransactionStoreTestClock() let delegate = DelayedTransactionDelegate(clock: clock) + try await withTransactionStoreTestHarness( subscriptionCatalog: subscriptionCatalog, delegate: delegate @@ -1018,7 +1036,6 @@ try await withTransactionStoreTestHarness( } try await clock.waitUntilPendingSleepCount(reaches: 1) - #expect(!viewModel.canExportPDF) clock.advance(by: .seconds(30)) @@ -1028,188 +1045,125 @@ try await withTransactionStoreTestHarness( } ``` -`waitUntilPendingSleepCount(reaches:)` returns when at least that many pending -sleeps have registered. This is the same boundary as waiting until a dependency -has reached its controlled suspension point before asserting intermediate -state. The implementation uses an awaitable continuation-backed barrier rather -than a fixed sleep or a guessed number of `Task.yield()` calls. Cancelling the -barrier throws `CancellationError`. - -Advancing the Clock only makes due sleepers runnable; `purchase.value` remains -the pipeline receipt. Negative clock advances and negative sleeper counts are -programmer errors and fail immediately. Cancelling a sleeping task removes its -sleeper and throws `CancellationError` according to the standard Clock -contract. - -### Harness lifecycle and coverage boundary - -The scoped function stops command admission and drains already admitted work -before closing the underlying store. Cancellation of the task awaiting an -already admitted command does not silently cancel durable transaction decisions; -scope cleanup still establishes terminal completion. The scope drains only -framework-owned work. A consumer task started inside `operation` remains owned -by that operation and must reach its own terminal state before the closure -returns. - -The harness captures background failures in `reportedFailures` and forwards -explicit command errors to the command caller without also appending them as a -background failure. To preserve that ownership rule, `StoreTransactionKit` -exposes a package-scoped Session/TransactionStore seam that stages the synthetic -current state and delegates the command to the runtime's attached direct -`process(_:leases:)` path. It does not use `StoreTransactionSource.runUpdates` -for explicit commands. The harness's forwarding delegate captures background -notifications and, when supplied, forwards decisions and notifications to the -app delegate. No public raw transaction-source protocol or fake -`TransactionStore` is required. - -This layer proves the app catalog, StoreTransactionKit pipeline, and consumer -state integration. The app-hosted `.storekit` suite remains the owner of the -live StoreKit adapter, verification results, StoreKit Test session behavior, -and system integration. +The registration barrier is continuation-backed; tests do not use fixed sleeps +or guessed `Task.yield()` counts. Negative clock advances and negative sleeper +counts are programmer errors. Cancelling a sleeper removes it and throws +`CancellationError` according to the `Clock` contract. + +The public harness initially exposes only `purchase(_:,in:)` as a synthetic +mutation command. App-hosted `.storekit` tests remain responsible for the live +StoreKit adapter, verification, StoreKit Test session behavior, renewal +scheduling, restore UI, history, expiration, and revocation. ## Required contract tests -### Catalog tests - -- Every declared monthly and yearly Product ID maps to its expected entitlement. -- Multiple groups compose into one catalog without type erasure at the call site. -- `including(_:)` does not change the original catalog or share mutable storage. -- An empty `SubscriptionGroupID` fails when the ID value is constructed. -- Empty Product IDs, empty groups, duplicate group IDs, and duplicate Product - IDs fail during catalog construction. -- Duplicate entitlement values remain valid. -- Each `AutoRenewableSubscriptionCatalogError.errorDescription` identifies the - Product ID and relevant expected or actual metadata. -- Known Product ID with a wrong product type fails projection. -- Known Product ID with a wrong or missing group ID fails projection. -- Unknown non-upgraded Product ID inside a managed group fails projection. -- Unknown Product ID outside managed groups remains raw and is ignored by the - typed set. -- A catalog mismatch in one included group fails the complete composed - projection. -- Known and unknown upgraded transactions remain raw, do not require a current - catalog entry, and do not grant typed access. - -### State-owner tests - -- Initial state is `.loading` with both projections `nil`. -- Override initialization publishes `.overridden`, leaves raw `entitlements` - `nil`, and normalizes duplicate input values into one typed set. -- An empty override sequence publishes `.overridden` with an empty, non-`nil` - `activeEntitlements` set. -- Override membership queries return exact set membership. -- Every StoreKit-specific operation on an override store throws - `operationUnavailableInOverride(operation:)` without changing state or - invoking a delegate method; repeated `close()` calls succeed. -- A successful empty query produces `.ready` and two empty collections. -- Startup query, transaction-handling, and catalog failures produce `.failed` - without a partial candidate snapshot when no earlier query has succeeded. -- A later success recovers `.failed` to `.ready` atomically. -- A late startup query failure after a background success preserves `.ready`. -- Explicit and background query or transaction-handling errors after `.ready` - preserve the previous raw and typed snapshot. -- A verified known-tier to unknown-tier change produces `.failed`, clears both - public projections, and makes `isEntitled(to:)` return `false`. -- A coalesced catalog failure does not publish a partial raw or typed snapshot. -- Observation never publishes a status/projection combination outside the state - table. -- `withObservationTracking` observes `isEntitled(to:)` through the private - availability value. -- `isEntitled(to:)` returns `false` for `.loading`, `.failed`, and an available - set without the value, and `true` for a matching ready or overridden - entitlement. - -### Coordination and reporting tests - -- `.automatic` calls StoreKit `finish()` only for a catalog-validated managed - transaction; an explicit `.finish` is the only other path to `finish()`. -- `.automatic` also finishes an upgraded transaction in a managed group after - validating `.autoRenewable`, without requiring its retired Product ID to - remain declared or granting typed access. -- `.automatic` throws `unhandledTransaction` for every unmanaged product, while - invalid catalog metadata for a non-upgraded transaction fails before the - delegate is called. Neither path finishes the transaction. -- Completed-revision suppression is bounded; an evicted exact revision can be - decided again and therefore still requires app-level idempotency. -- `.keepUnfinished` calls neither `finish()` nor - `transactionStore(didFailWith:)`, returns `.keptUnfinished` to a direct - caller, and still completes the causal entitlement publication. -- A revision kept unfinished is decided at most once while its causal receipt is - active across direct, update, and unfinished delivery paths, including - coalesced reservations, and can be decided again only after that receipt - completes. -- A thrown decision error stops candidate publication, reaches an attached - direct caller without a duplicate notification, or reaches - `transactionStore(didFailWith:)` once for background-owned work. -- Physical query completions reach the availability reducer once and in token - order before attached receipts complete. A coalesced completion uses the last - reservation token. -- Startup-owned plain query failure is reported once. -- Startup-owned catalog failure is reported once. -- Background-owner/direct-observer and direct-owner/background-observer catalog - failures each complete state once, deliver the error to the attached direct - caller, and send no background diagnostic. -- Background-owner/startup-observer and startup-owner/background-observer - failures each complete state and diagnostics once. -- A physical failure is reported once if every direct caller abandons it. -- The store retains its delegate until lifecycle completion, and `close()` - drains admitted decisions and notifications. -- A delegate that implements only `transactionStore(didFailWith:)` inherits - `.automatic` and receives an unmanaged background transaction as an - `unhandledTransaction` failure. -- Propagated callback context rejects direct and `Task {}` reentry into - admission-bearing store operations. A detached task does not inherit that - context; the contract test documents the detection boundary without claiming - that detached reentry throws. -- The default no-op `transactionStore(didFailWith:)` does not block the runtime - or alter transaction policy. - -### Integration and distribution tests - -- App-hosted StoreKit tests cover monthly/yearly mapping, upgrades, a known-tier - to unknown-tier transition, restore, revocation, and recovery without fixed - sleeps. -- The external consumer fixture builds the README story using only public API. -- A second external fixture imports `StoreTransactionKitTesting`, starts from a - ready empty set, purchases a typed Product ID, and observes the ViewModel - change immediately after the command returns without a `.storekit` file. -- A harness purchase uses the attached direct-operation path: a delegate, - automatic-handling, or catalog failure reaches the command caller and is not - duplicated in `reportedFailures`; `.completed` and `.keptUnfinished` return - only after MainActor publication. -- A time-dependent consumer dependency reaches a registered Clock sleeper, - exposes its intermediate state, advances virtual time, and still waits for - the harness command's MainActor publication receipt. The test contains no - fixed sleeps or guessed `Task.yield()` counts. -- `withTransactionStoreTestHarness` drains and closes after normal return, - operation failure, and cancellation; an operation failure is rethrown only - after cleanup. -- Final scoped cleanup succeeds idempotently when the operation already called - `harness.store.close()`. -- Cancellation before command admission creates no transaction; cancellation - after admission is still drained by scoped cleanup. -- The testing product cannot be imported transitively by a consumer that - depends only on the production product. -- Swift 6 strict-concurrency builds prove the primary-associated-type and - `Clock` existential, class and actor delegate conformances, and `Sendable` - surfaces. -- DocC builds without warnings after symbol documentation is added. +### Catalog and state + +- Every monthly and yearly Product ID maps to its expected entitlement. +- Empty group IDs, Product IDs, and Product ID case sets fail at construction. +- Duplicate raw Product IDs fail; duplicate entitlement values remain valid. +- Known Product IDs with a wrong type or group fail projection. +- An undeclared non-upgraded Product ID in the managed group fails projection. +- An undeclared upgraded Product ID in the managed group with a non-auto- + renewable type fails with `productTypeMismatch` before delegate policy. +- An external-group Product ID remains raw and does not enter the typed set. +- An upgraded managed-group transaction remains raw, grants no typed access, + and can be handled without retaining a retired Product ID declaration. +- Initial live state, ready-empty state, failed state, and override-empty state + preserve the state table's `nil` distinctions. +- Every publication changes raw state, typed state, and status atomically. +- Catalog contradictions clear stale access; transient refresh failures preserve + an earlier ready snapshot. +- `isEntitled(to:)` is observed through the single availability owner and uses + exact membership. + +### Processing, failure routing, and lifecycle + +- `.automatic` finishes only a validated managed auto-renewable transaction. +- `.finish` is the only app-selected path to finish an unmanaged transaction. +- A thrown decision performs no finish or causal refresh and is redeliverable. +- A post-finish refresh failure records the revision as completed, throws + `entitlementRefreshFailed(after: .finishedTransaction(...))`, and recovers via + `refreshEntitlements()` without repeating policy or finish. +- A sync failure throws its original error; a post-sync refresh failure throws + the completed-operation error and recovers without repeating sync. +- When process, restore, and plain refresh receipts coalesce on one failed + physical refresh, each direct caller receives its own wrapper or root error; + an abandoned batch produces one background report containing the root error. +- `StorePurchaseOutcome.completed` is returned only after MainActor publication. +- Completed-revision suppression is bounded and never substitutes for an app + ledger. +- Coalesced direct, update, and unfinished deliveries decide one exact revision + once per active receipt. +- Direct errors are not duplicated as background logs or notifications; an + abandoned direct failure transfers to the background owner once. +- Every background failure reaches the internal diagnostic sink once even with + no delegate. A supplied delegate receives it after the related state commit. +- The store retains its delegate until close drains decisions and notifications. +- Direct and inherited child-task callback reentry is rejected. Detached reentry + is documented as unsupported without claiming provenance detection. +- A second live initializer fails across different `Entitlement` types. +- Override and multiple synthetic stores do not consume the live lease. +- Close seals admission before producer shutdown, joins concurrent callers, + ignores waiter cancellation, and releases the lease only after complete drain. +- A producer holding an iteration lease before `next()` processes an element + returned concurrently with close; no later iteration begins after the seal, + and close waits for its policy, finish, refresh, and publication. +- Close completion guarantees no later framework task, callback, or publication. +- Cancellation before admission starts no work; cancellation after admission + detaches the caller and lets the operation complete under background ownership. +- Deinit cancellation retains the live lease until runtime termination. + +### Testing and distribution + +- The external production fixture builds the released README against public API. +- A testing fixture starts ready-empty, purchases a typed Product ID, and reads + its ViewModel change immediately after the command returns without `.storekit`. +- Wrong group ID, substituted group declaration, and undeclared Product ID + commands throw their testing errors before admission and leave state and + delegate calls unchanged. +- A second purchase in the same group replaces the active synthetic product and + publishes the newly mapped entitlement without retaining a synthetic upgraded + snapshot. +- Harness validation and decision errors reach the command caller directly; + failures after acknowledgement use the production completed-action wrapper. +- A synthetic store refreshes its synthetic set and closes normally; process, + history, and restore throw `operationUnavailable` before live StoreKit work. +- Passing a real purchase result to a synthetic store cannot call live + `Transaction.finish()` or bypass the process-wide live lease. +- Scoped cleanup drains after success, failure, and cancellation and remains + idempotent if the operation called `store.close()`. +- A timed app dependency reaches a registered sleeper, exposes intermediate + state, advances virtual time, and still awaits the purchase publication + receipt without fixed sleeps. +- The test clock releases only due sleepers, removes cancelled sleepers, and + resumes a cancelled sleep with `CancellationError`. +- The sleep-registration barrier handles multiple waiters and cancellation + without polling; invalid negative advances or counts fail in subprocess + precondition tests. +- App-hosted StoreKit tests cover monthly/yearly products, real upgrade and + downgrade behavior, restore, renewal, expiration, revocation, and recovery. +- The testing product is not imported transitively by production consumers. +- Swift 6 strict-concurrency builds cover primary-associated-type, Clock + existential, actor/class delegate, and Sendable surfaces. +- Symbol DocC and the consumer article build without warnings. ## Implementation transaction -This public redesign is complete only when one change updates all of the -following: +The redesign is complete only when one change updates all of the following: - Public source and symbol documentation. - Unit and app-hosted StoreKit tests. -- The `StoreTransactionKitTesting` product, its one-way target dependency, and - package-scoped production seams used by its synthetic source. +- The `StoreTransactionKitTesting` product and its one-way target dependency. +- Package-scoped production seams used by the synthetic source. - Production and testing external consumer fixtures. -- README and DocC examples. -- Any dependent app and its resolved package revision. +- The released README and hosted DocC examples. +- Every dependent app and its resolved package revision. -No compatibility alias or deprecated initializer is planned while the package -is beta. +Until that transaction lands, the README must show only the released API. Once +the contract is implemented and moved into symbol DocC and a consumer article, +this proposal is deleted. No compatibility alias or deprecated initializer is +planned while the package is beta. ## References @@ -1220,10 +1174,8 @@ is beta. - [`Transaction.isUpgraded`](https://developer.apple.com/documentation/storekit/transaction/isupgraded) - [`Transaction.currentEntitlements`](https://developer.apple.com/documentation/storekit/transaction/currententitlements) - [`WKNavigationDelegate`](https://developer.apple.com/documentation/webkit/wknavigationdelegate) -- [`WKNavigationResponsePolicy`](https://developer.apple.com/documentation/webkit/wknavigationresponsepolicy) - [`TaskLocal`](https://developer.apple.com/documentation/swift/tasklocal) - [`Task.detached(priority:operation:)`](https://developer.apple.com/documentation/swift/task/detached(priority:operation:)) -- [`SendableMetatype`](https://developer.apple.com/documentation/swift/sendablemetatype) - [`Clock`](https://developer.apple.com/documentation/swift/clock) - [SE-0329: Clock, Instant, and Duration](https://github.com/swiftlang/swift-evolution/blob/main/proposals/0329-clock-instant-duration.md) - [Using Continuations and Clock for deterministic Swift concurrency tests](https://zenn.dev/kntk/articles/2e8d1925b0bb6b) diff --git a/README.md b/README.md index 4e3a3dc..9f00740 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # StoreTransactionKit -StoreTransactionKit moves StoreKit 2 transaction monitoring, verification, -durable processing, and `Transaction.finish()` into one process-owned, +StoreTransactionKit centralizes verified transaction handling, entitlement +reconciliation, and `Transaction.finish()` authority in one process-owned, observable store. ## Requirements @@ -14,91 +14,54 @@ observable store. - visionOS 2.4+ - Swift 6.3+ -## What it owns — and what your app owns +## Installation -The store owns the durable transaction path for the process lifetime: - -- Monitoring: `Transaction.updates`, `Transaction.unfinished` reconciliation, - and subscription status changes -- Verification: only verified transactions reach policy code; direct failures - throw, and an optional delegate can receive background failures -- Ordering: verification, an optional app policy decision, then `finish()` only - when permitted. A bounded process-local cache suppresses recent completed - revisions, while unfinished decisions are coalesced only through their causal - attempt. -- State: the observable current-entitlement projection, restore - synchronization, background failure delivery, and explicit shutdown - -Your app owns everything the user sees and everything it persists: - -- Paywall and purchase UI (StoreKit views or the platform-appropriate - StoreKit purchase action) -- Any app-specific durable ledger used by a transaction delegate -- Subscription status presentation (`Product.SubscriptionInfo.Status`) -- Purchases that begin outside the app on platforms that provide - `PurchaseIntent.intents` +In Xcode, choose **File > Add Package Dependencies**, enter +`https://github.com/lynnswap/StoreTransactionKit`, and add the +`StoreTransactionKit` library to your app target. ## Quick start -The API in this Quick start is proposed for the next beta and is not implemented -in the current source yet. - -Define the app entitlements, then describe one App Store Connect auto-renewable -subscription group with its Product IDs: +Define typed identifiers whose raw values exactly match the Product IDs in App +Store Connect: ```swift import StoreTransactionKit -enum SubscriptionEntitlement: Hashable, Sendable { - case tier1 - case tier2 +enum SubscriptionID: String, Hashable, Sendable { + case monthly = "com.example.subscription.monthly" + case yearly = "com.example.subscription.yearly" } - -enum Plans: AutoRenewableSubscriptionGroup { - static let id = SubscriptionGroupID( - rawValue: "YOUR_SUBSCRIPTION_GROUP_ID" - ) - - enum ProductID: String, CaseIterable { - case tier1_Monthly = "com.example.subscription.tier1.monthly" - case tier1_Yearly = "com.example.subscription.tier1.yearly" - case tier2_Monthly = "com.example.subscription.tier2.monthly" - case tier2_Yearly = "com.example.subscription.tier2.yearly" - } - - static func entitlement( - for productID: ProductID - ) -> SubscriptionEntitlement { - switch productID { - case .tier1_Monthly, .tier1_Yearly: - .tier1 - - case .tier2_Monthly, .tier2_Yearly: - .tier2 - } - } -} - -let subscriptionCatalog = AutoRenewableSubscriptionCatalog(Plans.self) ``` -`TransactionStore` is `@MainActor` and `@Observable`. It starts monitoring -during initialization. Create one store at the process-lifetime composition -root, retain it with SwiftUI state, and inject that same instance into the -environment: +Create one store at the app's process-lifetime composition root. In this +subscription-only example, StoreKit's current-entitlement state is the complete +app effect. If the app also persists an effect, the transaction handler returns +only after that work is complete. The failure handler records failures owned by +background work: ```swift import StoreTransactionKit +import OSLog import SwiftUI @main struct ExampleApp: App { - @State private var store: TransactionStore + @State private var store: TransactionStore init() { + let logger = Logger( + subsystem: "com.example.app", + category: "StoreKit" + ) _store = State( initialValue: TransactionStore( - subscriptionCatalog: subscriptionCatalog + handleTransaction: { _ in }, + reportFailure: { failure in + logger.error( + "StoreKit background failure: \(failure.underlyingError)" + ) + } ) ) } @@ -114,8 +77,8 @@ struct ExampleApp: App { } ``` -Read the store from the environment and gate premium features without making -the rest of the UI depend on entitlement availability: +Read the store directly from the environment. Entitlement availability does not +need to block the rest of the UI: ```swift import StoreKit @@ -123,11 +86,12 @@ import StoreTransactionKit import SwiftUI struct ContentView: View { - @Environment(TransactionStore.self) private var store + @Environment(TransactionStore.self) private var store @State private var isShowingPaywall = false - private var canExportPDF: Bool { - store.isEntitled(to: .tier1) + private var hasPremium: Bool { + store.activeEntitlements? + .isDisjoint(with: [.monthly, .yearly]) == false } var body: some View { @@ -142,7 +106,7 @@ struct ContentView: View { Button("Export as PDF") { exportPDF() } - .disabled(!canExportPDF) + .disabled(!hasPremium) Button("Plans and subscriptions") { isShowingPaywall = true @@ -153,217 +117,54 @@ struct ContentView: View { } .sheet(isPresented: $isShowingPaywall) { SubscriptionStoreView( - groupID: Plans.id.rawValue + groupID: "YOUR_SUBSCRIPTION_GROUP_ID" ) } } } ``` -### Connect the identifiers - -Replace `Plans.id` and the nested Product ID raw values with the identifiers -configured in [App Store Connect][subscription-setup]. Map monthly and yearly -products that grant the same access level to the same app entitlement. StoreKit -owns upgrade and downgrade ordering and each product's renewal period; the -catalog owns the app-access meaning of each Product ID. - -For local StoreKit Testing, use the same values in the active `.storekit` -configuration. See [Setting up StoreKit Testing in Xcode][storekit-testing]. - -No `onInAppPurchaseCompletion` modifier is needed: successful StoreKit view -purchases arrive through `Transaction.updates`, which the store already -monitors. If you add a non-`nil` completion action, it replaces that default -*and* StoreKit's failure alert — pass each `.success` value to -`store.process(_:)` and own `.failure` presentation yourself. - -## Override entitlements +Use the subscription group ID from App Store Connect for +`SubscriptionStoreView`. Use the same Product IDs in the app and in the active +`.storekit` configuration when running local StoreKit tests. -For previews, internal builds, or other app-defined environments that should -bypass StoreKit, provide the exact app entitlements to enable: +No `onInAppPurchaseCompletion` modifier is needed for the default StoreKit-view +flow: successful purchases arrive through `Transaction.updates`, which the store +monitors. If you supply a completion action, pass each successful result to +`store.process(_:)` and present failures yourself. -```swift -let store = TransactionStore( - subscriptionCatalog: subscriptionCatalog, - overridingEntitlements: [ - SubscriptionEntitlement.tier1, - .tier2, - ] -) -``` - -## Transaction delegate +## Entitlement availability -Without a delegate, `.automatic` handling finishes only catalog-validated -auto-renewable subscriptions. Supply a delegate when the app handles other -product types, applies another durable effect, or needs background diagnostics: - -```swift -final class AppTransactionDelegate: TransactionStoreDelegate { - func transactionStore( - decidePolicyFor transaction: StoreTransactionSnapshot - ) async throws -> StoreTransactionHandlingPolicy { - guard transaction.productType == .consumable else { - return .automatic - } +- `activeEntitlements == nil` means no entitlement query has succeeded yet. An + empty set means the query succeeded and none of the typed Product IDs is + active. +- `startupError` describes a failed initial readiness attempt. The store keeps + monitoring, and a later successful `refreshEntitlements()` clears it. +- Transactions superseded by a subscription upgrade remain in `entitlements` + but are excluded from `activeEntitlements`. - try await persist(transaction) - return .finish - } +Keep normal app content usable while the entitlement set is unavailable. Gate +only the features that require an active purchase. - func transactionStore( - didFailWith failure: StoreTransactionBackgroundFailure - ) async { - await record(failure) - } -} +## Transaction handling -let store = TransactionStore( - subscriptionCatalog: subscriptionCatalog, - delegate: AppTransactionDelegate() -) -``` +StoreTransactionKit may present the same verified transaction revision to +`handleTransaction` more than once. Make the handler idempotent and return only +after its app-owned business effect is durable; the store calls `finish()` after +the handler succeeds. `reportFailure` receives failures owned by background +work. Neither callback may start another operation on the same store. -Both delegate methods are optional through default implementations. The decision -defaults to `.automatic`; the failure notification defaults to a no-op. - -When the app implements `transactionStore(decidePolicyFor:)`, it owns the -durable correctness of every non-automatic decision: - -- **Be idempotent.** Delivery is at least once; key the ledger on transaction - identity plus the business event it applies. -- **Treat purchase and revocation as distinct events.** A refund or - family-sharing revocation arrives as the same transaction with - `revocationDate` set; transaction ID alone is not a sufficient key. -- **Return `.finish` only after the business effect is durable.** The store then - calls `finish()`. Return `.keepUnfinished` only after deliberately choosing - to leave the transaction eligible for a later attempt; throw when neither - decision can be completed. -- **Don't start another operation on the same store** from either delegate - method, even through an awaited detached task. Calling `process(_:)`, - `refreshEntitlements()`, `history(for:)`, `restorePurchases()`, or `close()` - there can create a dependency cycle with the work being handled. The store - retains its delegate, so any delegate reference back to the store must also be - weak. - -`.automatic` is not unconditional finish: a catalog mismatch fails before the -delegate is called, and a catalog-external product fails as unhandled unless the -delegate explicitly decides how to process it. - -`transactionStore(didFailWith:)` is a notification. Its return cannot change the -transaction decision. - -The full decision, redelivery, and failure-routing contracts are documented in -the [API design](Docs/AutoRenewableSubscriptionCatalogAPI.md). - -## How entitlement availability behaves - -- A live store reports `.loading` before the first readiness result, - `.failed(error)` when no usable catalog projection is available, and `.ready` - when raw and typed entitlement state is available. A store created with - `overridingEntitlements` reports `.overridden` immediately. -- `activeEntitlements` is `nil` while `entitlementStatus` is `.loading` or - `.failed`. It is non-`nil` for `.ready` and `.overridden`; an empty set means - no app entitlement is active. -- `entitlements` contains a verified StoreKit snapshot only for `.ready`. It is - `nil` in override mode because an override does not invent StoreKit - transactions. -- Gate paid features with `isEntitled(to:)` without blocking the surrounding UI. - The query checks exact set membership in both `.ready` and `.overridden`. - Consult `entitlementStatus` only when the app needs to explain where the - entitlement set came from or why it is unavailable. -- A successful refresh after `.failed` publishes `.ready` and the new active - entitlement set. A background query or transaction-handling error after - `.ready` preserves the last active set and reports the failure through - `transactionStore(didFailWith:)`. -- A verified catalog mismatch fails closed: it changes the status to `.failed` - and clears both entitlement projections instead of preserving stale access. -- Startup and every refresh reconcile `Transaction.unfinished` — including - consumables — before publishing state. A transaction-handling error fails that - refresh; the next refresh retries the unfinished work. -- Transactions superseded by a subscription upgrade stay in `entitlements` but - don't appear in `activeEntitlements`. -- Unverified current-entitlement elements are omitted and reported to - `transactionStore(didFailWith:)` with source - `.currentEntitlementVerification`. -- Product IDs mapped to the same app entitlement appear as the same typed value. -- `AutoRenewableSubscriptionCatalog` maps auto-renewable subscriptions only. - Other product types don't belong to subscription groups; consumables remain - part of transaction handling and never appear in current entitlements. - -## Beyond the basics - -- **Custom purchase UI** — load products and start the purchase with StoreKit - views, SwiftUI's `PurchaseAction`, or the platform-appropriate `Product` - purchase API. Pass the resulting `Product.PurchaseResult` to - `store.process(_:)`; after `.pending`, a later completion may arrive through - transaction monitoring and the delegate decision path. -- **Restore** — call `store.restorePurchases()` only from an explicit user - action; `AppStore.sync()` presents authentication UI, and - `StoreKitError.userCancelled` is a normal outcome, not a diagnostic failure. -- **Promoted purchases and win-back offers** — on platforms that provide - `PurchaseIntent.intents`, the app completes each intent's purchase and - passes the result to `store.process(_:)`. -- **Renewal, grace-period, and billing-retry UI** — read - `Product.SubscriptionInfo.Status` directly; the store owns the durable - transaction path, not subscription status presentation. - -For product merchandising and UI composition, use Apple's -[Getting started with In-App Purchase using StoreKit views](https://developer.apple.com/documentation/storekit/getting-started-with-in-app-purchases-using-storekit-views) -and -[Implementing a store in your app using the StoreKit API](https://developer.apple.com/documentation/storekit/implementing-a-store-in-your-app-using-the-storekit-api). -Apple's -[purchase API guidance](https://developer.apple.com/documentation/storekit/product/purchase(options:)) -explains which purchase entry point to use for each UI framework and platform. - -## API design - -See [StoreTransactionKit API design](Docs/AutoRenewableSubscriptionCatalogAPI.md) -for the proposed public interface, validation rules, ownership boundaries, and -state transition contract behind the Quick start. +For the delivery, reconciliation, restore, shutdown, and failure-routing +contracts, see +[Understanding transaction handling][understanding]. ## Testing -App and ViewModel tests can use `StoreTransactionKitTesting` without creating a -`.storekit` configuration: - -```swift -import StoreTransactionKitTesting -import Testing - -@Test -@MainActor -func subscriptionUpdatesViewModel() async throws { - try await withTransactionStoreTestHarness( - subscriptionCatalog: subscriptionCatalog - ) { harness in - let viewModel = NotesViewModel(store: harness.store) - - #expect(!viewModel.canExportPDF) - - try await harness.purchase( - .tier1_Monthly, - in: Plans.self - ) - - #expect(viewModel.canExportPDF) - } -} -``` - -`purchase(_:,in:)` returns after the policy decision, reconciliation, catalog -projection, and the `@MainActor` store publication it caused, so this test does -not need a Clock. Time-driven scenarios inject `TransactionStoreTestClock` into -the app component that owns the delay or deadline. - -The harness tests app state and the StoreTransactionKit pipeline. The separate -app-hosted StoreKit integration suite continues to test the live StoreKit -adapter with `xcodebuild` and a shared configuration. See +The app-hosted StoreKit integration suite runs with `xcodebuild`. See [Tools/TestApp/README.md](Tools/TestApp/README.md) for its scenarios and command. ## License StoreTransactionKit is available under the MIT License. -[subscription-setup]: https://developer.apple.com/help/app-store-connect/manage-subscriptions/offer-auto-renewable-subscriptions -[storekit-testing]: https://developer.apple.com/documentation/xcode/setting-up-storekit-testing-in-xcode +[understanding]: https://lynnswap.github.io/StoreTransactionKit/documentation/storetransactionkit/understandingtransactionhandling From 74d6afc65b613d24701e0065c7715eb63d46cb50 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Thu, 23 Jul 2026 09:35:42 +0900 Subject: [PATCH 09/26] docs(api): redesign subscription catalog declarations Model group membership and entitlement mapping with a result-builder declaration, expose StoreSubscription naming in the consumer API, and align the README with the proposed beta contracts. --- Docs/AutoRenewableSubscriptionCatalogAPI.md | 219 +++++++++++++++----- README.md | 169 +++++++++------ 2 files changed, 273 insertions(+), 115 deletions(-) diff --git a/Docs/AutoRenewableSubscriptionCatalogAPI.md b/Docs/AutoRenewableSubscriptionCatalogAPI.md index cd51694..7f61f7f 100644 --- a/Docs/AutoRenewableSubscriptionCatalogAPI.md +++ b/Docs/AutoRenewableSubscriptionCatalogAPI.md @@ -2,11 +2,12 @@ Status: Proposed for the next beta API. -This document is the source of truth for the proposal only. The public source, -README, and symbol documentation continue to describe the currently released -API until the implementation transaction is complete. After implementation, -the public contracts move to symbol DocC and a consumer article, and this -temporary design document is removed. +This document is the source of truth for the proposal. The README presents its +consumer-facing shape and labels it as proposed; the public source and symbol +documentation continue to describe the currently released API until the +implementation transaction is complete. After implementation, the public +contracts move to symbol DocC and a consumer article, and this temporary design +document is removed. ## Purpose @@ -24,6 +25,8 @@ transaction pipeline without inventing StoreKit transactions in app code. - Scope the Product ID type to one auto-renewable subscription group. - Map multiple billing durations at one access level to one app entitlement. +- Make the subscription declaration the single source of catalog membership and + app entitlement mapping. - Keep StoreKit group levels and renewal periods in StoreKit rather than copying them into the catalog. - Validate every piece of verified transaction metadata that the static catalog @@ -62,9 +65,9 @@ transaction pipeline without inventing StoreKit transactions in app code. ## StoreKit model -An App Store Connect subscription group contains auto-renewable products with -different access levels and durations. A customer holds one subscription -product in a group at a time. Products at one level may have monthly and yearly +An App Store Connect subscription group contains auto-renewable subscriptions +with different access levels and durations. A customer holds one subscription +in a group at a time. Subscriptions at one level may have monthly and yearly variants. StoreKit owns these facts: @@ -94,28 +97,23 @@ enum SubscriptionEntitlement: Hashable, Sendable { case tier2 } -enum Plans: AutoRenewableSubscriptionGroup { +enum Plans: AutoRenewableSubscriptionGroup { static let id = SubscriptionGroupID( rawValue: "YOUR_SUBSCRIPTION_GROUP_ID" ) - enum ProductID: String, CaseIterable { + enum ProductID: String { case tier1_Monthly = "com.example.subscription.tier1.monthly" case tier1_Yearly = "com.example.subscription.tier1.yearly" case tier2_Monthly = "com.example.subscription.tier2.monthly" case tier2_Yearly = "com.example.subscription.tier2.yearly" } - static func entitlement( - for productID: ProductID - ) -> SubscriptionEntitlement { - switch productID { - case .tier1_Monthly, .tier1_Yearly: - .tier1 - - case .tier2_Monthly, .tier2_Yearly: - .tier2 - } + static var subscriptions: StoreSubscriptions { + StoreSubscription(.tier1_Monthly, entitlement: .tier1) + StoreSubscription(.tier1_Yearly, entitlement: .tier1) + StoreSubscription(.tier2_Monthly, entitlement: .tier2) + StoreSubscription(.tier2_Yearly, entitlement: .tier2) } } @@ -196,7 +194,7 @@ struct ContentView: View { } ``` -Monthly and yearly products granting the same access map to the same +Monthly and yearly subscriptions granting the same access map to the same entitlement. StoreKit owns upgrade and downgrade ordering. If several plan identities grant one feature, the app checks the accepted entitlement values; the catalog does not infer tier inclusion. @@ -234,16 +232,57 @@ public struct SubscriptionGroupID: public init(rawValue: String) } +public struct StoreSubscription: + Sendable +where + ProductID: RawRepresentable & Hashable & Sendable, + Entitlement: Hashable & Sendable +{ + public let id: ProductID + public let entitlement: Entitlement + + public init( + _ id: ProductID, + entitlement: Entitlement + ) +} + +@resultBuilder +public struct StoreSubscriptionsBuilder +where + ProductID: RawRepresentable & Hashable & Sendable, + Entitlement: Hashable & Sendable +{ + public typealias Element = + StoreSubscription + + public static func buildExpression( + _ expression: Element + ) -> Element + + public static func buildBlock( + _ first: Element, + _ rest: Element... + ) -> [Element] +} + public protocol AutoRenewableSubscriptionGroup { associatedtype Entitlement: Hashable & Sendable associatedtype ProductID: - RawRepresentable & CaseIterable + RawRepresentable & Hashable & Sendable static var id: SubscriptionGroupID { get } - static func entitlement( - for productID: ProductID - ) -> Entitlement + @StoreSubscriptionsBuilder< + Self.ProductID, + Self.Entitlement + > + static var subscriptions: Self.StoreSubscriptions { get } +} + +public extension AutoRenewableSubscriptionGroup { + typealias StoreSubscriptions = + [StoreSubscription] } public struct AutoRenewableSubscriptionCatalog: Sendable @@ -388,41 +427,89 @@ keys. ### Type-safety boundary The nested Product ID type prevents a Product ID declared for another group -from being passed to a group-specific API. The exhaustive -`entitlement(for:)` switch maps every declared Product ID, and -`SubscriptionGroupID` prevents group IDs from being confused with Product IDs. -Feature code sees the app's `Entitlement`, not raw identifiers. +from being passed to a group-specific API. The primary associated type in +`AutoRenewableSubscriptionGroup` fixes the app +entitlement type at the conformance, and the builder then accepts only that +group's `ProductID` and entitlement values. `SubscriptionGroupID` prevents group +IDs from being confused with Product IDs. Feature code sees the app's +`Entitlement`, not raw identifiers. + +`subscriptions` is the single source of catalog membership and mapping. +Declaring a case or static member on `ProductID` does not by itself make that +identifier a managed subscription; a `StoreSubscription` entry does. +The catalog therefore does not require `CaseIterable` or reconcile a second +list of identifiers with the builder output. The compiler cannot validate App Store Connect. Runtime validation is therefore part of the catalog contract. The name `SubscriptionGroupID` mirrors StoreKit's `subscriptionGroupID`; the auto-renewable qualifier belongs on the group and -catalog types that define product scope. +catalog types that define subscription scope. The client-conformance protocol stays intentionally small. The catalog consumes -`ProductID.allCases` synchronously and stores normalized strings plus the -`ObjectIdentifier` of the declaring group type. It retains no group instance, -group metatype, or typed Product ID. The declaration identity is used only to -prevent a testing command from substituting another conformance with the same -raw identifiers but a different mapping. Future optional metadata belongs in a -configuration value or catalog initializer rather than a new protocol -requirement. +`subscriptions` synchronously and stores normalized strings, entitlement values, +and the `ObjectIdentifier` of the declaring group type. It retains no group +instance, group metatype, or typed Product ID. The declaration identity is used +only to prevent a testing command from substituting another conformance with +the same raw identifiers but a different mapping. Future optional metadata +belongs in a configuration value or catalog initializer rather than a new +protocol requirement. + +### Apple API analog + +This declaration shape follows the Xcode 27 `Evaluations` framework in three +places: `Evaluation.Evaluators` resolves a nested collection alias from a +conformance's associated types, `EvaluatorsBuilder` makes that collection +declarative, and `Evaluator` provides an inline concrete element. Here, +`StoreSubscriptions` resolves to an array of inline `StoreSubscription` values. + +This is an API-shape analog only. StoreTransactionKit does not import +`Evaluations`, and adopting the shape does not raise the package's deployment +targets to OS 27. + +The collection property is named `subscriptions`, matching +`SubscriptionStoreView.init(subscriptions:)`. The unqualified `Subscription` +and `Subscriptions` names are not used: Combine already exports a protocol and +namespace with those exact names. `StoreSubscription` and +`StoreSubscriptions` retain the subscription vocabulary while avoiding that +collision. The enclosing group and catalog types retain the `AutoRenewable` +qualifier because they define the StoreKit product scope. + +The analogy stops at the storage boundary. Evaluations needs +`any EvaluatorProtocol` because one list can contain different +evaluator implementations. Every subscription entry has the same +`ProductID`-plus-`Entitlement` shape, so StoreTransactionKit uses one generic +value and introduces no per-subscription protocol, existential, closure-backed +mapping, or type erasure. + +The group remains a static schema and the catalog initializer continues to take +its metatype. `buildExpression` gives each `StoreSubscription` initializer the +group's concrete `ProductID` and `Entitlement` context. The builder DSL exposes +only those element expressions and flat `buildBlock` composition; it does not +add optional, either, or array syntax. A witness getter can bypass the builder +transformation with an explicit `return`, so the API does not claim to make +runtime-dependent declarations unrepresentable. The catalog evaluates the +getter once during construction, snapshots that returned declaration, and does +not observe later getter results. ### Construction `SubscriptionGroupID.init(rawValue:)` preconditions that its value is not empty. -Catalog construction performs no StoreKit request and preconditions that: +Catalog construction evaluates the static subscription declaration once, +performs no StoreKit request, and preconditions that: -- `ProductID.allCases` is not empty. +- At least one subscription is declared. - Every Product ID raw value is nonempty. - No raw Product ID is repeated within the group. These are static programmer errors, so the initializer remains nonthrowing. Duplicate entitlement values are valid and expected for monthly and yearly -products at one access level. +subscriptions at one access level. Declaration order has no semantic meaning; +the catalog normalizes entries into its lookup. ### Runtime validation and projection -For each verified transaction in a candidate `StoreEntitlements` snapshot, the +A Product ID is declared only when it appears in a `subscriptions` entry. For +each verified transaction in a candidate `StoreEntitlements` snapshot, the catalog applies these rules before publication: 1. A declared Product ID must have `productType == .autoRenewable`. @@ -700,7 +787,7 @@ separate design, not another member of `AutoRenewableSubscriptionGroup`. | Responsibility | Owner | | --- | --- | -| Group ID, Product IDs, and Product ID to app-entitlement mapping | App-defined `AutoRenewableSubscriptionGroup` | +| Group ID, typed Product IDs, catalog membership, and entitlement mapping | App-defined `AutoRenewableSubscriptionGroup.subscriptions` | | Catalog lookup and verified metadata validation | `AutoRenewableSubscriptionCatalog` | | Choosing live or override mode | App composition root | | Fixed override normalization and publication | `TransactionStore` availability owner | @@ -937,11 +1024,11 @@ entitlement cannot be reverse-mapped to one monthly or yearly product. A group whose ID differs from the catalog throws `subscriptionGroupMismatch(expected:actual:)`. A different group declaration that reuses the same ID throws -`subscriptionGroupTypeMismatch(subscriptionGroupID:)`; the catalog declaration -and mapping remain authoritative. A raw Product ID absent from that declaration -throws `undeclaredProduct(productID:subscriptionGroupID:)`. All checks complete -before synthetic transaction admission, invoke no delegate method, and leave -state unchanged. +`subscriptionGroupTypeMismatch(subscriptionGroupID:)`; the catalog's declaring +group and `subscriptions` mapping remain authoritative. A raw Product ID absent +from that declaration throws `undeclaredProduct(productID:subscriptionGroupID:)`. +All checks complete before synthetic transaction admission, invoke no delegate +method, and leave state unchanged. `purchase(_:,in:)` returns only after: @@ -1059,9 +1146,13 @@ scheduling, restore UI, history, expiration, and revocation. ### Catalog and state -- Every monthly and yearly Product ID maps to its expected entitlement. -- Empty group IDs, Product IDs, and Product ID case sets fail at construction. +- Every monthly and yearly `StoreSubscription` maps to its expected + entitlement. +- Empty group IDs, subscription declarations, and Product ID raw values fail at + construction. - Duplicate raw Product IDs fail; duplicate entitlement values remain valid. +- A typed Product ID that has no `subscriptions` entry is not a catalog member + and is rejected by typed testing commands before admission. - Known Product IDs with a wrong type or group fail projection. - An undeclared non-upgraded Product ID in the managed group fails projection. - An undeclared upgraded Product ID in the managed group with a non-auto- @@ -1116,7 +1207,8 @@ scheduling, restore UI, history, expiration, and revocation. ### Testing and distribution -- The external production fixture builds the released README against public API. +- After implementation, the external production fixture builds the README + examples against public API. - A testing fixture starts ready-empty, purchases a typed Product ID, and reads its ViewModel change immediately after the command returns without `.storekit`. - Wrong group ID, substituted group declaration, and undeclared Product ID @@ -1144,8 +1236,10 @@ scheduling, restore UI, history, expiration, and revocation. - App-hosted StoreKit tests cover monthly/yearly products, real upgrade and downgrade behavior, restore, renewal, expiration, revocation, and recovery. - The testing product is not imported transitively by production consumers. -- Swift 6 strict-concurrency builds cover primary-associated-type, Clock - existential, actor/class delegate, and Sendable surfaces. +- Swift 6 strict-concurrency builds cover the specialized primary-associated- + type conformance, nested `StoreSubscriptions` alias, generic + `StoreSubscriptionsBuilder`, Clock existential, actor/class delegate, and + Sendable surfaces. - Symbol DocC and the consumer article build without warnings. ## Implementation transaction @@ -1157,13 +1251,16 @@ The redesign is complete only when one change updates all of the following: - The `StoreTransactionKitTesting` product and its one-way target dependency. - Package-scoped production seams used by the synthetic source. - Production and testing external consumer fixtures. -- The released README and hosted DocC examples. +- The README examples, removal of their proposal label, and hosted DocC + examples. - Every dependent app and its resolved package revision. -Until that transaction lands, the README must show only the released API. Once -the contract is implemented and moved into symbol DocC and a consumer article, -this proposal is deleted. No compatibility alias or deprecated initializer is -planned while the package is beta. +Until that transaction lands, the README labels the consumer sketch as proposed +and the current symbol documentation remains authoritative for compilable API. +Once the contract is implemented, the proposal label is removed and the +contract moves into symbol DocC and a consumer article; this document is then +deleted. No compatibility alias or deprecated initializer is planned while the +package is beta. ## References @@ -1173,6 +1270,14 @@ planned while the package is beta. - [`Product.SubscriptionInfo.subscriptionPeriod`](https://developer.apple.com/documentation/storekit/product/subscriptioninfo/subscriptionperiod) - [`Transaction.isUpgraded`](https://developer.apple.com/documentation/storekit/transaction/isupgraded) - [`Transaction.currentEntitlements`](https://developer.apple.com/documentation/storekit/transaction/currententitlements) +- [`SubscriptionStoreView.init(subscriptions:)`](https://developer.apple.com/documentation/storekit/subscriptionstoreview/init(subscriptions:)) +- [`Combine.Subscription`](https://developer.apple.com/documentation/combine/subscription) +- [`Combine.Subscriptions`](https://developer.apple.com/documentation/combine/subscriptions) +- [`Evaluation`](https://developer.apple.com/documentation/evaluations/evaluation) +- [`Evaluation.Evaluators`](https://developer.apple.com/documentation/evaluations/evaluation/evaluators-swift.typealias) +- [`EvaluatorsBuilder`](https://developer.apple.com/documentation/evaluations/evaluatorsbuilder) +- [`EvaluatorProtocol`](https://developer.apple.com/documentation/evaluations/evaluatorprotocol) +- [`Evaluator`](https://developer.apple.com/documentation/evaluations/evaluator) - [`WKNavigationDelegate`](https://developer.apple.com/documentation/webkit/wknavigationdelegate) - [`TaskLocal`](https://developer.apple.com/documentation/swift/tasklocal) - [`Task.detached(priority:operation:)`](https://developer.apple.com/documentation/swift/task/detached(priority:operation:)) diff --git a/README.md b/README.md index 9f00740..455711a 100644 --- a/README.md +++ b/README.md @@ -22,46 +22,61 @@ In Xcode, choose **File > Add Package Dependencies**, enter ## Quick start -Define typed identifiers whose raw values exactly match the Product IDs in App -Store Connect: +The APIs shown below are proposed for the next beta. The current source does not +implement them yet. + +Define the app's entitlements and one App Store Connect auto-renewable +subscription group: ```swift import StoreTransactionKit -enum SubscriptionID: String, Hashable, Sendable { - case monthly = "com.example.subscription.monthly" - case yearly = "com.example.subscription.yearly" +enum SubscriptionEntitlement: Hashable, Sendable { + case tier1 + case tier2 +} + +enum Plans: AutoRenewableSubscriptionGroup { + static let id = SubscriptionGroupID( + rawValue: "YOUR_SUBSCRIPTION_GROUP_ID" + ) + + enum ProductID: String { + case tier1_Monthly = "com.example.subscription.tier1.monthly" + case tier1_Yearly = "com.example.subscription.tier1.yearly" + case tier2_Monthly = "com.example.subscription.tier2.monthly" + case tier2_Yearly = "com.example.subscription.tier2.yearly" + } + + static var subscriptions: StoreSubscriptions { + StoreSubscription(.tier1_Monthly, entitlement: .tier1) + StoreSubscription(.tier1_Yearly, entitlement: .tier1) + StoreSubscription(.tier2_Monthly, entitlement: .tier2) + StoreSubscription(.tier2_Yearly, entitlement: .tier2) + } } + +let subscriptionCatalog = AutoRenewableSubscriptionCatalog(Plans.self) ``` -Create one store at the app's process-lifetime composition root. In this -subscription-only example, StoreKit's current-entitlement state is the complete -app effect. If the app also persists an effect, the transaction handler returns -only after that work is complete. The failure handler records failures owned by -background work: +Use the subscription group ID and Product IDs exactly as configured in +[App Store Connect][subscription-setup]. Monthly and yearly subscriptions can +grant the same app entitlement. + +Create one store at the app's process-lifetime composition root: ```swift import StoreTransactionKit -import OSLog import SwiftUI @main struct ExampleApp: App { - @State private var store: TransactionStore + @State private var store: TransactionStore init() { - let logger = Logger( - subsystem: "com.example.app", - category: "StoreKit" - ) _store = State( initialValue: TransactionStore( - handleTransaction: { _ in }, - reportFailure: { failure in - logger.error( - "StoreKit background failure: \(failure.underlyingError)" - ) - } + subscriptionCatalog: subscriptionCatalog ) ) } @@ -77,8 +92,8 @@ struct ExampleApp: App { } ``` -Read the store directly from the environment. Entitlement availability does not -need to block the rest of the UI: +Read the store directly and gate only the paid feature. Entitlement +availability does not need to block the rest of the UI: ```swift import StoreKit @@ -86,12 +101,11 @@ import StoreTransactionKit import SwiftUI struct ContentView: View { - @Environment(TransactionStore.self) private var store + @Environment(TransactionStore.self) private var store @State private var isShowingPaywall = false - private var hasPremium: Bool { - store.activeEntitlements? - .isDisjoint(with: [.monthly, .yearly]) == false + private var canExportPDF: Bool { + store.isEntitled(to: .tier1) } var body: some View { @@ -106,7 +120,7 @@ struct ContentView: View { Button("Export as PDF") { exportPDF() } - .disabled(!hasPremium) + .disabled(!canExportPDF) Button("Plans and subscriptions") { isShowingPaywall = true @@ -116,55 +130,94 @@ struct ContentView: View { } } .sheet(isPresented: $isShowingPaywall) { - SubscriptionStoreView( - groupID: "YOUR_SUBSCRIPTION_GROUP_ID" - ) + SubscriptionStoreView(groupID: Plans.id.rawValue) } } } ``` -Use the subscription group ID from App Store Connect for -`SubscriptionStoreView`. Use the same Product IDs in the app and in the active -`.storekit` configuration when running local StoreKit tests. - No `onInAppPurchaseCompletion` modifier is needed for the default StoreKit-view -flow: successful purchases arrive through `Transaction.updates`, which the store -monitors. If you supply a completion action, pass each successful result to -`store.process(_:)` and present failures yourself. +flow. Successful purchases arrive through `Transaction.updates`, which the +store monitors. Use the same Product IDs in the active `.storekit` +configuration when running local StoreKit tests. ## Entitlement availability -- `activeEntitlements == nil` means no entitlement query has succeeded yet. An - empty set means the query succeeded and none of the typed Product IDs is +- `activeEntitlements == nil` means no usable entitlement snapshot is + available. An empty set means the query succeeded and no app entitlement is active. -- `startupError` describes a failed initial readiness attempt. The store keeps - monitoring, and a later successful `refreshEntitlements()` clears it. -- Transactions superseded by a subscription upgrade remain in `entitlements` - but are excluded from `activeEntitlements`. +- `entitlementStatus` explains whether the store is loading, failed, ready, or + using an app-supplied override. +- `isEntitled(to:)` performs exact membership and returns `false` while the + entitlement set is unavailable. Keep normal app content usable while the entitlement set is unavailable. Gate -only the features that require an active purchase. +only features that require an active purchase. -## Transaction handling +## Override entitlements -StoreTransactionKit may present the same verified transaction revision to -`handleTransaction` more than once. Make the handler idempotent and return only -after its app-owned business effect is durable; the store calls `finish()` after -the handler succeeds. `reportFailure` receives failures owned by background -work. Neither callback may start another operation on the same store. +An app-defined debug, preview, or distribution environment can bypass StoreKit +with an exact entitlement set: -For the delivery, reconciliation, restore, shutdown, and failure-routing -contracts, see -[Understanding transaction handling][understanding]. +```swift +let store = TransactionStore( + subscriptionCatalog: subscriptionCatalog, + overridingEntitlements: [ + SubscriptionEntitlement.tier1, + .tier2, + ] +) +``` + +## Transaction delegate + +The delegate is optional. Supply one only when the app owns an additional +durable transaction effect, handles a product outside the subscription catalog, +or needs background-failure notifications. Without a delegate, automatic +handling finishes only catalog-validated auto-renewable subscriptions. + +For policy, redelivery, failure routing, and shutdown contracts, see the +[API design](Docs/AutoRenewableSubscriptionCatalogAPI.md). ## Testing -The app-hosted StoreKit integration suite runs with `xcodebuild`. See -[Tools/TestApp/README.md](Tools/TestApp/README.md) for its scenarios and command. +App and ViewModel tests can use `StoreTransactionKitTesting` without a +`.storekit` configuration: + +```swift +import StoreTransactionKitTesting +import Testing + +@Test +@MainActor +func subscriptionUpdatesViewModel() async throws { + try await withTransactionStoreTestHarness( + subscriptionCatalog: subscriptionCatalog + ) { harness in + let viewModel = NotesViewModel(store: harness.store) + + #expect(!viewModel.canExportPDF) + + try await harness.purchase( + .tier1_Monthly, + in: Plans.self + ) + + #expect(viewModel.canExportPDF) + } +} +``` + +`purchase(_:,in:)` returns after the resulting entitlement publication, so the +test needs no timing guess. Inject `TransactionStoreTestClock` into the app +component that owns a delay or deadline. + +The app-hosted StoreKit integration suite continues to test the live adapter. +See [Tools/TestApp/README.md](Tools/TestApp/README.md) for its scenarios and +command. ## License StoreTransactionKit is available under the MIT License. -[understanding]: https://lynnswap.github.io/StoreTransactionKit/documentation/storetransactionkit/understandingtransactionhandling +[subscription-setup]: https://developer.apple.com/help/app-store-connect/manage-subscriptions/offer-auto-renewable-subscriptions From 220b407a5484ea24bf7a85ceb8c167cec8f4d176 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Thu, 23 Jul 2026 09:50:48 +0900 Subject: [PATCH 10/26] feat(api)!: add transaction policy and state primitives Expose entitlement readiness, delegate policy, and completed-action error context. Rename the refresh operation case to match the public API. --- .../Entitlements/EntitlementStatus.swift | 16 +++ .../Runtime/StoreTransactionRuntime.swift | 2 +- .../StoreTransactionFailure.swift | 36 ++++++- .../StoreTransactionSession.swift | 2 +- .../TransactionStore.swift | 2 +- .../TransactionStoreDelegate.swift | 48 +++++++++ .../DirectOperationReportingTests.swift | 4 +- .../PublicPolicyStateTests.swift | 99 +++++++++++++++++++ .../RuntimeContractTests.swift | 2 +- .../StoreTransactionKitTests/StoreTests.swift | 2 +- .../StoreTransactionSessionTests.swift | 2 +- 11 files changed, 204 insertions(+), 11 deletions(-) create mode 100644 Sources/StoreTransactionKit/Entitlements/EntitlementStatus.swift create mode 100644 Sources/StoreTransactionKit/TransactionStoreDelegate.swift create mode 100644 Tests/StoreTransactionKitTests/PublicPolicyStateTests.swift diff --git a/Sources/StoreTransactionKit/Entitlements/EntitlementStatus.swift b/Sources/StoreTransactionKit/Entitlements/EntitlementStatus.swift new file mode 100644 index 0000000..20ec6ba --- /dev/null +++ b/Sources/StoreTransactionKit/Entitlements/EntitlementStatus.swift @@ -0,0 +1,16 @@ +/// The availability of the store's typed entitlement projection. +public enum EntitlementStatus: Sendable { + /// No entitlement readiness attempt has completed. + case loading + + /// No usable complete entitlement snapshot is available. + /// + /// The associated error explains why entitlement readiness failed. + case failed(any Error) + + /// A complete live entitlement snapshot is available. + case ready + + /// App-supplied entitlements are authoritative instead of StoreKit state. + case overridden +} diff --git a/Sources/StoreTransactionKit/Runtime/StoreTransactionRuntime.swift b/Sources/StoreTransactionKit/Runtime/StoreTransactionRuntime.swift index d7438c8..c8ecf51 100644 --- a/Sources/StoreTransactionKit/Runtime/StoreTransactionRuntime.swift +++ b/Sources/StoreTransactionKit/Runtime/StoreTransactionRuntime.swift @@ -288,7 +288,7 @@ package final class StoreTransactionRuntime: Sendable { binding: binding, propagating: error, reportsWhenAbandoned: refresh.role == .owner, - operation: .currentEntitlements, + operation: .refreshEntitlements, snapshot: nil ) ) diff --git a/Sources/StoreTransactionKit/StoreTransactionFailure.swift b/Sources/StoreTransactionKit/StoreTransactionFailure.swift index 2b01604..3ddd2fd 100644 --- a/Sources/StoreTransactionKit/StoreTransactionFailure.swift +++ b/Sources/StoreTransactionKit/StoreTransactionFailure.swift @@ -1,4 +1,5 @@ import Foundation +import StoreKit /// A store operation that can appear in lifecycle and diagnostic errors. public enum StoreTransactionOperation: Sendable, Hashable { @@ -6,7 +7,7 @@ public enum StoreTransactionOperation: Sendable, Hashable { case processPurchase /// Refreshing the current entitlement projection. - case currentEntitlements + case refreshEntitlements /// Querying transaction history for one product. case history @@ -94,8 +95,17 @@ package struct StoreTransactionFailurePropagation: Sendable { } } -/// An error caused by using a store outside its documented lifecycle. -public enum StoreTransactionError: Error, Sendable, Hashable { +/// An error produced while operating a transaction store. +public enum StoreTransactionError: Error, Sendable { + /// An irreversible StoreKit action that completed before a later operation failed. + public enum CompletedOperation: Sendable, Hashable { + /// The framework finished the exact transaction revision. + case finishedTransaction(StoreTransactionSnapshot) + + /// The framework successfully synchronized purchases with the App Store. + case synchronizedPurchases + } + /// The store has begun its shared close operation and accepts no new work. case closing @@ -105,6 +115,12 @@ public enum StoreTransactionError: Error, Sendable, Hashable { /// StoreKit returned a purchase result unknown to this framework version. case unknownPurchaseResult + /// Automatic handling was requested for a product outside the managed catalog. + case unhandledTransaction( + productID: Product.ID, + productType: Product.ProductType + ) + /// A consumer callback attempted to reenter its own session. /// /// Reentrancy with propagated callback context is rejected because a @@ -113,6 +129,20 @@ public enum StoreTransactionError: Error, Sendable, Hashable { /// detached task that calls the same store: detached tasks don't carry the /// callback context but can still create the same dependency cycle. case reentrantOperation(operation: StoreTransactionOperation) + + /// A StoreKit-backed operation was requested from a fixed override store. + case operationUnavailableInOverride( + operation: StoreTransactionOperation + ) + + /// Entitlement refresh failed after an irreversible StoreKit action completed. + /// + /// Retry ``TransactionStore/refreshEntitlements()`` instead of repeating + /// the completed action. + case entitlementRefreshFailed( + after: CompletedOperation, + underlyingError: any Error + ) } package enum StoreTransactionLifecycleError: Error, Sendable { diff --git a/Sources/StoreTransactionKit/StoreTransactionSession.swift b/Sources/StoreTransactionKit/StoreTransactionSession.swift index 1f06574..65701aa 100644 --- a/Sources/StoreTransactionKit/StoreTransactionSession.swift +++ b/Sources/StoreTransactionKit/StoreTransactionSession.swift @@ -179,7 +179,7 @@ package actor StoreTransactionSession { /// - Throws: A StoreKit verification or query error, a lifecycle or callback /// reentrancy error, or `CancellationError` for an abandoned waiter. package func currentEntitlements() async throws -> StoreEntitlements { - let runtime = try runningRuntime(operation: .currentEntitlements) + let runtime = try runningRuntime(operation: .refreshEntitlements) guard let leases = runtime.beginOperation() else { throw StoreTransactionError.closing } diff --git a/Sources/StoreTransactionKit/TransactionStore.swift b/Sources/StoreTransactionKit/TransactionStore.swift index 3cd518d..6a45003 100644 --- a/Sources/StoreTransactionKit/TransactionStore.swift +++ b/Sources/StoreTransactionKit/TransactionStore.swift @@ -162,7 +162,7 @@ where /// - Returns: The complete verified entitlement projection. @discardableResult public func refreshEntitlements() async throws -> StoreEntitlements { - try await waitForStartupAttempt(operation: .currentEntitlements) + try await waitForStartupAttempt(operation: .refreshEntitlements) return try await transactionSession.currentEntitlements() } diff --git a/Sources/StoreTransactionKit/TransactionStoreDelegate.swift b/Sources/StoreTransactionKit/TransactionStoreDelegate.swift new file mode 100644 index 0000000..f41a589 --- /dev/null +++ b/Sources/StoreTransactionKit/TransactionStoreDelegate.swift @@ -0,0 +1,48 @@ +/// The action StoreTransactionKit takes after classifying a verified transaction. +public enum StoreTransactionHandlingPolicy: Sendable, Hashable { + /// Finishes a catalog-managed transaction without an app-owned business effect. + /// + /// StoreTransactionKit rejects this policy for a transaction outside the + /// managed subscription catalog. + case automatic + + /// Finishes a transaction after the app has durably applied its business effect. + case finish +} + +/// Receives transaction decisions and background failure notifications. +/// +/// The delegate is optional because both requirements have default +/// implementations. Conforming types may use an actor or provide their own +/// synchronization; the protocol does not prescribe an actor. +public protocol TransactionStoreDelegate: AnyObject, Sendable { + /// Chooses how to handle a verified transaction. + /// + /// StoreTransactionKit invokes decisions serially. Throwing prevents the + /// transaction from being finished and prevents its causal entitlement + /// refresh. + func decidePolicy( + for transaction: StoreTransactionSnapshot + ) async throws -> StoreTransactionHandlingPolicy + + /// Notifies the delegate of a failure owned by background work. + /// + /// This notification cannot alter the completed operation or request a + /// retry. When a failure changes observable entitlement state, that state + /// is committed before this method begins. + func didFail( + with failure: StoreTransactionBackgroundFailure + ) async +} + +public extension TransactionStoreDelegate { + func decidePolicy( + for transaction: StoreTransactionSnapshot + ) async throws -> StoreTransactionHandlingPolicy { + .automatic + } + + func didFail( + with failure: StoreTransactionBackgroundFailure + ) async {} +} diff --git a/Tests/StoreTransactionKitTests/DirectOperationReportingTests.swift b/Tests/StoreTransactionKitTests/DirectOperationReportingTests.swift index 7baf55c..aa14bb0 100644 --- a/Tests/StoreTransactionKitTests/DirectOperationReportingTests.swift +++ b/Tests/StoreTransactionKitTests/DirectOperationReportingTests.swift @@ -38,7 +38,7 @@ struct DirectOperationReportingTests { #expect( claimed?.source - == .abandonedDirectOperation(.currentEntitlements) + == .abandonedDirectOperation(.refreshEntitlements) ) #expect(claimed?.transactionID == 2) #expect(claimed?.underlyingError is TestFailure) @@ -64,7 +64,7 @@ struct DirectOperationReportingTests { private func makeReport(id: UInt64) -> StoreTransactionBackgroundFailure { StoreTransactionBackgroundFailure( - source: .abandonedDirectOperation(.currentEntitlements), + source: .abandonedDirectOperation(.refreshEntitlements), transactionID: id, productID: "product-\(id)", underlyingError: TestFailure() diff --git a/Tests/StoreTransactionKitTests/PublicPolicyStateTests.swift b/Tests/StoreTransactionKitTests/PublicPolicyStateTests.swift new file mode 100644 index 0000000..c54fbf8 --- /dev/null +++ b/Tests/StoreTransactionKitTests/PublicPolicyStateTests.swift @@ -0,0 +1,99 @@ +import StoreKit +import Testing +@testable import StoreTransactionKit + +@Suite("Public policy and state primitives") +struct PublicPolicyStateTests { + @Test("a failed entitlement status preserves its underlying error") + func failedEntitlementStatusPreservesError() { + let status = EntitlementStatus.failed(MarkerError(value: 7)) + + guard case .failed(let error) = status else { + Issue.record("Expected a failed entitlement status.") + return + } + #expect(error as? MarkerError == MarkerError(value: 7)) + } + + @Test("the default delegate selects automatic handling") + func defaultDelegatePolicy() async throws { + let delegate: any TransactionStoreDelegate = DefaultDelegate() + let transaction = makeSnapshot(id: 1) + + let policy = try await delegate.decidePolicy(for: transaction) + #expect(policy == .automatic) + + await delegate.didFail( + with: StoreTransactionBackgroundFailure( + source: .updates, + transactionID: transaction.id, + productID: transaction.productID, + underlyingError: MarkerError(value: 1) + )) + } + + @Test("an actor delegate can provide an app-owned finish decision") + func actorDelegatePolicy() async throws { + let delegate: any TransactionStoreDelegate = ActorDelegate() + + let policy = try await delegate.decidePolicy( + for: makeSnapshot(id: 2) + ) + + #expect(policy == .finish) + } + + @Test("completed action errors preserve recovery context") + func completedActionErrorContext() { + let transaction = makeSnapshot(id: 3) + let error = StoreTransactionError.entitlementRefreshFailed( + after: .finishedTransaction(transaction), + underlyingError: MarkerError(value: 3) + ) + + guard + case .entitlementRefreshFailed(let operation, let underlying) = + error + else { + Issue.record("Expected a completed-action refresh failure.") + return + } + #expect( + operation + == .finishedTransaction(transaction) + ) + #expect(underlying as? MarkerError == MarkerError(value: 3)) + } + + @Test("an unhandled transaction records its StoreKit product metadata") + func unhandledTransactionContext() { + let error = StoreTransactionError.unhandledTransaction( + productID: "consumable.product", + productType: .consumable + ) + + guard + case .unhandledTransaction(let productID, let productType) = + error + else { + Issue.record("Expected an unhandled transaction failure.") + return + } + #expect(productID == "consumable.product") + #expect(productType == Product.ProductType.consumable) + } +} + +private struct MarkerError: Error, Sendable, Equatable { + let value: Int +} + +private final class DefaultDelegate: TransactionStoreDelegate {} + +private actor ActorDelegate: TransactionStoreDelegate { + func decidePolicy( + for transaction: StoreTransactionSnapshot + ) async throws -> StoreTransactionHandlingPolicy { + .finish + } +} diff --git a/Tests/StoreTransactionKitTests/RuntimeContractTests.swift b/Tests/StoreTransactionKitTests/RuntimeContractTests.swift index fc96e7e..2765952 100644 --- a/Tests/StoreTransactionKitTests/RuntimeContractTests.swift +++ b/Tests/StoreTransactionKitTests/RuntimeContractTests.swift @@ -298,7 +298,7 @@ struct RuntimeContractTests { handleTransaction: { _ in }, reportFailure: { failure in switch failure.source { - case .abandonedDirectOperation(.currentEntitlements): + case .abandonedDirectOperation(.refreshEntitlements): await reports.append("abandoned-refresh") default: await reports.append("unexpected") diff --git a/Tests/StoreTransactionKitTests/StoreTests.swift b/Tests/StoreTransactionKitTests/StoreTests.swift index 6c17d2a..baaf199 100644 --- a/Tests/StoreTransactionKitTests/StoreTests.swift +++ b/Tests/StoreTransactionKitTests/StoreTests.swift @@ -298,7 +298,7 @@ struct StoreTests { _ = try await holder.get().refreshEntitlements() Issue.record("TransactionStore unexpectedly allowed handler reentry.") } catch StoreTransactionError.reentrantOperation( - operation: .currentEntitlements + operation: .refreshEntitlements ) { await rejected.send() } catch { diff --git a/Tests/StoreTransactionKitTests/StoreTransactionSessionTests.swift b/Tests/StoreTransactionKitTests/StoreTransactionSessionTests.swift index 851ea13..afb56ff 100644 --- a/Tests/StoreTransactionKitTests/StoreTransactionSessionTests.swift +++ b/Tests/StoreTransactionKitTests/StoreTransactionSessionTests.swift @@ -710,7 +710,7 @@ struct StoreTransactionSessionTests { _ = try await holder.get().currentEntitlements() await observations.append("entitlements-unexpected-success") } catch StoreTransactionError.reentrantOperation( - operation: .currentEntitlements + operation: .refreshEntitlements ) { await observations.append("entitlements-rejected") } catch { From b6d18fcf86c788cc5e32936a626eea00352486e4 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Thu, 23 Jul 2026 09:51:18 +0900 Subject: [PATCH 11/26] feat(testing): add deterministic transaction store clock --- Package.swift | 16 +- .../TransactionStoreTestClock.swift | 208 ++++++++++++++++++ .../TransactionStoreTestClockTests.swift | 178 +++++++++++++++ 3 files changed, 401 insertions(+), 1 deletion(-) create mode 100644 Sources/StoreTransactionKitTesting/TransactionStoreTestClock.swift create mode 100644 Tests/StoreTransactionKitTestingTests/TransactionStoreTestClockTests.swift diff --git a/Package.swift b/Package.swift index 174d991..d6077ce 100644 --- a/Package.swift +++ b/Package.swift @@ -22,17 +22,31 @@ let package = Package( .library( name: "StoreTransactionKit", targets: ["StoreTransactionKit"] - ) + ), + .library( + name: "StoreTransactionKitTesting", + targets: ["StoreTransactionKitTesting"] + ), ], targets: [ .target( name: "StoreTransactionKit", swiftSettings: strictSwiftSettings ), + .target( + name: "StoreTransactionKitTesting", + dependencies: ["StoreTransactionKit"], + swiftSettings: strictSwiftSettings + ), .testTarget( name: "StoreTransactionKitTests", dependencies: ["StoreTransactionKit"], swiftSettings: strictSwiftSettings ), + .testTarget( + name: "StoreTransactionKitTestingTests", + dependencies: ["StoreTransactionKitTesting"], + swiftSettings: strictSwiftSettings + ), ] ) diff --git a/Sources/StoreTransactionKitTesting/TransactionStoreTestClock.swift b/Sources/StoreTransactionKitTesting/TransactionStoreTestClock.swift new file mode 100644 index 0000000..a5a5e6c --- /dev/null +++ b/Sources/StoreTransactionKitTesting/TransactionStoreTestClock.swift @@ -0,0 +1,208 @@ +import Synchronization + +/// A manually advanced clock for deterministic tests. +public final class TransactionStoreTestClock: Clock, Sendable { + public typealias Duration = Swift.Duration + + /// An instant in a ``TransactionStoreTestClock`` timeline. + public struct Instant: InstantProtocol, Sendable { + public typealias Duration = Swift.Duration + + /// The origin of a test-clock timeline. + public static let zero: Instant = Instant(offset: .zero) + + private let offset: Duration + + private init(offset: Duration) { + self.offset = offset + } + + public func advanced(by duration: Duration) -> Instant { + Instant(offset: offset + duration) + } + + public func duration(to other: Instant) -> Duration { + other.offset - offset + } + + public static func < (lhs: Instant, rhs: Instant) -> Bool { + lhs.offset < rhs.offset + } + } + + private struct Sleeper { + let id: UInt64 + let deadline: Instant + let continuation: CheckedContinuation + } + + private struct SleepCountWaiter { + let target: Int + let continuation: CheckedContinuation + } + + private struct State { + var now: Instant + var nextID: UInt64 = 0 + var sleepers: [UInt64: Sleeper] = [:] + var sleepCountWaiters: [UInt64: SleepCountWaiter] = [:] + + mutating func takeID() -> UInt64 { + precondition(nextID < .max, "The test clock exhausted its waiter identifiers.") + defer { nextID += 1 } + return nextID + } + + mutating func takeSatisfiedSleepCountWaiters() -> [CheckedContinuation] { + let readyIDs = sleepCountWaiters.compactMap { id, waiter in + waiter.target <= sleepers.count ? id : nil + } + return readyIDs.compactMap { id in + sleepCountWaiters.removeValue(forKey: id)?.continuation + } + } + } + + private enum SleepRegistration { + case pending([CheckedContinuation]) + case elapsed + case cancelled + } + + private enum SleepCountWaiterRegistration { + case pending + case reached + case cancelled + } + + private let state: Mutex + + /// The clock's current virtual instant. + public var now: Instant { + state.withLock { $0.now } + } + + /// The clock's virtual-time resolution. + public var minimumResolution: Duration { + .zero + } + + /// Creates a clock at the supplied virtual instant. + public init(now: Instant = .zero) { + state = Mutex(State(now: now)) + } + + /// Suspends until the deadline is reached by a call to ``advance(by:)``. + /// + /// The clock resumes at the exact deadline boundary, so `tolerance` does + /// not alter virtual-time scheduling. + public func sleep( + until deadline: Instant, + tolerance: Duration? + ) async throws { + try Task.checkCancellation() + let id = state.withLock { $0.takeID() } + + try await withTaskCancellationHandler { + try await withCheckedThrowingContinuation { continuation in + let registration = state.withLock { state -> SleepRegistration in + guard !Task.isCancelled else { return .cancelled } + guard deadline > state.now else { return .elapsed } + + state.sleepers[id] = Sleeper( + id: id, + deadline: deadline, + continuation: continuation + ) + return .pending(state.takeSatisfiedSleepCountWaiters()) + } + + switch registration { + case .pending(let waiters): + for waiter in waiters { + waiter.resume() + } + case .elapsed: + continuation.resume() + case .cancelled: + continuation.resume(throwing: CancellationError()) + } + } + } onCancel: { + self.cancelSleeper(id) + } + } + + /// Advances virtual time and resumes every sleeper whose deadline is due. + public func advance(by duration: Duration) { + precondition(duration >= .zero, "A test clock cannot advance by a negative duration.") + + let continuations = state.withLock { state -> [CheckedContinuation] in + state.now = state.now.advanced(by: duration) + let dueIDs = state.sleepers.values + .filter { $0.deadline <= state.now } + .sorted { + if $0.deadline != $1.deadline { + return $0.deadline < $1.deadline + } + return $0.id < $1.id + } + .map(\.id) + return dueIDs.compactMap { id in + state.sleepers.removeValue(forKey: id)?.continuation + } + } + + for continuation in continuations { + continuation.resume() + } + } + + /// Suspends until at least `count` sleeps are registered with the clock. + public func waitUntilPendingSleepCount( + reaches count: Int + ) async throws { + precondition(count >= 0, "A pending sleep count cannot be negative.") + try Task.checkCancellation() + let id = state.withLock { $0.takeID() } + + try await withTaskCancellationHandler { + try await withCheckedThrowingContinuation { continuation in + let registration = state.withLock { state -> SleepCountWaiterRegistration in + guard !Task.isCancelled else { return .cancelled } + guard state.sleepers.count < count else { return .reached } + state.sleepCountWaiters[id] = SleepCountWaiter( + target: count, + continuation: continuation + ) + return .pending + } + + switch registration { + case .pending: + break + case .reached: + continuation.resume() + case .cancelled: + continuation.resume(throwing: CancellationError()) + } + } + } onCancel: { + self.cancelSleepCountWaiter(id) + } + } + + private func cancelSleeper(_ id: UInt64) { + let continuation = state.withLock { state in + state.sleepers.removeValue(forKey: id)?.continuation + } + continuation?.resume(throwing: CancellationError()) + } + + private func cancelSleepCountWaiter(_ id: UInt64) { + let continuation = state.withLock { state in + state.sleepCountWaiters.removeValue(forKey: id)?.continuation + } + continuation?.resume(throwing: CancellationError()) + } +} diff --git a/Tests/StoreTransactionKitTestingTests/TransactionStoreTestClockTests.swift b/Tests/StoreTransactionKitTestingTests/TransactionStoreTestClockTests.swift new file mode 100644 index 0000000..241eaad --- /dev/null +++ b/Tests/StoreTransactionKitTestingTests/TransactionStoreTestClockTests.swift @@ -0,0 +1,178 @@ +import StoreTransactionKitTesting +import Testing + +@Suite("TransactionStoreTestClock", .timeLimit(.minutes(1))) +struct TransactionStoreTestClockTests { + @Test("Instant arithmetic defines the virtual timeline") + func instantArithmetic() { + let origin = TransactionStoreTestClock.Instant.zero + let later = origin.advanced(by: .seconds(3)) + + #expect(origin < later) + #expect(origin.duration(to: later) == .seconds(3)) + #expect(later.duration(to: origin) == .seconds(-3)) + } + + @Test("Initialization selects now and zero resolution") + func initialization() { + let initial = TransactionStoreTestClock.Instant.zero + .advanced(by: .seconds(12)) + let clock = TransactionStoreTestClock(now: initial) + + #expect(clock.now == initial) + #expect(clock.minimumResolution == .zero) + } + + @Test("The clock satisfies a Clock Duration dependency") + func clockDependency() async throws { + let clock = TransactionStoreTestClock() + let dependency: any Clock = clock + let sleeper = Task { + try await dependency.sleep(for: .seconds(2)) + } + + try await clock.waitUntilPendingSleepCount(reaches: 1) + clock.advance(by: .seconds(2)) + + try await sleeper.value + #expect(clock.now == .zero.advanced(by: .seconds(2))) + } + + @Test("Advancing releases only due sleepers") + func releasesOnlyDueSleepers() async throws { + let clock = TransactionStoreTestClock() + let first = Task { + try await clock.sleep( + until: .zero.advanced(by: .seconds(1)), + tolerance: nil + ) + } + let second = Task { + try await clock.sleep( + until: .zero.advanced(by: .seconds(2)), + tolerance: nil + ) + } + let third = Task { + try await clock.sleep( + until: .zero.advanced(by: .seconds(3)), + tolerance: nil + ) + } + + try await clock.waitUntilPendingSleepCount(reaches: 3) + clock.advance(by: .seconds(1)) + try await first.value + + third.cancel() + await #expect(throws: CancellationError.self) { + try await third.value + } + + clock.advance(by: .seconds(1)) + try await second.value + + clock.advance(by: .seconds(10)) + } + + @Test("Deadlines at or before now return immediately") + func elapsedDeadline() async throws { + let clock = TransactionStoreTestClock() + clock.advance(by: .seconds(5)) + + try await clock.sleep( + until: .zero.advanced(by: .seconds(5)), + tolerance: .seconds(1) + ) + try await clock.sleep( + until: .zero.advanced(by: .seconds(4)), + tolerance: nil + ) + } + + @Test("The registration barrier supports multiple waiters") + func multipleRegistrationWaiters() async throws { + let clock = TransactionStoreTestClock() + let firstWaiter = Task { + try await clock.waitUntilPendingSleepCount(reaches: 1) + } + let secondWaiter = Task { + try await clock.waitUntilPendingSleepCount(reaches: 2) + } + let firstSleeper = Task { + try await clock.sleep( + until: .zero.advanced(by: .seconds(1)), + tolerance: nil + ) + } + + try await firstWaiter.value + + let secondSleeper = Task { + try await clock.sleep( + until: .zero.advanced(by: .seconds(2)), + tolerance: nil + ) + } + + try await secondWaiter.value + clock.advance(by: .seconds(2)) + try await firstSleeper.value + try await secondSleeper.value + } + + @Test("Cancelling a sleep removes it and throws CancellationError") + func cancelledSleep() async throws { + let clock = TransactionStoreTestClock() + let sleeper = Task { + try await clock.sleep( + until: .zero.advanced(by: .seconds(1)), + tolerance: nil + ) + } + + try await clock.waitUntilPendingSleepCount(reaches: 1) + sleeper.cancel() + + await #expect(throws: CancellationError.self) { + try await sleeper.value + } + + clock.advance(by: .seconds(1)) + } + + @Test("Cancelling a registration waiter throws CancellationError") + func cancelledRegistrationWaiter() async { + let clock = TransactionStoreTestClock() + let waiter = Task { + try await clock.waitUntilPendingSleepCount(reaches: 1) + } + + waiter.cancel() + + await #expect(throws: CancellationError.self) { + try await waiter.value + } + } + + @Test("Zero pending sleeps is already reached") + func zeroPendingSleeps() async throws { + let clock = TransactionStoreTestClock() + try await clock.waitUntilPendingSleepCount(reaches: 0) + } + + @Test("A negative advance is a programmer error") + func negativeAdvance() async { + await #expect(processExitsWith: .failure) { + TransactionStoreTestClock().advance(by: .seconds(-1)) + } + } + + @Test("A negative pending-sleep target is a programmer error") + func negativePendingSleepCount() async { + await #expect(processExitsWith: .failure) { + try await TransactionStoreTestClock() + .waitUntilPendingSleepCount(reaches: -1) + } + } +} From e4094191c7f2a3a00b449636dadba7c3e1c97c6b Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Thu, 23 Jul 2026 09:55:17 +0900 Subject: [PATCH 12/26] feat(catalog): add typed subscription catalog API --- .../AutoRenewableSubscriptionCatalog.swift | 132 ++++++ ...utoRenewableSubscriptionCatalogError.swift | 42 ++ ...oRenewableSubscriptionClassification.swift | 7 + .../AutoRenewableSubscriptionGroup.swift | 26 + .../Subscriptions/StoreSubscription.swift | 21 + .../StoreSubscriptionsBuilder.swift | 25 + .../Subscriptions/SubscriptionGroupID.swift | 19 + ...utoRenewableSubscriptionCatalogTests.swift | 447 ++++++++++++++++++ 8 files changed, 719 insertions(+) create mode 100644 Sources/StoreTransactionKit/Subscriptions/AutoRenewableSubscriptionCatalog.swift create mode 100644 Sources/StoreTransactionKit/Subscriptions/AutoRenewableSubscriptionCatalogError.swift create mode 100644 Sources/StoreTransactionKit/Subscriptions/AutoRenewableSubscriptionClassification.swift create mode 100644 Sources/StoreTransactionKit/Subscriptions/AutoRenewableSubscriptionGroup.swift create mode 100644 Sources/StoreTransactionKit/Subscriptions/StoreSubscription.swift create mode 100644 Sources/StoreTransactionKit/Subscriptions/StoreSubscriptionsBuilder.swift create mode 100644 Sources/StoreTransactionKit/Subscriptions/SubscriptionGroupID.swift create mode 100644 Tests/StoreTransactionKitTests/AutoRenewableSubscriptionCatalogTests.swift diff --git a/Sources/StoreTransactionKit/Subscriptions/AutoRenewableSubscriptionCatalog.swift b/Sources/StoreTransactionKit/Subscriptions/AutoRenewableSubscriptionCatalog.swift new file mode 100644 index 0000000..495b175 --- /dev/null +++ b/Sources/StoreTransactionKit/Subscriptions/AutoRenewableSubscriptionCatalog.swift @@ -0,0 +1,132 @@ +import StoreKit + +/// A validated mapping from StoreKit products to app-defined entitlements. +public struct AutoRenewableSubscriptionCatalog: Sendable +where Entitlement: Hashable & Sendable { + package let subscriptionGroupID: SubscriptionGroupID + + private let declaringGroupTypeID: ObjectIdentifier + private let entitlementsByProductID: [Product.ID: Entitlement] + + /// Creates and validates a catalog from one auto-renewable subscription group. + public init(_ groupType: Group.Type) + where Group: AutoRenewableSubscriptionGroup { + let subscriptions = Group.subscriptions + + precondition( + !subscriptions.isEmpty, + "An auto-renewable subscription group must declare at least one product." + ) + + var entitlementsByProductID: [Product.ID: Entitlement] = [:] + entitlementsByProductID.reserveCapacity(subscriptions.count) + + for subscription in subscriptions { + let productID = subscription.id.rawValue + + precondition( + !productID.isEmpty, + "A subscription product identifier must not be empty." + ) + precondition( + entitlementsByProductID[productID] == nil, + "A subscription product identifier must not be declared more than once: \(productID)" + ) + + entitlementsByProductID[productID] = subscription.entitlement + } + + subscriptionGroupID = Group.id + declaringGroupTypeID = ObjectIdentifier(groupType) + self.entitlementsByProductID = entitlementsByProductID + } + + package func activeEntitlements( + in entitlements: StoreEntitlements + ) throws -> Set { + var activeEntitlements: Set = [] + + for transaction in entitlements.transactions { + switch try validatedTransaction(transaction) { + case let .declared(entitlement): + if !transaction.isUpgraded { + activeEntitlements.insert(entitlement) + } + + case .retiredUpgraded, .unmanaged: + continue + } + } + + return activeEntitlements + } + + package func classification( + of transaction: StoreTransactionSnapshot + ) throws -> AutoRenewableSubscriptionClassification { + switch try validatedTransaction(transaction) { + case .declared, .retiredUpgraded: + .managed + + case .unmanaged: + .unmanaged + } + } + + package func isDeclared(by groupType: Any.Type) -> Bool { + declaringGroupTypeID == ObjectIdentifier(groupType) + } + + package func contains(productID: Product.ID) -> Bool { + entitlementsByProductID[productID] != nil + } + + private func validatedTransaction( + _ transaction: StoreTransactionSnapshot + ) throws -> ValidatedTransaction { + if let entitlement = entitlementsByProductID[transaction.productID] { + guard transaction.productType == .autoRenewable else { + throw AutoRenewableSubscriptionCatalogError.productTypeMismatch( + productID: transaction.productID, + actual: transaction.productType + ) + } + + guard transaction.subscriptionGroupID == subscriptionGroupID.rawValue else { + throw AutoRenewableSubscriptionCatalogError.subscriptionGroupMismatch( + productID: transaction.productID, + expected: subscriptionGroupID, + actual: transaction.subscriptionGroupID + ) + } + + return .declared(entitlement) + } + + guard transaction.subscriptionGroupID == subscriptionGroupID.rawValue else { + return .unmanaged + } + + guard transaction.isUpgraded else { + throw AutoRenewableSubscriptionCatalogError.undeclaredProduct( + productID: transaction.productID, + subscriptionGroupID: subscriptionGroupID + ) + } + + guard transaction.productType == .autoRenewable else { + throw AutoRenewableSubscriptionCatalogError.productTypeMismatch( + productID: transaction.productID, + actual: transaction.productType + ) + } + + return .retiredUpgraded + } + + private enum ValidatedTransaction { + case declared(Entitlement) + case retiredUpgraded + case unmanaged + } +} diff --git a/Sources/StoreTransactionKit/Subscriptions/AutoRenewableSubscriptionCatalogError.swift b/Sources/StoreTransactionKit/Subscriptions/AutoRenewableSubscriptionCatalogError.swift new file mode 100644 index 0000000..a04ffed --- /dev/null +++ b/Sources/StoreTransactionKit/Subscriptions/AutoRenewableSubscriptionCatalogError.swift @@ -0,0 +1,42 @@ +import Foundation +import StoreKit + +/// An inconsistency between a verified StoreKit transaction and a subscription catalog. +public enum AutoRenewableSubscriptionCatalogError: LocalizedError, Sendable { + /// A current product in the managed group has no catalog declaration. + case undeclaredProduct( + productID: Product.ID, + subscriptionGroupID: SubscriptionGroupID + ) + + /// A catalog product isn't an auto-renewable subscription. + case productTypeMismatch( + productID: Product.ID, + actual: Product.ProductType + ) + + /// A catalog product belongs to a different subscription group. + case subscriptionGroupMismatch( + productID: Product.ID, + expected: SubscriptionGroupID, + actual: String? + ) + + /// A localized description of the catalog inconsistency. + public var errorDescription: String? { + switch self { + case let .undeclaredProduct(productID, subscriptionGroupID): + "Product \(productID) is not declared in subscription group " + + "\(subscriptionGroupID.rawValue)." + + case let .productTypeMismatch(productID, actual): + "Product \(productID) has type \(actual); expected an " + + "auto-renewable subscription." + + case let .subscriptionGroupMismatch(productID, expected, actual): + "Product \(productID) belongs to subscription group " + + (actual ?? "nil") + + "; expected \(expected.rawValue)." + } + } +} diff --git a/Sources/StoreTransactionKit/Subscriptions/AutoRenewableSubscriptionClassification.swift b/Sources/StoreTransactionKit/Subscriptions/AutoRenewableSubscriptionClassification.swift new file mode 100644 index 0000000..bd2b1f3 --- /dev/null +++ b/Sources/StoreTransactionKit/Subscriptions/AutoRenewableSubscriptionClassification.swift @@ -0,0 +1,7 @@ +package enum AutoRenewableSubscriptionClassification: + Equatable, + Sendable +{ + case managed + case unmanaged +} diff --git a/Sources/StoreTransactionKit/Subscriptions/AutoRenewableSubscriptionGroup.swift b/Sources/StoreTransactionKit/Subscriptions/AutoRenewableSubscriptionGroup.swift new file mode 100644 index 0000000..03c9d7b --- /dev/null +++ b/Sources/StoreTransactionKit/Subscriptions/AutoRenewableSubscriptionGroup.swift @@ -0,0 +1,26 @@ +/// A typed declaration of one App Store auto-renewable subscription group. +public protocol AutoRenewableSubscriptionGroup { + /// The app-defined access value granted by the group's products. + associatedtype Entitlement: Hashable & Sendable + + /// A typed product identifier declared by this group. + associatedtype + ProductID: + RawRepresentable & Hashable & Sendable + + /// The group's identifier in App Store Connect. + static var id: SubscriptionGroupID { get } + + /// The products managed by the group and the entitlement each one grants. + @StoreSubscriptionsBuilder< + Self.ProductID, + Self.Entitlement + > + static var subscriptions: Self.StoreSubscriptions { get } +} + +public extension AutoRenewableSubscriptionGroup { + /// The concrete collection produced by the subscription builder. + typealias StoreSubscriptions = + [StoreSubscription] +} diff --git a/Sources/StoreTransactionKit/Subscriptions/StoreSubscription.swift b/Sources/StoreTransactionKit/Subscriptions/StoreSubscription.swift new file mode 100644 index 0000000..560d582 --- /dev/null +++ b/Sources/StoreTransactionKit/Subscriptions/StoreSubscription.swift @@ -0,0 +1,21 @@ +/// A product declaration and the app entitlement it grants. +public struct StoreSubscription: Sendable +where + ProductID: RawRepresentable & Hashable & Sendable, + Entitlement: Hashable & Sendable +{ + /// The product identifier configured in App Store Connect. + public let id: ProductID + + /// The app-defined entitlement granted by the product. + public let entitlement: Entitlement + + /// Declares the entitlement granted by a subscription product. + public init( + _ id: ProductID, + entitlement: Entitlement + ) { + self.id = id + self.entitlement = entitlement + } +} diff --git a/Sources/StoreTransactionKit/Subscriptions/StoreSubscriptionsBuilder.swift b/Sources/StoreTransactionKit/Subscriptions/StoreSubscriptionsBuilder.swift new file mode 100644 index 0000000..87a7f12 --- /dev/null +++ b/Sources/StoreTransactionKit/Subscriptions/StoreSubscriptionsBuilder.swift @@ -0,0 +1,25 @@ +/// Builds the subscription declarations for an auto-renewable subscription group. +@resultBuilder +public struct StoreSubscriptionsBuilder +where + ProductID: RawRepresentable & Hashable & Sendable, + Entitlement: Hashable & Sendable +{ + public typealias Element = + StoreSubscription + + /// Adds one subscription declaration to the group. + public static func buildExpression( + _ expression: Element + ) -> Element { + expression + } + + /// Builds a nonempty subscription declaration. + public static func buildBlock( + _ first: Element, + _ rest: Element... + ) -> [Element] { + [first] + rest + } +} diff --git a/Sources/StoreTransactionKit/Subscriptions/SubscriptionGroupID.swift b/Sources/StoreTransactionKit/Subscriptions/SubscriptionGroupID.swift new file mode 100644 index 0000000..6428a93 --- /dev/null +++ b/Sources/StoreTransactionKit/Subscriptions/SubscriptionGroupID.swift @@ -0,0 +1,19 @@ +/// The identifier of an auto-renewable subscription group in App Store Connect. +public struct SubscriptionGroupID: + RawRepresentable, + Hashable, + Sendable +{ + /// The identifier configured in App Store Connect. + public let rawValue: String + + /// Creates a subscription group identifier from its App Store Connect value. + public init(rawValue: String) { + precondition( + !rawValue.isEmpty, + "A subscription group identifier must not be empty." + ) + + self.rawValue = rawValue + } +} diff --git a/Tests/StoreTransactionKitTests/AutoRenewableSubscriptionCatalogTests.swift b/Tests/StoreTransactionKitTests/AutoRenewableSubscriptionCatalogTests.swift new file mode 100644 index 0000000..b694939 --- /dev/null +++ b/Tests/StoreTransactionKitTests/AutoRenewableSubscriptionCatalogTests.swift @@ -0,0 +1,447 @@ +import Foundation +import StoreKit +import Synchronization +import Testing +@testable import StoreTransactionKit + +@Suite("Auto-renewable subscription catalog") +struct AutoRenewableSubscriptionCatalogTests { + @Test("the declaration is evaluated once and normalized into one lookup") + func evaluatesDeclarationOnce() throws { + countingSubscriptionsAccessCount.withLock { count in + count = 0 + } + + let catalog = AutoRenewableSubscriptionCatalog(CountingPlans.self) + + #expect(countingSubscriptionsAccessCount.withLock { $0 } == 1) + #expect(catalog.subscriptionGroupID == CountingPlans.id) + #expect(catalog.isDeclared(by: CountingPlans.self)) + #expect(!catalog.isDeclared(by: OtherPlans.self)) + #expect(catalog.contains(productID: CountingPlans.ProductID.monthly.rawValue)) + #expect(!catalog.contains(productID: "com.example.subscription.undeclared")) + + let transaction = subscriptionSnapshot( + id: 0, + productID: CountingPlans.ProductID.monthly.rawValue, + subscriptionGroupID: CountingPlans.id.rawValue + ) + #expect(try catalog.classification(of: transaction) == .managed) + #expect( + try catalog.activeEntitlements( + in: StoreEntitlements(transactions: [transaction]) + ) == [.tier1] + ) + #expect(countingSubscriptionsAccessCount.withLock { $0 } == 1) + } + + @Test("monthly and yearly products project to their declared entitlements") + func projectsDeclaredEntitlements() throws { + let catalog = AutoRenewableSubscriptionCatalog(Plans.self) + let entitlements = StoreEntitlements( + transactions: [ + subscriptionSnapshot( + id: 1, + productID: Plans.ProductID.tier1_Monthly.rawValue + ), + subscriptionSnapshot( + id: 2, + productID: Plans.ProductID.tier1_Yearly.rawValue + ), + subscriptionSnapshot( + id: 3, + productID: Plans.ProductID.tier2_Monthly.rawValue + ), + subscriptionSnapshot( + id: 4, + productID: Plans.ProductID.tier2_Yearly.rawValue + ), + ] + ) + + #expect( + try catalog.activeEntitlements(in: entitlements) + == [.tier1, .tier2] + ) + } + + @Test("an upgraded declared product remains managed without granting access") + func upgradedDeclaredProductDoesNotGrantAccess() throws { + let catalog = AutoRenewableSubscriptionCatalog(Plans.self) + let transaction = subscriptionSnapshot( + id: 5, + productID: Plans.ProductID.tier1_Monthly.rawValue, + isUpgraded: true + ) + + #expect(try catalog.classification(of: transaction) == .managed) + #expect( + try catalog.activeEntitlements( + in: StoreEntitlements(transactions: [transaction]) + ).isEmpty + ) + } + + @Test("a retired upgraded product remains managed without granting access") + func retiredUpgradedProductDoesNotGrantAccess() throws { + let catalog = AutoRenewableSubscriptionCatalog(Plans.self) + let transaction = subscriptionSnapshot( + id: 6, + productID: "com.example.subscription.retired", + isUpgraded: true + ) + + #expect(try catalog.classification(of: transaction) == .managed) + #expect( + try catalog.activeEntitlements( + in: StoreEntitlements(transactions: [transaction]) + ).isEmpty + ) + } + + @Test("a product outside the catalog group remains unmanaged and unprojected") + func externalProductIsUnmanaged() throws { + let catalog = AutoRenewableSubscriptionCatalog(Plans.self) + let transaction = subscriptionSnapshot( + id: 7, + productID: "com.example.other.product", + subscriptionGroupID: "other-group" + ) + + #expect(try catalog.classification(of: transaction) == .unmanaged) + #expect( + try catalog.activeEntitlements( + in: StoreEntitlements(transactions: [transaction]) + ).isEmpty + ) + } + + @Test("a declared product with a wrong StoreKit type fails validation") + func declaredProductTypeMismatch() { + let catalog = AutoRenewableSubscriptionCatalog(Plans.self) + let productID = Plans.ProductID.tier1_Monthly.rawValue + + do { + _ = try catalog.activeEntitlements( + in: StoreEntitlements( + transactions: [ + subscriptionSnapshot( + id: 8, + productID: productID, + productType: .nonConsumable + ) + ] + ) + ) + Issue.record("Projection unexpectedly accepted a non-consumable product.") + } catch let error as AutoRenewableSubscriptionCatalogError { + guard case let .productTypeMismatch(actualProductID, actual) = error else { + Issue.record("Unexpected catalog error: \(error)") + return + } + + #expect(actualProductID == productID) + #expect(actual == .nonConsumable) + } catch { + Issue.record("Unexpected error: \(error)") + } + } + + @Test("a declared product with a wrong group fails validation") + func declaredProductGroupMismatch() { + let catalog = AutoRenewableSubscriptionCatalog(Plans.self) + let productID = Plans.ProductID.tier1_Monthly.rawValue + + do { + _ = try catalog.classification( + of: subscriptionSnapshot( + id: 9, + productID: productID, + subscriptionGroupID: "other-group" + ) + ) + Issue.record("Classification unexpectedly accepted the wrong group.") + } catch let error as AutoRenewableSubscriptionCatalogError { + guard + case let .subscriptionGroupMismatch( + actualProductID, + expected, + actual + ) = error + else { + Issue.record("Unexpected catalog error: \(error)") + return + } + + #expect(actualProductID == productID) + #expect(expected == Plans.id) + #expect(actual == "other-group") + } catch { + Issue.record("Unexpected error: \(error)") + } + } + + @Test("an undeclared current product in the managed group fails validation") + func undeclaredCurrentProduct() { + let catalog = AutoRenewableSubscriptionCatalog(Plans.self) + let productID = "com.example.subscription.undeclared" + + do { + _ = try catalog.activeEntitlements( + in: StoreEntitlements( + transactions: [ + subscriptionSnapshot( + id: 10, + productID: productID + ) + ] + ) + ) + Issue.record("Projection unexpectedly accepted an undeclared product.") + } catch let error as AutoRenewableSubscriptionCatalogError { + guard + case let .undeclaredProduct( + actualProductID, + subscriptionGroupID + ) = error + else { + Issue.record("Unexpected catalog error: \(error)") + return + } + + #expect(actualProductID == productID) + #expect(subscriptionGroupID == Plans.id) + } catch { + Issue.record("Unexpected error: \(error)") + } + } + + @Test("an undeclared upgraded product still requires an auto-renewable type") + func retiredProductTypeMismatch() { + let catalog = AutoRenewableSubscriptionCatalog(Plans.self) + let productID = "com.example.subscription.retired" + + do { + _ = try catalog.classification( + of: subscriptionSnapshot( + id: 11, + productID: productID, + productType: .nonRenewable, + isUpgraded: true + ) + ) + Issue.record("Classification unexpectedly accepted the wrong type.") + } catch let error as AutoRenewableSubscriptionCatalogError { + guard case let .productTypeMismatch(actualProductID, actual) = error else { + Issue.record("Unexpected catalog error: \(error)") + return + } + + #expect(actualProductID == productID) + #expect(actual == .nonRenewable) + } catch { + Issue.record("Unexpected error: \(error)") + } + } + + @Test("projection validates the complete candidate before returning a set") + func projectionIsAllOrNothing() { + let catalog = AutoRenewableSubscriptionCatalog(Plans.self) + + do { + _ = try catalog.activeEntitlements( + in: StoreEntitlements( + transactions: [ + subscriptionSnapshot( + id: 12, + productID: Plans.ProductID.tier1_Monthly.rawValue + ), + subscriptionSnapshot( + id: 13, + productID: "com.example.subscription.undeclared" + ), + ] + ) + ) + Issue.record("Projection unexpectedly returned a partial set.") + } catch let error as AutoRenewableSubscriptionCatalogError { + guard case .undeclaredProduct = error else { + Issue.record("Unexpected catalog error: \(error)") + return + } + } catch { + Issue.record("Unexpected error: \(error)") + } + } + + @Test("an empty group identifier is a construction error") + func emptyGroupIDFails() async { + await #expect(processExitsWith: .failure) { + _ = SubscriptionGroupID(rawValue: "") + } + } + + @Test("an empty subscription declaration is a construction error") + func emptyDeclarationFails() async { + await #expect(processExitsWith: .failure) { + _ = AutoRenewableSubscriptionCatalog(EmptyPlans.self) + } + } + + @Test("an empty product identifier is a construction error") + func emptyProductIDFails() async { + await #expect(processExitsWith: .failure) { + _ = AutoRenewableSubscriptionCatalog(EmptyProductIDPlans.self) + } + } + + @Test("duplicate raw product identifiers are a construction error") + func duplicateProductIDFails() async { + await #expect(processExitsWith: .failure) { + _ = AutoRenewableSubscriptionCatalog(DuplicateProductIDPlans.self) + } + } +} + +private enum SubscriptionEntitlement: Hashable, Sendable { + case tier1 + case tier2 +} + +private enum Plans: + AutoRenewableSubscriptionGroup +{ + static let id = SubscriptionGroupID(rawValue: "example-group") + + enum ProductID: String, Hashable, Sendable { + case tier1_Monthly = "com.example.subscription.tier1.monthly" + case tier1_Yearly = "com.example.subscription.tier1.yearly" + case tier2_Monthly = "com.example.subscription.tier2.monthly" + case tier2_Yearly = "com.example.subscription.tier2.yearly" + } + + static var subscriptions: StoreSubscriptions { + StoreSubscription(.tier1_Monthly, entitlement: .tier1) + StoreSubscription(.tier1_Yearly, entitlement: .tier1) + StoreSubscription(.tier2_Monthly, entitlement: .tier2) + StoreSubscription(.tier2_Yearly, entitlement: .tier2) + } +} + +private enum OtherPlans: + AutoRenewableSubscriptionGroup +{ + static let id = Plans.id + + enum ProductID: String, Hashable, Sendable { + case monthly = "com.example.subscription.tier1.monthly" + } + + static var subscriptions: StoreSubscriptions { + StoreSubscription(.monthly, entitlement: .tier2) + } +} + +private let countingSubscriptionsAccessCount = Mutex(0) + +private enum CountingPlans: + AutoRenewableSubscriptionGroup +{ + static let id = SubscriptionGroupID(rawValue: "counting-group") + + enum ProductID: String, Hashable, Sendable { + case monthly = "com.example.counting.monthly" + } + + static var subscriptions: StoreSubscriptions { + countingSubscriptionsAccessCount.withLock { count in + count += 1 + } + + return [StoreSubscription(.monthly, entitlement: .tier1)] + } +} + +private enum EmptyPlans: + AutoRenewableSubscriptionGroup +{ + static let id = SubscriptionGroupID(rawValue: "empty-group") + + enum ProductID: String, Hashable, Sendable { + case monthly = "com.example.empty.monthly" + } + + static var subscriptions: StoreSubscriptions { + return [] + } +} + +private enum EmptyProductIDPlans: + AutoRenewableSubscriptionGroup +{ + static let id = SubscriptionGroupID(rawValue: "empty-product-group") + + enum ProductID: String, Hashable, Sendable { + case empty = "" + } + + static var subscriptions: StoreSubscriptions { + StoreSubscription(.empty, entitlement: .tier1) + } +} + +private struct DuplicateProductID: RawRepresentable, Hashable, Sendable { + let rawValue: String + + static let first = Self(rawValue: "com.example.duplicate") + static let second = Self(rawValue: "com.example.duplicate") +} + +private enum DuplicateProductIDPlans: + AutoRenewableSubscriptionGroup +{ + static let id = SubscriptionGroupID(rawValue: "duplicate-product-group") + + typealias ProductID = DuplicateProductID + + static var subscriptions: StoreSubscriptions { + StoreSubscription(.first, entitlement: .tier1) + StoreSubscription(.second, entitlement: .tier2) + } +} + +private func subscriptionSnapshot( + id: UInt64, + productID: String, + subscriptionGroupID: String? = Plans.id.rawValue, + productType: Product.ProductType = .autoRenewable, + isUpgraded: Bool = false +) -> StoreTransactionSnapshot { + let date = Date(timeIntervalSince1970: TimeInterval(id)) + + return StoreTransactionSnapshot( + id: id, + originalID: id, + productID: productID, + subscriptionGroupID: subscriptionGroupID, + productType: productType, + environment: .xcode, + offer: nil, + storefrontID: "143441", + storefrontCountryCode: "USA", + price: nil, + currency: nil, + purchaseDate: date, + originalPurchaseDate: date, + expirationDate: nil, + revocationDate: nil, + revocationReason: nil, + purchasedQuantity: 1, + isUpgraded: isUpgraded, + ownershipType: .purchased, + reason: .purchase, + appAccountToken: nil, + signedDate: date, + jwsRepresentation: "catalog-jws-\(id)" + ) +} From 07a05d9bf80db9458a6539405d511f798a477597 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Thu, 23 Jul 2026 11:08:54 +0900 Subject: [PATCH 13/26] feat(runtime): implement entitlement transaction store --- .../FailureReporterDispatcher.swift | 36 +- .../CurrentEntitlementReconciler.swift | 260 ++-- .../EntitlementRefreshCoordinator.swift | 368 +++-- .../Processing/CompletedRevisionCache.swift | 26 +- .../TransactionProcessingCore.swift | 302 ++++- .../Runtime/DirectOperationReporting.swift | 87 +- .../Runtime/FiniteOperationRegistry.swift | 14 +- .../Runtime/LiveTransactionStoreLease.swift | 43 + .../Runtime/RestoreCoordinator.swift | 87 +- .../Runtime/StoreTransactionPipeline.swift | 150 ++- .../Runtime/StoreTransactionRuntime.swift | 544 +++++--- .../Runtime/TaskCancellationBag.swift | 22 +- .../Runtime/TaskCompletionBag.swift | 48 +- .../TransactionStoreDelegateReference.swift | 27 + .../Runtime/TransactionStoreLifecycle.swift | 152 +++ .../LiveStoreTransactionSource.swift | 97 +- .../StoreTransactionSource.swift | 4 + .../SyntheticStoreTransactionSource.swift | 41 + .../StoreTransactionSession.swift | 294 ---- .../AutoRenewableSubscriptionCatalog.swift | 8 +- ...utoRenewableSubscriptionCatalogError.swift | 4 + .../TransactionStore.swift | 528 +++++--- .../CompletedDeliveryRefreshTests.swift | 81 -- .../DirectOperationReportingTests.swift | 25 + .../EntitlementRefreshCoordinatorTests.swift | 275 ++-- .../LifecycleResidualTests.swift | 232 ---- .../ReconciliationFixedPointTests.swift | 568 -------- .../RuntimeContractTests.swift | 1192 ----------------- .../RuntimeOwnerTests.swift | 700 ++++++++++ .../StoreTransactionKitTests/StoreTests.swift | 388 ------ .../StoreTransactionSessionTests.swift | 787 ----------- .../TestSupport.swift | 96 +- .../TransactionProcessingCoreTests.swift | 84 +- .../TransactionStoreTests.swift | 554 ++++++++ 34 files changed, 3619 insertions(+), 4505 deletions(-) create mode 100644 Sources/StoreTransactionKit/Runtime/LiveTransactionStoreLease.swift create mode 100644 Sources/StoreTransactionKit/Runtime/TransactionStoreDelegateReference.swift create mode 100644 Sources/StoreTransactionKit/Runtime/TransactionStoreLifecycle.swift create mode 100644 Sources/StoreTransactionKit/StoreKitSource/SyntheticStoreTransactionSource.swift delete mode 100644 Sources/StoreTransactionKit/StoreTransactionSession.swift delete mode 100644 Tests/StoreTransactionKitTests/CompletedDeliveryRefreshTests.swift delete mode 100644 Tests/StoreTransactionKitTests/LifecycleResidualTests.swift delete mode 100644 Tests/StoreTransactionKitTests/ReconciliationFixedPointTests.swift delete mode 100644 Tests/StoreTransactionKitTests/RuntimeContractTests.swift create mode 100644 Tests/StoreTransactionKitTests/RuntimeOwnerTests.swift delete mode 100644 Tests/StoreTransactionKitTests/StoreTests.swift delete mode 100644 Tests/StoreTransactionKitTests/StoreTransactionSessionTests.swift create mode 100644 Tests/StoreTransactionKitTests/TransactionStoreTests.swift diff --git a/Sources/StoreTransactionKit/Diagnostics/FailureReporterDispatcher.swift b/Sources/StoreTransactionKit/Diagnostics/FailureReporterDispatcher.swift index 1c5f7f7..16d1656 100644 --- a/Sources/StoreTransactionKit/Diagnostics/FailureReporterDispatcher.swift +++ b/Sources/StoreTransactionKit/Diagnostics/FailureReporterDispatcher.swift @@ -1,4 +1,5 @@ import Foundation +import OSLog package actor FailureReporterDispatcher { private struct Item: Sendable { @@ -7,24 +8,25 @@ package actor FailureReporterDispatcher { } private let sessionID: UUID + private let lifetime: TransactionStoreLifecycle? private let capacity: Int - private let report: - @Sendable (StoreTransactionBackgroundFailure) async - -> Void + private let report: (@Sendable (StoreTransactionBackgroundFailure) async -> Void)? private var queue: [Item] = [] private var spaceWaiters: [ProcessingReceipt] = [] private var worker: Task? private var acceptsFailures = true + private nonisolated let workerCancellation = TaskCancellationBag() package init( sessionID: UUID = UUID(), capacity: Int = 32, + lifetime: TransactionStoreLifecycle? = nil, report: - @escaping @Sendable (StoreTransactionBackgroundFailure) async - -> Void + (@Sendable (StoreTransactionBackgroundFailure) async -> Void)? = nil ) { precondition(capacity > 0) self.sessionID = sessionID + self.lifetime = lifetime self.capacity = capacity self.report = report } @@ -51,12 +53,18 @@ package actor FailureReporterDispatcher { precondition(spaceWaiters.isEmpty) } + package nonisolated func cancelSynchronously() { + workerCancellation.cancel() + } + private func startWorkerIfNeeded() { guard worker == nil else { return } - worker = Task.detached { [weak self] in + let task = Task.detached { [weak self] in guard let self else { return } await self.drainQueue() } + worker = task + workerCancellation.insert(task) } private func drainQueue() async { @@ -71,14 +79,28 @@ package actor FailureReporterDispatcher { callback: .failureReporter ) ) { - await report(item.failure) + let errorType = String( + reflecting: type(of: item.failure.underlyingError) + ) + Self.logger.error( + "Background StoreKit failure from \(String(describing: item.failure.source), privacy: .public) [\(errorType, privacy: .public)]" + ) + if let report { + await report(item.failure) + } } item.receipt.succeed(()) } worker = nil + workerCancellation.removeAll() } isolated deinit { worker?.cancel() } + + private nonisolated static let logger = Logger( + subsystem: "StoreTransactionKit", + category: "TransactionStore" + ) } diff --git a/Sources/StoreTransactionKit/Entitlements/CurrentEntitlementReconciler.swift b/Sources/StoreTransactionKit/Entitlements/CurrentEntitlementReconciler.swift index 0501211..802215a 100644 --- a/Sources/StoreTransactionKit/Entitlements/CurrentEntitlementReconciler.swift +++ b/Sources/StoreTransactionKit/Entitlements/CurrentEntitlementReconciler.swift @@ -1,18 +1,36 @@ import Foundation import StoreKit +package struct CurrentEntitlementReconciliation: Sendable { + package let snapshots: [StoreTransactionSnapshot] + package let causalClaims: [TransactionCausalResolutionClaim] + package let diagnostics: [StoreTransactionBackgroundFailure] +} + +package struct CurrentEntitlementReconciliationFailure: Error, Sendable { + package let underlyingError: any Error + package let causalFailures: [CurrentEntitlementCausalFailure] + package let rootReportingAuthorities: [DirectOperationReportingAuthority] + package let exactFailures: [CurrentEntitlementExactFailure] + package let diagnostics: [StoreTransactionBackgroundFailure] +} + +package struct CurrentEntitlementCausalFailure: Sendable { + package let claim: TransactionCausalResolutionClaim + package let error: any Error +} + +package struct CurrentEntitlementExactFailure: Sendable { + package let snapshot: StoreTransactionSnapshot + package let reportingAuthority: DirectOperationReportingAuthority + package let underlyingError: any Error + package let isCausalOwner: Bool +} + package final class CurrentEntitlementReconciler: Sendable { - struct AcceptedTransaction: Sendable { + private struct AcceptedTransaction: Sendable { let snapshot: StoreTransactionSnapshot let acceptance: ProcessingAcceptance - - init( - snapshot: StoreTransactionSnapshot, - acceptance: ProcessingAcceptance - ) { - self.snapshot = snapshot - self.acceptance = acceptance - } } private struct UnfinishedBatch: Sendable { @@ -29,7 +47,6 @@ package final class CurrentEntitlementReconciler: Sendable { private let currentEntitlements: @Sendable () async throws -> CurrentEntitlementQueryResult private let queryUnfinished: @Sendable () async -> [StoreTransactionDelivery] private let core: TransactionProcessingCore - private let failures: FailureReporterDispatcher package init( query: @@ -37,56 +54,69 @@ package final class CurrentEntitlementReconciler: Sendable { -> CurrentEntitlementQueryResult, queryUnfinished: @escaping @Sendable () async -> [StoreTransactionDelivery], - core: TransactionProcessingCore, - failures: FailureReporterDispatcher + core: TransactionProcessingCore ) { - self.currentEntitlements = query + currentEntitlements = query self.queryUnfinished = queryUnfinished self.core = core - self.failures = failures } package func query( retryFailedTransactions: Bool - ) async throws -> [StoreTransactionSnapshot] { + ) async throws -> CurrentEntitlementReconciliation { if retryFailedTransactions { await core.beginRetryAttempt() } var reconciledRevisions: Set = [] - var batch = await unfinishedBatch( - excluding: reconciledRevisions - ) - var observedUnfinishedVerificationRevisions: Set = [] - var observedUnfinishedVerificationFailures: [any Error] = [] + var batch = await unfinishedBatch(excluding: reconciledRevisions) + var observedVerificationRevisions: Set = [] + var diagnostics: [StoreTransactionBackgroundFailure] = [] + var causalClaims: [TransactionCausalResolutionClaim] = [] collectUnfinishedVerificationFailures( batch.verificationFailures, - observedRevisions: &observedUnfinishedVerificationRevisions, - observedFailures: &observedUnfinishedVerificationFailures + observedRevisions: &observedVerificationRevisions, + diagnostics: &diagnostics ) var precedingCurrentVerificationFailures: [StoreTransactionVerificationError] = [] while true { while !batch.acceptedTransactions.isEmpty { do { - try await drain(batch.acceptedTransactions) - } catch { - await reportVerificationFailures( - unfinished: observedUnfinishedVerificationFailures, - currentEntitlements: - precedingCurrentVerificationFailures + causalClaims.append( + contentsOf: try await drain( + batch.acceptedTransactions + ) + ) + } catch let failure as DrainFailure { + appendCurrentEntitlementVerificationFailures( + precedingCurrentVerificationFailures, + to: &diagnostics + ) + let rootCausalFailures = causalClaims.map { + CurrentEntitlementCausalFailure( + claim: $0, + error: failure.underlyingError + ) + } + throw CurrentEntitlementReconciliationFailure( + underlyingError: failure.underlyingError, + causalFailures: + rootCausalFailures + failure.causalFailures, + rootReportingAuthorities: + causalClaims.map(\.reportingAuthority) + + failure.rootReportingAuthorities, + exactFailures: failure.exactFailures, + diagnostics: diagnostics ) - throw error + } catch { + preconditionFailure("Unclassified reconciliation failure: \(error)") } reconciledRevisions.formUnion(batch.revisions) - batch = await unfinishedBatch( - excluding: reconciledRevisions - ) + batch = await unfinishedBatch(excluding: reconciledRevisions) collectUnfinishedVerificationFailures( batch.verificationFailures, - observedRevisions: - &observedUnfinishedVerificationRevisions, - observedFailures: - &observedUnfinishedVerificationFailures + observedRevisions: &observedVerificationRevisions, + diagnostics: &diagnostics ) } @@ -94,10 +124,19 @@ package final class CurrentEntitlementReconciler: Sendable { do { result = try await currentEntitlements() } catch { - await reportUnfinishedVerificationFailures( - observedUnfinishedVerificationFailures + throw CurrentEntitlementReconciliationFailure( + underlyingError: error, + causalFailures: causalClaims.map { + CurrentEntitlementCausalFailure( + claim: $0, + error: error + ) + }, + rootReportingAuthorities: + causalClaims.map(\.reportingAuthority), + exactFailures: [], + diagnostics: diagnostics ) - throw error } let postQueryBatch = await unfinishedBatch( @@ -105,21 +144,22 @@ package final class CurrentEntitlementReconciler: Sendable { ) collectUnfinishedVerificationFailures( postQueryBatch.verificationFailures, - observedRevisions: - &observedUnfinishedVerificationRevisions, - observedFailures: - &observedUnfinishedVerificationFailures + observedRevisions: &observedVerificationRevisions, + diagnostics: &diagnostics ) guard !postQueryBatch.acceptedTransactions.isEmpty else { - await reportVerificationFailures( - unfinished: observedUnfinishedVerificationFailures, - currentEntitlements: result.verificationFailures + appendCurrentEntitlementVerificationFailures( + result.verificationFailures, + to: &diagnostics + ) + return CurrentEntitlementReconciliation( + snapshots: result.snapshots, + causalClaims: causalClaims, + diagnostics: diagnostics ) - return result.snapshots } batch = postQueryBatch - precedingCurrentVerificationFailures = - result.verificationFailures + precedingCurrentVerificationFailures = result.verificationFailures } } @@ -143,13 +183,15 @@ package final class CurrentEntitlementReconciler: Sendable { AcceptedTransaction( snapshot: envelope.value, acceptance: await core.accept(envelope) - )) + ) + ) case .unverified(let revision, let error): verificationFailures.append( UnfinishedVerificationFailure( revision: revision, error: error - )) + ) + ) } } @@ -163,71 +205,101 @@ package final class CurrentEntitlementReconciler: Sendable { private func collectUnfinishedVerificationFailures( _ verificationFailures: [UnfinishedVerificationFailure], observedRevisions: inout Set, - observedFailures: inout [any Error] + diagnostics: inout [StoreTransactionBackgroundFailure] ) { for failure in verificationFailures where observedRevisions.insert(failure.revision).inserted { - observedFailures.append(failure.error) - } - } - - private func reportVerificationFailures( - unfinished: [any Error], - currentEntitlements: [StoreTransactionVerificationError] - ) async { - await reportUnfinishedVerificationFailures(unfinished) - await reportCurrentEntitlementVerificationFailures( - currentEntitlements - ) - } - - private func reportUnfinishedVerificationFailures( - _ verificationFailures: [any Error] - ) async { - for failure in verificationFailures { - await failures.enqueue( + diagnostics.append( StoreTransactionBackgroundFailure( source: .unfinished, transactionID: nil, productID: nil, - underlyingError: failure + underlyingError: failure.error ) ) } } - func drain(_ transactions: [AcceptedTransaction]) async throws { - var firstError: (any Error)? + private struct DrainFailure: Error, Sendable { + let underlyingError: any Error + let causalFailures: [CurrentEntitlementCausalFailure] + let rootReportingAuthorities: [DirectOperationReportingAuthority] + let exactFailures: [CurrentEntitlementExactFailure] + } + + private func drain( + _ transactions: [AcceptedTransaction] + ) async throws -> [TransactionCausalResolutionClaim] { + var claimedTransactions: + [( + transaction: AcceptedTransaction, + claim: TransactionCausalResolutionClaim? + )] = [] for transaction in transactions { + claimedTransactions.append( + ( + transaction, + await transaction.acceptance.claimCausalResolutionIfOwner() + ) + ) + } + + var exactFailures: [CurrentEntitlementExactFailure] = [] + for entry in claimedTransactions { do { - _ = try await transaction.acceptance.receipt.terminalValue() + _ = try await entry.transaction.acceptance.receipt.terminalValue() } catch { - if transaction.acceptance.role == .owner { - let failure = StoreTransactionBackgroundFailure( - source: .unfinished, - transactionID: transaction.snapshot.id, - productID: transaction.snapshot.productID, - underlyingError: error + exactFailures.append( + CurrentEntitlementExactFailure( + snapshot: entry.transaction.snapshot, + reportingAuthority: + entry.transaction.acceptance.reportingAuthority, + underlyingError: error, + isCausalOwner: entry.claim != nil ) - await failures.enqueue(failure) - } - if firstError == nil { - firstError = StoreTransactionFailureWithReportingOwner( - underlyingError: error - ) - } + ) } } - if let firstError { - throw firstError + if let firstFailure = exactFailures.first { + let additionalOwnedAuthorities = exactFailures.dropFirst() + .filter(\.isCausalOwner) + .map(\.reportingAuthority) + let causalFailures: [CurrentEntitlementCausalFailure] = + claimedTransactions.compactMap { entry in + guard let claim = entry.claim else { return nil } + let exactError = exactFailures.first { failure in + failure.reportingAuthority + === entry.transaction.acceptance.reportingAuthority + }?.underlyingError + return CurrentEntitlementCausalFailure( + claim: claim, + error: exactError ?? firstFailure.underlyingError + ) + } + let rootReportingAuthorities = + causalFailures + .map { $0.claim.reportingAuthority } + .filter { authority in + !additionalOwnedAuthorities.contains { + $0 === authority + } + } + [firstFailure.reportingAuthority] + throw DrainFailure( + underlyingError: firstFailure.underlyingError, + causalFailures: causalFailures, + rootReportingAuthorities: rootReportingAuthorities, + exactFailures: exactFailures + ) } + return claimedTransactions.compactMap(\.claim) } - private func reportCurrentEntitlementVerificationFailures( - _ verificationFailures: [StoreTransactionVerificationError] - ) async { + private func appendCurrentEntitlementVerificationFailures( + _ verificationFailures: [StoreTransactionVerificationError], + to diagnostics: inout [StoreTransactionBackgroundFailure] + ) { for failure in verificationFailures { - await failures.enqueue( + diagnostics.append( StoreTransactionBackgroundFailure( source: .currentEntitlementVerification, transactionID: nil, diff --git a/Sources/StoreTransactionKit/Entitlements/EntitlementRefreshCoordinator.swift b/Sources/StoreTransactionKit/Entitlements/EntitlementRefreshCoordinator.swift index 1fac461..84232e5 100644 --- a/Sources/StoreTransactionKit/Entitlements/EntitlementRefreshCoordinator.swift +++ b/Sources/StoreTransactionKit/Entitlements/EntitlementRefreshCoordinator.swift @@ -1,99 +1,149 @@ import Foundation -package struct EntitlementRefreshReservation: Sendable { +package struct EntitlementPublication: Sendable +where Entitlement: Hashable & Sendable { + package let entitlements: StoreEntitlements + package let activeEntitlements: Set +} + +package enum EntitlementRefreshOutcome: Sendable +where Entitlement: Hashable & Sendable { + case success(EntitlementPublication) + case transientFailure(any Error) + case catalogFailure(AutoRenewableSubscriptionCatalogError) +} + +package struct EntitlementRefreshReservation: Sendable +where Entitlement: Hashable & Sendable { package enum Role: Equatable, Sendable { case owner case observer } - package let receipt: ProcessingReceipt + package let receipt: ProcessingReceipt> package let role: Role - package let token: UInt64 package let reportingAuthority: DirectOperationReportingAuthority + package let directBinding: DirectOperationObservation.Binding? } -package struct EntitlementRefreshSuccess: Sendable { - package let token: UInt64 - package let entitlements: StoreEntitlements -} - -package actor EntitlementRefreshCoordinator { +package actor EntitlementRefreshCoordinator +where Entitlement: Hashable & Sendable { private struct PendingReservation: Sendable { - let token: UInt64 let retryFailedTransactions: Bool - let receipt: ProcessingReceipt + let receipt: ProcessingReceipt> let reportingAuthority: DirectOperationReportingAuthority } - private let sessionID: UUID - private let query: @Sendable (Bool) async throws -> [StoreTransactionSnapshot] - private let didChange: @Sendable (StoreEntitlements) async -> Void - private let didSucceed: @Sendable (EntitlementRefreshSuccess) async -> Void - private var nextToken: UInt64 = 0 + private struct PendingFailureResolution: Sendable { + let claim: TransactionCausalResolutionClaim + let error: any Error + } + + private enum PendingWork: Sendable { + case refresh(PendingReservation) + case failure(PendingFailureResolution) + } + + private let query: @Sendable (Bool) async throws -> CurrentEntitlementReconciliation + private let project: + @Sendable (StoreEntitlements) throws(AutoRenewableSubscriptionCatalogError) + -> Set + private let didComplete: @Sendable (EntitlementRefreshOutcome) async -> Void + private let failures: FailureReporterDispatcher + private let lifetime: TransactionStoreLifecycle? private var current: StoreEntitlements? - private var pending: [PendingReservation] = [] + private var pending: [PendingWork] = [] private var worker: Task? private var acceptsReservations = true + private nonisolated let workerCancellation = TaskCancellationBag() package init( - sessionID: UUID = UUID(), query: @escaping @Sendable (Bool) async throws - -> [StoreTransactionSnapshot], - didChange: @escaping @Sendable (StoreEntitlements) async -> Void, - didSucceed: - @escaping @Sendable (EntitlementRefreshSuccess) async -> Void = { _ in } + -> CurrentEntitlementReconciliation, + project: + @escaping @Sendable (StoreEntitlements) throws(AutoRenewableSubscriptionCatalogError) + -> Set, + didComplete: + @escaping @Sendable (EntitlementRefreshOutcome) async + -> Void, + failures: FailureReporterDispatcher, + lifetime: TransactionStoreLifecycle? = nil ) { - self.sessionID = sessionID self.query = query - self.didChange = didChange - self.didSucceed = didSucceed + self.project = project + self.didComplete = didComplete + self.failures = failures + self.lifetime = lifetime } package func reserve( - retryFailedTransactions: Bool = true - ) -> EntitlementRefreshReservation { + retryFailedTransactions: Bool = true, + directObservation: DirectOperationObservation? = nil, + reportingAuthority preferredReportingAuthority: + DirectOperationReportingAuthority? = nil + ) -> EntitlementRefreshReservation { guard acceptsReservations else { return EntitlementRefreshReservation( receipt: .failed( StoreTransactionInternalError.entitlementRefreshClosed ), role: .owner, - token: 0, - reportingAuthority: DirectOperationReportingAuthority() + reportingAuthority: DirectOperationReportingAuthority(), + directBinding: nil ) } - precondition(nextToken < .max) - nextToken += 1 - let role: EntitlementRefreshReservation.Role + + let role: EntitlementRefreshReservation.Role let reportingAuthority: DirectOperationReportingAuthority - if let preceding = pending.last, + if case .refresh(let preceding)? = pending.last, preceding.retryFailedTransactions == retryFailedTransactions { role = .observer reportingAuthority = preceding.reportingAuthority + preferredReportingAuthority?.merge(into: reportingAuthority) } else { role = .owner - reportingAuthority = DirectOperationReportingAuthority() + reportingAuthority = + preferredReportingAuthority + ?? DirectOperationReportingAuthority() } - let receipt = ProcessingReceipt() + let receipt = ProcessingReceipt>() + let directBinding = directObservation?.bind(to: reportingAuthority) pending.append( - PendingReservation( - token: nextToken, - retryFailedTransactions: retryFailedTransactions, - receipt: receipt, - reportingAuthority: reportingAuthority + .refresh( + PendingReservation( + retryFailedTransactions: retryFailedTransactions, + receipt: receipt, + reportingAuthority: reportingAuthority + ) ) ) startWorkerIfNeeded() return EntitlementRefreshReservation( receipt: receipt, role: role, - token: nextToken, - reportingAuthority: reportingAuthority + reportingAuthority: reportingAuthority, + directBinding: directBinding ) } + package func resolve( + _ claim: TransactionCausalResolutionClaim, + failure error: any Error + ) { + precondition(acceptsReservations) + pending.append( + .failure( + PendingFailureResolution( + claim: claim, + error: error + ) + ) + ) + startWorkerIfNeeded() + } + package func sealAndDrain() async { acceptsReservations = false let activeWorker = worker @@ -101,57 +151,215 @@ package actor EntitlementRefreshCoordinator { precondition(pending.isEmpty) } + package nonisolated func cancelSynchronously() { + workerCancellation.cancel() + } + private func startWorkerIfNeeded() { guard worker == nil else { return } - worker = Task.detached { [weak self] in + let task = Task.detached { [weak self] in guard let self else { return } - await self.runQueries() + await self.runWork() } + worker = task + workerCancellation.insert(task) } - private func runQueries() async { + private func runWork() async { while !pending.isEmpty { - let retryFailedTransactions = - pending[0].retryFailedTransactions - let end = - pending.firstIndex { - $0.retryFailedTransactions != retryFailedTransactions - } ?? pending.endIndex - let reservations = Array(pending[..>], + reservationHasReportingOwner: Bool = false, + exactFailures: [CurrentEntitlementExactFailure] = [] + ) async { + let exposed: any Error + if let catalogFailure = error as? StoreTransactionCatalogFailure { + exposed = catalogFailure.error + await didComplete(.catalogFailure(catalogFailure.error)) + } else { + exposed = error + await didComplete(.transientFailure(error)) + } + for failure in exactFailures where failure.isCausalOwner { + failure.reportingAuthority.record( + report: exactFailureReport(failure) + ) + } + for failure in causalFailures { + await failure.claim.fail(exposedError(failure.error)) + } + let reservationError: any Error = + if reservationHasReportingOwner { + StoreTransactionFailureWithReportingOwner( + underlyingError: exposed + ) + } else { + exposed + } + for receipt in reservationReceipts { + receipt.fail(reservationError) + } + } + + private func report( + _ exactFailures: [CurrentEntitlementExactFailure] + ) async { + for failure in exactFailures where failure.isCausalOwner { + let report = exactFailureReport(failure) + if let claimed = failure.reportingAuthority + .failWithoutParticipant(report: report) + { + await failures.enqueue(claimed) + } + } + } + + private func exactFailureReport( + _ failure: CurrentEntitlementExactFailure + ) -> StoreTransactionBackgroundFailure { + StoreTransactionBackgroundFailure( + source: .unfinished, + transactionID: failure.snapshot.id, + productID: failure.snapshot.productID, + underlyingError: exposedError(failure.underlyingError) + ) + } + + private func report(_ diagnostics: [StoreTransactionBackgroundFailure]) async { + for diagnostic in diagnostics { + await failures.enqueue(diagnostic) + } + } + + private func merge( + _ authorities: [DirectOperationReportingAuthority], + into reportingAuthority: DirectOperationReportingAuthority + ) { + for authority in authorities { + authority.merge(into: reportingAuthority) + } + } + + private func exposedError(_ error: any Error) -> any Error { + if let catalogFailure = error as? StoreTransactionCatalogFailure { + return catalogFailure.error + } + return error } package nonisolated static func entitlementOrder( diff --git a/Sources/StoreTransactionKit/Processing/CompletedRevisionCache.swift b/Sources/StoreTransactionKit/Processing/CompletedRevisionCache.swift index f7e53b3..87ea7b5 100644 --- a/Sources/StoreTransactionKit/Processing/CompletedRevisionCache.swift +++ b/Sources/StoreTransactionKit/Processing/CompletedRevisionCache.swift @@ -1,9 +1,14 @@ import Foundation package struct CompletedRevisionCache { + package enum State: Sendable { + case needsRefresh + case satisfied + } + private let capacity: Int private var insertionOrder: [Data] = [] - private var membership: Set = [] + private var states: [Data: State] = [:] package init(capacity: Int = 512) { precondition(capacity > 0) @@ -11,15 +16,26 @@ package struct CompletedRevisionCache { } package func contains(_ revision: Data) -> Bool { - membership.contains(revision) + states[revision] != nil } - package mutating func insert(_ revision: Data) { - guard membership.insert(revision).inserted else { return } + package func state(for revision: Data) -> State? { + states[revision] + } + + package mutating func insert( + _ revision: Data, + state: State = .needsRefresh + ) { + guard states[revision] == nil else { + states[revision] = state + return + } + states[revision] = state insertionOrder.append(revision) if insertionOrder.count > capacity { let evicted = insertionOrder.removeFirst() - membership.remove(evicted) + states.removeValue(forKey: evicted) } } } diff --git a/Sources/StoreTransactionKit/Processing/TransactionProcessingCore.swift b/Sources/StoreTransactionKit/Processing/TransactionProcessingCore.swift index 00b9b35..d8a6ff8 100644 --- a/Sources/StoreTransactionKit/Processing/TransactionProcessingCore.swift +++ b/Sources/StoreTransactionKit/Processing/TransactionProcessingCore.swift @@ -1,5 +1,38 @@ import Foundation +package struct TransactionCausalResolutionClaim: Sendable { + package let value: Value + package let reportingAuthority: DirectOperationReportingAuthority + + private let receipt: ProcessingReceipt + private let finishSuccess: @Sendable () async -> Void + private let finishFailure: @Sendable () async -> Void + + fileprivate init( + value: Value, + reportingAuthority: DirectOperationReportingAuthority, + receipt: ProcessingReceipt, + finishSuccess: @escaping @Sendable () async -> Void, + finishFailure: @escaping @Sendable () async -> Void + ) { + self.value = value + self.reportingAuthority = reportingAuthority + self.receipt = receipt + self.finishSuccess = finishSuccess + self.finishFailure = finishFailure + } + + package func succeed() async { + await finishSuccess() + receipt.succeed(value) + } + + package func fail(_ error: any Error) async { + await finishFailure() + receipt.fail(error) + } +} + package struct ProcessingAcceptance: Sendable { package enum Role: Equatable, Sendable { case owner @@ -8,23 +41,66 @@ package struct ProcessingAcceptance: Sendable { case completedObserver } + /// Completes after the handling policy and `finish()` complete. package let receipt: ProcessingReceipt + + /// Completes after the exact revision's causal entitlement publication. + package let causalReceipt: ProcessingReceipt package let role: Role package let reportingAuthority: DirectOperationReportingAuthority + package let directBinding: DirectOperationObservation.Binding? + + private let claimCausalResolution: @Sendable () async -> TransactionCausalResolutionClaim? + + fileprivate init( + receipt: ProcessingReceipt, + causalReceipt: ProcessingReceipt, + role: Role, + reportingAuthority: DirectOperationReportingAuthority, + directBinding: DirectOperationObservation.Binding?, + claimCausalResolution: + @escaping @Sendable () async + -> TransactionCausalResolutionClaim? + ) { + self.receipt = receipt + self.causalReceipt = causalReceipt + self.role = role + self.reportingAuthority = reportingAuthority + self.directBinding = directBinding + self.claimCausalResolution = claimCausalResolution + } + + package func claimCausalResolutionIfOwner() async + -> TransactionCausalResolutionClaim? + { + await claimCausalResolution() + } } package actor TransactionProcessingCore { + private enum AttemptPhase: Sendable { + case deciding + case decisionFailed + case finished + } + private struct Attempt: Sendable { - let receipt: ProcessingReceipt + let id: UUID + let value: Value + let decisionReceipt: ProcessingReceipt + let causalReceipt: ProcessingReceipt let reportingAuthority: DirectOperationReportingAuthority + var causalResolutionClaimed: Bool + var phase: AttemptPhase } private struct QueuedOperation: Sendable { let envelope: ProcessingEnvelope - let attempt: Attempt + let attemptID: UUID } private let sessionID: UUID + private let lifetime: TransactionStoreLifecycle? private let handle: @Sendable (Value) async throws -> Void private var queue: [QueuedOperation] = [] private var inFlight: [Data: Attempt] = [:] @@ -33,59 +109,94 @@ package actor TransactionProcessingCore { private var worker: Task? private var acceptsInput = true private var initialAttemptCompleted = false + private nonisolated let workerCancellation = TaskCancellationBag() package init( sessionID: UUID = UUID(), + lifetime: TransactionStoreLifecycle? = nil, handle: @escaping @Sendable (Value) async throws -> Void ) { self.sessionID = sessionID + self.lifetime = lifetime self.handle = handle } package func accept( - _ envelope: ProcessingEnvelope + _ envelope: ProcessingEnvelope, + directObservation: DirectOperationObservation? = nil ) -> ProcessingAcceptance { guard acceptsInput else { + let error = StoreTransactionInternalError.inputClosed return ProcessingAcceptance( - receipt: .failed(StoreTransactionInternalError.inputClosed), + receipt: .failed(error), + causalReceipt: .failed(error), role: .owner, + reportingAuthority: DirectOperationReportingAuthority(), + directBinding: nil, + claimCausalResolution: { nil } + ) + } + if let attempt = inFlight[envelope.revision] { + return acceptance( + revision: envelope.revision, + attempt: attempt, + role: .inFlightObserver, + directObservation: directObservation + ) + } + if let attempt = failed[envelope.revision] { + return acceptance( + revision: envelope.revision, + attempt: attempt, + role: .failedObserver, + directObservation: directObservation, reportingAuthority: DirectOperationReportingAuthority() ) } - if completed.contains(envelope.revision) { + if completed.state(for: envelope.revision) == .satisfied { + let reportingAuthority = DirectOperationReportingAuthority() return ProcessingAcceptance( receipt: .succeeded(envelope.value), + causalReceipt: .succeeded(envelope.value), role: .completedObserver, - reportingAuthority: DirectOperationReportingAuthority() + reportingAuthority: reportingAuthority, + directBinding: directObservation?.bind( + to: reportingAuthority + ), + claimCausalResolution: { nil } ) } - if let attempt = inFlight[envelope.revision] { - return ProcessingAcceptance( - receipt: attempt.receipt, - role: .inFlightObserver, - reportingAuthority: attempt.reportingAuthority + if completed.state(for: envelope.revision) == .needsRefresh { + let attempt = makeAttempt( + value: envelope.value, + decisionReceipt: .succeeded(envelope.value), + alreadyFinished: true ) - } - if let attempt = failed[envelope.revision] { - return ProcessingAcceptance( - receipt: attempt.receipt, - role: .failedObserver, - reportingAuthority: attempt.reportingAuthority + inFlight[envelope.revision] = attempt + return acceptance( + revision: envelope.revision, + attempt: attempt, + role: .completedObserver, + directObservation: directObservation ) } - let attempt = Attempt( - receipt: ProcessingReceipt(), - reportingAuthority: DirectOperationReportingAuthority() - ) + let attempt = makeAttempt(value: envelope.value) inFlight[envelope.revision] = attempt - queue.append(QueuedOperation(envelope: envelope, attempt: attempt)) - startWorkerIfNeeded() - return ProcessingAcceptance( - receipt: attempt.receipt, + queue.append( + QueuedOperation( + envelope: envelope, + attemptID: attempt.id + ) + ) + let accepted = acceptance( + revision: envelope.revision, + attempt: attempt, role: .owner, - reportingAuthority: attempt.reportingAuthority + directObservation: directObservation ) + startWorkerIfNeeded() + return accepted } package func retryFailedTransactionsInNewAttempt() -> Bool { @@ -117,17 +228,126 @@ package actor TransactionProcessingCore { failed.removeAll(keepingCapacity: false) } + package nonisolated func cancelSynchronously() { + workerCancellation.cancel() + } + + private func makeAttempt( + value: Value, + decisionReceipt: ProcessingReceipt = ProcessingReceipt(), + alreadyFinished: Bool = false + ) -> Attempt { + Attempt( + id: UUID(), + value: value, + decisionReceipt: decisionReceipt, + causalReceipt: ProcessingReceipt(), + reportingAuthority: DirectOperationReportingAuthority(), + causalResolutionClaimed: false, + phase: alreadyFinished ? .finished : .deciding + ) + } + + private func acceptance( + revision: Data, + attempt: Attempt, + role: ProcessingAcceptance.Role, + directObservation: DirectOperationObservation?, + reportingAuthority: DirectOperationReportingAuthority? = nil + ) -> ProcessingAcceptance { + let reportingAuthority = reportingAuthority ?? attempt.reportingAuthority + return ProcessingAcceptance( + receipt: attempt.decisionReceipt, + causalReceipt: attempt.causalReceipt, + role: role, + reportingAuthority: reportingAuthority, + directBinding: directObservation?.bind(to: reportingAuthority), + claimCausalResolution: { [weak self] in + guard let self else { return nil } + return await self.claimCausalResolution( + revision: revision, + attemptID: attempt.id + ) + } + ) + } + + private func claimCausalResolution( + revision: Data, + attemptID: UUID + ) -> TransactionCausalResolutionClaim? { + let attempt: Attempt + if var active = inFlight[revision], active.id == attemptID { + guard !active.causalResolutionClaimed else { return nil } + active.causalResolutionClaimed = true + inFlight[revision] = active + attempt = active + } else { + return nil + } + + return TransactionCausalResolutionClaim( + value: attempt.value, + reportingAuthority: attempt.reportingAuthority, + receipt: attempt.causalReceipt, + finishSuccess: { [weak self] in + await self?.finishCausalResolution( + revision: revision, + attemptID: attemptID, + succeeded: true + ) + }, + finishFailure: { [weak self] in + await self?.finishCausalResolution( + revision: revision, + attemptID: attemptID, + succeeded: false + ) + } + ) + } + + private func finishCausalResolution( + revision: Data, + attemptID: UUID, + succeeded: Bool + ) { + if let attempt = inFlight[revision], attempt.id == attemptID { + precondition(attempt.causalResolutionClaimed) + inFlight.removeValue(forKey: revision) + if succeeded { + precondition(attempt.phase == .finished) + completed.insert(revision, state: .satisfied) + } else if attempt.phase == .decisionFailed { + failed[revision] = attempt + } + return + } + if let attempt = failed[revision], attempt.id == attemptID { + precondition(attempt.causalResolutionClaimed) + return + } + } + private func startWorkerIfNeeded() { guard worker == nil else { return } - worker = Task.detached { [weak self] in + let task = Task.detached { [weak self] in guard let self else { return } await self.drainQueue() } + worker = task + workerCancellation.insert(task) } private func drainQueue() async { while !queue.isEmpty { let operation = queue.removeFirst() + guard + let attempt = inFlight[operation.envelope.revision], + attempt.id == operation.attemptID + else { + preconditionFailure("A queued transaction lost its processing attempt.") + } do { try await StoreTransactionCallbackContext.$current.withValue( StoreTransactionCallbackInvocation( @@ -139,15 +359,33 @@ package actor TransactionProcessingCore { } await operation.envelope.finish() completed.insert(operation.envelope.revision) - inFlight.removeValue(forKey: operation.envelope.revision) - operation.attempt.receipt.succeed(operation.envelope.value) + guard + var finishedAttempt = inFlight[operation.envelope.revision], + finishedAttempt.id == operation.attemptID + else { + preconditionFailure( + "A finished transaction lost its processing attempt." + ) + } + finishedAttempt.phase = .finished + inFlight[operation.envelope.revision] = finishedAttempt + attempt.decisionReceipt.succeed(operation.envelope.value) } catch { - inFlight.removeValue(forKey: operation.envelope.revision) - failed[operation.envelope.revision] = operation.attempt - operation.attempt.receipt.fail(error) + guard + var failedAttempt = inFlight[operation.envelope.revision], + failedAttempt.id == operation.attemptID + else { + preconditionFailure( + "A failed transaction lost its processing attempt." + ) + } + failedAttempt.phase = .decisionFailed + inFlight[operation.envelope.revision] = failedAttempt + attempt.decisionReceipt.fail(error) } } worker = nil + workerCancellation.removeAll() } isolated deinit { diff --git a/Sources/StoreTransactionKit/Runtime/DirectOperationReporting.swift b/Sources/StoreTransactionKit/Runtime/DirectOperationReporting.swift index 7914b75..937a538 100644 --- a/Sources/StoreTransactionKit/Runtime/DirectOperationReporting.swift +++ b/Sources/StoreTransactionKit/Runtime/DirectOperationReporting.swift @@ -14,12 +14,19 @@ package final class DirectOperationReportingAuthority: Sendable { var claimed = false } - private let state = Mutex(State()) + private final class Node: @unchecked Sendable { + var parent: Node? + let state = Mutex(State()) + } + + private static let graph = Mutex(()) + private let node = Node() package init() {} fileprivate func attach(abandoned: Bool) -> UUID { - state.withLock { state in + withState { state in + precondition(!state.claimed) let id = UUID() state.participants[id] = abandoned ? .abandoned : .attached return id @@ -27,7 +34,7 @@ package final class DirectOperationReportingAuthority: Sendable { } fileprivate func succeed(participant id: UUID) { - state.withLock { state in + withState { state in _ = state.participants.removeValue(forKey: id) } } @@ -36,7 +43,7 @@ package final class DirectOperationReportingAuthority: Sendable { participant id: UUID, report: StoreTransactionBackgroundFailure? ) -> StoreTransactionBackgroundFailure? { - state.withLock { state in + withState { state in guard state.participants[id] != nil else { return nil } if state.report == nil { state.report = report @@ -48,7 +55,7 @@ package final class DirectOperationReportingAuthority: Sendable { fileprivate func abandon( participant id: UUID ) -> StoreTransactionBackgroundFailure? { - state.withLock { state in + withState { state in guard state.participants[id] != nil else { return nil } state.participants[id] = .abandoned return claimReportIfAbandoned(state: &state) @@ -56,7 +63,7 @@ package final class DirectOperationReportingAuthority: Sendable { } fileprivate func deliver(participant id: UUID) { - state.withLock { state in + withState { state in guard state.participants.removeValue(forKey: id) != nil else { return } @@ -64,6 +71,74 @@ package final class DirectOperationReportingAuthority: Sendable { } } + package func failWithoutParticipant( + report: StoreTransactionBackgroundFailure + ) -> StoreTransactionBackgroundFailure? { + withState { state in + if state.report == nil { + state.report = report + } + return claimReportIfAbandoned(state: &state) + } + } + + package func record( + report: StoreTransactionBackgroundFailure + ) { + withState { state in + if state.report == nil { + state.report = report + } + } + } + + package func merge( + into authority: DirectOperationReportingAuthority + ) { + Self.graph.withLock { _ in + let source = root(of: node) + let target = root(of: authority.node) + guard source !== target else { return } + + let sourceState = source.state.withLock { $0 } + target.state.withLock { targetState in + precondition( + !sourceState.claimed + && !sourceState.delivered + && !targetState.claimed + && !targetState.delivered + ) + precondition( + sourceState.report == nil || targetState.report == nil + ) + for (id, participant) in sourceState.participants { + precondition(targetState.participants[id] == nil) + targetState.participants[id] = participant + } + if targetState.report == nil { + targetState.report = sourceState.report + } + } + source.parent = target + } + } + + private func withState( + _ body: (inout sending State) -> sending Result + ) -> Result { + Self.graph.withLock { _ in + root(of: node).state.withLock(body) + } + } + + private func root(of node: Node) -> Node { + var node = node + while let parent = node.parent { + node = parent + } + return node + } + private func claimReportIfAbandoned( state: inout State ) -> StoreTransactionBackgroundFailure? { diff --git a/Sources/StoreTransactionKit/Runtime/FiniteOperationRegistry.swift b/Sources/StoreTransactionKit/Runtime/FiniteOperationRegistry.swift index 8fd8c96..b8e1e52 100644 --- a/Sources/StoreTransactionKit/Runtime/FiniteOperationRegistry.swift +++ b/Sources/StoreTransactionKit/Runtime/FiniteOperationRegistry.swift @@ -30,9 +30,14 @@ package final class FiniteOperationRegistry: Sendable { } } - package func stopAdmissionAndWait() async { - let receipt = state.withLock { state -> ProcessingReceipt? in + package func seal() { + state.withLock { state in state.acceptsInput = false + } + } + + package func waitForDrain() async { + let receipt = state.withLock { state -> ProcessingReceipt? in guard state.count > 0 else { return nil } if let receipt = state.drainReceipt { return receipt } let receipt = ProcessingReceipt() @@ -44,6 +49,11 @@ package final class FiniteOperationRegistry: Sendable { } } + package func stopAdmissionAndWait() async { + seal() + await waitForDrain() + } + fileprivate func end() { let receipt = state.withLock { state -> ProcessingReceipt? in precondition(state.count > 0) diff --git a/Sources/StoreTransactionKit/Runtime/LiveTransactionStoreLease.swift b/Sources/StoreTransactionKit/Runtime/LiveTransactionStoreLease.swift new file mode 100644 index 0000000..4d4b6b7 --- /dev/null +++ b/Sources/StoreTransactionKit/Runtime/LiveTransactionStoreLease.swift @@ -0,0 +1,43 @@ +import Synchronization + +package final class LiveTransactionStoreLease: Sendable { + private static let isAcquired = Mutex(false) + + private let isReleased = Mutex(false) + private let releaseReceipt = ProcessingReceipt() + + private init() {} + + package static func acquire() -> LiveTransactionStoreLease { + isAcquired.withLock { isAcquired in + precondition( + !isAcquired, + "Only one live TransactionStore may exist in a process." + ) + isAcquired = true + } + return LiveTransactionStoreLease() + } + + package func release() { + let shouldRelease = isReleased.withLock { isReleased in + guard !isReleased else { return false } + isReleased = true + return true + } + guard shouldRelease else { return } + Self.isAcquired.withLock { isAcquired in + precondition(isAcquired) + isAcquired = false + } + releaseReceipt.succeed(()) + } + + package func waitUntilReleased() async { + _ = try? await releaseReceipt.terminalValue() + } + + deinit { + release() + } +} diff --git a/Sources/StoreTransactionKit/Runtime/RestoreCoordinator.swift b/Sources/StoreTransactionKit/Runtime/RestoreCoordinator.swift index 90b9dc9..48471c2 100644 --- a/Sources/StoreTransactionKit/Runtime/RestoreCoordinator.swift +++ b/Sources/StoreTransactionKit/Runtime/RestoreCoordinator.swift @@ -1,73 +1,81 @@ -package struct RestoreReservation: Sendable { +package struct RestoreReservation: Sendable +where Entitlement: Hashable & Sendable { package enum Role: Equatable, Sendable { case owner case observer } - package let receipt: ProcessingReceipt + package let receipt: ProcessingReceipt> package let role: Role package let reportingAuthority: DirectOperationReportingAuthority + package let directBinding: DirectOperationObservation.Binding? } -package struct RestoreCoordinatorFailure: Error { +package struct RestoreCoordinatorFailure: Error, Sendable { package let underlyingError: any Error + package let synchronized: Bool package let reportsWhenAbandoned: Bool - package let reportingAuthority: DirectOperationReportingAuthority package init( propagating error: any Error, - reportsWhenAbandoned: Bool, - reportingAuthority: DirectOperationReportingAuthority + synchronized: Bool, + reportsWhenAbandoned: Bool ) { let propagation = StoreTransactionFailurePropagation(error) - self.underlyingError = propagation.underlyingError + underlyingError = propagation.underlyingError + self.synchronized = synchronized self.reportsWhenAbandoned = reportsWhenAbandoned && !propagation.hasReportingOwner - self.reportingAuthority = reportingAuthority } } -package actor RestoreCoordinator { +package actor RestoreCoordinator +where Entitlement: Hashable & Sendable { private struct InFlight: Sendable { let id: UInt64 - let receipt: ProcessingReceipt + let receipt: ProcessingReceipt> let reportingAuthority: DirectOperationReportingAuthority let task: Task } private let synchronize: @Sendable () async throws -> Void - private let entitlements: EntitlementRefreshCoordinator + private let entitlements: EntitlementRefreshCoordinator private var nextID: UInt64 = 0 private var inFlight: InFlight? + private nonisolated let taskCancellation = TaskCancellationBag() package init( synchronize: @escaping @Sendable () async throws -> Void, - entitlements: EntitlementRefreshCoordinator + entitlements: EntitlementRefreshCoordinator ) { self.synchronize = synchronize self.entitlements = entitlements } package func reserve( - retryFailedTransactions: Bool = true - ) -> RestoreReservation { + retryFailedTransactions: Bool = true, + directObservation: DirectOperationObservation? = nil + ) -> RestoreReservation { if let inFlight { return RestoreReservation( receipt: inFlight.receipt, role: .observer, - reportingAuthority: inFlight.reportingAuthority + reportingAuthority: inFlight.reportingAuthority, + directBinding: directObservation?.bind( + to: inFlight.reportingAuthority + ) ) } precondition(nextID < .max) nextID += 1 let id = nextID - let receipt = ProcessingReceipt() + let receipt = ProcessingReceipt>() let reportingAuthority = DirectOperationReportingAuthority() + let directBinding = directObservation?.bind(to: reportingAuthority) let synchronize = synchronize let entitlements = entitlements let task = Task.detached { [weak self] in - let result: Result do { try await synchronize() } catch { @@ -76,28 +84,39 @@ package actor RestoreCoordinator { result: .failure( RestoreCoordinatorFailure( propagating: error, - reportsWhenAbandoned: true, - reportingAuthority: reportingAuthority - )) + synchronized: false, + reportsWhenAbandoned: true + ) + ) ) return } let refresh = await entitlements.reserve( - retryFailedTransactions: retryFailedTransactions + retryFailedTransactions: retryFailedTransactions, + reportingAuthority: reportingAuthority ) do { - result = .success(try await refresh.receipt.terminalValue()) + await self?.complete( + id: id, + result: .success( + try await refresh.receipt.terminalValue() + ) + ) } catch { - result = .failure( - RestoreCoordinatorFailure( - propagating: error, - reportsWhenAbandoned: refresh.role == .owner, - reportingAuthority: refresh.reportingAuthority - )) + await self?.complete( + id: id, + result: .failure( + RestoreCoordinatorFailure( + propagating: error, + synchronized: true, + reportsWhenAbandoned: refresh.role == .owner + ) + ) + ) } - await self?.complete(id: id, result: result) } + taskCancellation.insert(task) inFlight = InFlight( id: id, receipt: receipt, @@ -107,13 +126,14 @@ package actor RestoreCoordinator { return RestoreReservation( receipt: receipt, role: .owner, - reportingAuthority: reportingAuthority + reportingAuthority: reportingAuthority, + directBinding: directBinding ) } private func complete( id: UInt64, - result: Result + result: Result, any Error> ) { guard let active = inFlight, active.id == id else { return } inFlight = nil @@ -123,6 +143,11 @@ package actor RestoreCoordinator { case .failure(let error): active.receipt.fail(error) } + taskCancellation.removeAll() + } + + package nonisolated func cancelSynchronously() { + taskCancellation.cancel() } isolated deinit { diff --git a/Sources/StoreTransactionKit/Runtime/StoreTransactionPipeline.swift b/Sources/StoreTransactionKit/Runtime/StoreTransactionPipeline.swift index 0f89ef7..8df02e6 100644 --- a/Sources/StoreTransactionKit/Runtime/StoreTransactionPipeline.swift +++ b/Sources/StoreTransactionKit/Runtime/StoreTransactionPipeline.swift @@ -1,11 +1,12 @@ -package final class StoreTransactionPipeline: Sendable { +package final class StoreTransactionPipeline: Sendable +where Entitlement: Hashable & Sendable { private let core: TransactionProcessingCore - private let entitlements: EntitlementRefreshCoordinator + private let entitlements: EntitlementRefreshCoordinator private let failures: FailureReporterDispatcher package init( core: TransactionProcessingCore, - entitlements: EntitlementRefreshCoordinator, + entitlements: EntitlementRefreshCoordinator, failures: FailureReporterDispatcher ) { self.core = core @@ -14,7 +15,8 @@ package final class StoreTransactionPipeline: Sendable { } package func accept( - _ delivery: StoreTransactionDelivery + _ delivery: StoreTransactionDelivery, + directObservation: DirectOperationObservation? = nil ) async throws -> ( snapshot: StoreTransactionSnapshot, acceptance: ProcessingAcceptance, @@ -25,7 +27,10 @@ package final class StoreTransactionPipeline: Sendable { let retryFailedTransactions = await core.beginTransactionAttempt() return ( envelope.value, - await core.accept(envelope), + await core.accept( + envelope, + directObservation: directObservation + ), retryFailedTransactions ) case .unverified(_, let error): @@ -37,72 +42,66 @@ package final class StoreTransactionPipeline: Sendable { _ delivery: StoreTransactionDelivery, source: StoreTransactionBackgroundFailure.Source ) async { - let snapshot: StoreTransactionSnapshot? - let acceptance: ProcessingAcceptance - let retryFailedTransactions: Bool + let accepted: + ( + snapshot: StoreTransactionSnapshot, + acceptance: ProcessingAcceptance, + retryFailedTransactions: Bool + ) do { - let accepted = try await accept(delivery) - snapshot = accepted.snapshot - acceptance = accepted.acceptance - retryFailedTransactions = accepted.retryFailedTransactions + accepted = try await accept(delivery) } catch { await failures.enqueue( StoreTransactionBackgroundFailure( source: source, transactionID: nil, productID: nil, - underlyingError: error - )) + underlyingError: exposedError(error) + ) + ) return } - await processAcceptedBackground( - snapshot: snapshot, - acceptance: acceptance, - retryFailedTransactions: retryFailedTransactions, - source: source - ) - } - - package func processAcceptedBackground( - snapshot: StoreTransactionSnapshot?, - acceptance: ProcessingAcceptance, - retryFailedTransactions: Bool = true, - source: StoreTransactionBackgroundFailure.Source - ) async { + let claim = await accepted.acceptance.claimCausalResolutionIfOwner() do { - _ = try await acceptance.receipt.terminalValue() + _ = try await accepted.acceptance.receipt.terminalValue() } catch { - guard case .owner = acceptance.role else { return } - await failures.enqueue( - StoreTransactionBackgroundFailure( - source: source, - transactionID: snapshot?.id, - productID: snapshot?.productID, - underlyingError: error - )) + if let claim { + await entitlements.resolve(claim, failure: error) + } + _ = try? await accepted.acceptance.causalReceipt.terminalValue() + await reportIfBackgroundOwned( + authority: accepted.acceptance.reportingAuthority, + source: source, + snapshot: accepted.snapshot, + error: error + ) return } - if case .inFlightObserver = acceptance.role { - return + if let claim { + let refresh = await entitlements.reserve( + retryFailedTransactions: accepted.retryFailedTransactions, + reportingAuthority: + accepted.acceptance.reportingAuthority + ) + do { + _ = try await refresh.receipt.terminalValue() + await claim.succeed() + } catch { + await claim.fail(error) + } } - let refresh = await entitlements.reserve( - retryFailedTransactions: retryFailedTransactions - ) + do { - _ = try await refresh.receipt.terminalValue() + _ = try await accepted.acceptance.causalReceipt.terminalValue() } catch { - let propagation = StoreTransactionFailurePropagation(error) - guard !propagation.hasReportingOwner else { return } - guard refresh.role == .owner else { return } - await failures.enqueue( - StoreTransactionBackgroundFailure( - source: .entitlementRefresh, - transactionID: snapshot?.id, - productID: snapshot?.productID, - underlyingError: propagation.underlyingError - )) + await reportIfBackgroundOwned( + authority: accepted.acceptance.reportingAuthority, + source: .entitlementRefresh, + snapshot: accepted.snapshot, + error: error + ) } } @@ -118,13 +117,44 @@ package final class StoreTransactionPipeline: Sendable { let propagation = StoreTransactionFailurePropagation(error) guard !propagation.hasReportingOwner else { return } guard refresh.role == .owner else { return } - await failures.enqueue( - StoreTransactionBackgroundFailure( - source: .entitlementRefresh, - transactionID: nil, - productID: nil, - underlyingError: propagation.underlyingError - )) + let report = StoreTransactionBackgroundFailure( + source: .entitlementRefresh, + transactionID: nil, + productID: nil, + underlyingError: exposedError(propagation.underlyingError) + ) + if let claimed = refresh.reportingAuthority.failWithoutParticipant( + report: report + ) { + await failures.enqueue(claimed) + } + } + } + + private func reportIfBackgroundOwned( + authority: DirectOperationReportingAuthority, + source: StoreTransactionBackgroundFailure.Source, + snapshot: StoreTransactionSnapshot, + error: any Error + ) async { + let report = StoreTransactionBackgroundFailure( + source: source, + transactionID: snapshot.id, + productID: snapshot.productID, + underlyingError: exposedError(error) + ) + if let claimed = authority.failWithoutParticipant(report: report) { + await failures.enqueue(claimed) + } + } + + private func exposedError(_ error: any Error) -> any Error { + let propagation = StoreTransactionFailurePropagation(error) + if let catalogFailure = + propagation.underlyingError as? StoreTransactionCatalogFailure + { + return catalogFailure.error } + return propagation.underlyingError } } diff --git a/Sources/StoreTransactionKit/Runtime/StoreTransactionRuntime.swift b/Sources/StoreTransactionKit/Runtime/StoreTransactionRuntime.swift index c8ecf51..1a985a6 100644 --- a/Sources/StoreTransactionKit/Runtime/StoreTransactionRuntime.swift +++ b/Sources/StoreTransactionKit/Runtime/StoreTransactionRuntime.swift @@ -1,149 +1,212 @@ import StoreKit +import Synchronization private struct DirectOperationFailure: Error { let underlyingError: any Error } -package final class StoreTransactionRuntime: Sendable { +package final class StoreTransactionRuntime: Sendable +where Entitlement: Hashable & Sendable { + private struct RuntimeTasks: Sendable { + let updates: Task + let subscriptionStatus: Task + let startup: Task + } + private let source: StoreTransactionSource + private let lifecycle: TransactionStoreLifecycle + private let delegate: TransactionStoreDelegateReference private let core: TransactionProcessingCore - private let entitlements: EntitlementRefreshCoordinator + private let entitlements: EntitlementRefreshCoordinator private let failures: FailureReporterDispatcher - private let pipeline: StoreTransactionPipeline - private let restoreCoordinator: RestoreCoordinator - private let operations = FiniteOperationRegistry() - private let readinessLease: FiniteOperationLease + private let pipeline: StoreTransactionPipeline + private let restoreCoordinator: RestoreCoordinator private let subscriptionStatusReadiness: ProcessingReceipt private let producerCancellation = TaskCancellationBag() private let finiteTasks = TaskCompletionBag() - private let updatesTask: Task - private let subscriptionStatusTask: Task + private let startupCompletion: ProcessingReceipt + private let tasks = Mutex(nil) package init( sessionID: UUID, source: StoreTransactionSource, - handleTransaction: - @escaping @Sendable (StoreTransactionSnapshot) async throws -> Void, - entitlementsDidChange: - @escaping @Sendable (StoreEntitlements) async -> Void, - entitlementRefreshDidSucceed: - @escaping @Sendable (EntitlementRefreshSuccess) async -> Void = { _ in }, - reportFailure: - @escaping @Sendable (StoreTransactionBackgroundFailure) async -> Void + lifecycle: TransactionStoreLifecycle, + subscriptionCatalog: AutoRenewableSubscriptionCatalog, + delegate: (any TransactionStoreDelegate)?, + entitlementOutcome: + @escaping @Sendable (EntitlementRefreshOutcome) async + -> Void ) { self.source = source + self.lifecycle = lifecycle + let startupCompletion = ProcessingReceipt() + self.startupCompletion = startupCompletion - let core = TransactionProcessingCore( + let delegateReference = TransactionStoreDelegateReference(delegate) + self.delegate = delegateReference + let failures = FailureReporterDispatcher( sessionID: sessionID, - handle: handleTransaction + lifetime: lifecycle, + report: { failure in + await delegateReference.didFail(with: failure) + } ) - let failures = FailureReporterDispatcher( + self.failures = failures + + let core = TransactionProcessingCore( sessionID: sessionID, - report: reportFailure + lifetime: lifecycle, + handle: { transaction in + let classification: AutoRenewableSubscriptionClassification + do { + classification = try subscriptionCatalog.classification( + of: transaction + ) + } catch let error as AutoRenewableSubscriptionCatalogError { + throw StoreTransactionCatalogFailure(error: error) + } + + let policy = try await delegateReference.decidePolicy( + for: transaction + ) + switch (classification, policy) { + case (.managed, .automatic), + (.managed, .finish), + (.unmanaged, .finish): + return + case (.unmanaged, .automatic): + throw StoreTransactionError.unhandledTransaction( + productID: transaction.productID, + productType: transaction.productType + ) + } + } ) - let currentEntitlements = CurrentEntitlementReconciler( + self.core = core + + let reconciler = CurrentEntitlementReconciler( query: source.currentEntitlements, queryUnfinished: source.queryUnfinished, - core: core, - failures: failures + core: core ) let entitlements = EntitlementRefreshCoordinator( - sessionID: sessionID, query: { retryFailedTransactions in - try await currentEntitlements.query( + try await reconciler.query( retryFailedTransactions: retryFailedTransactions ) }, - didChange: entitlementsDidChange, - didSucceed: entitlementRefreshDidSucceed + project: { + (entitlements: StoreEntitlements) throws(AutoRenewableSubscriptionCatalogError) + -> Set in + try subscriptionCatalog.activeEntitlements(in: entitlements) + }, + didComplete: entitlementOutcome, + failures: failures, + lifetime: lifecycle ) - let pipeline = StoreTransactionPipeline( + self.entitlements = entitlements + + let pipeline = StoreTransactionPipeline( core: core, entitlements: entitlements, failures: failures ) - self.core = core - self.entitlements = entitlements - self.failures = failures self.pipeline = pipeline self.restoreCoordinator = RestoreCoordinator( synchronize: source.synchronize, entitlements: entitlements ) - self.readinessLease = operations.begin()! + let subscriptionStatusReadiness = ProcessingReceipt() self.subscriptionStatusReadiness = subscriptionStatusReadiness - - self.updatesTask = Task.detached { - await source.runUpdates { delivery in - await pipeline.processBackground(delivery, source: .updates) - } - } - self.subscriptionStatusTask = Task.detached { - await source.runSubscriptionStatusUpdates { - do { - _ = try await subscriptionStatusReadiness.value() - } catch is ProcessingReceiptWaiterCancellation { - return - } catch { - preconditionFailure( - "Subscription status readiness cannot fail: \(error)" - ) - } - await pipeline.refreshEntitlements() - } - } - producerCancellation.insert(updatesTask) - producerCancellation.insert(subscriptionStatusTask) } - package func beginOperation() -> FiniteOperationLeases? { - operations.beginPair() - } - - package func readiness() async throws -> StoreTransactionReadiness { - let reservation = await entitlements.reserve( - retryFailedTransactions: false - ) - let completion = ProcessingReceipt() + package func start() { + let source = source + let lifecycle = lifecycle + let pipeline = pipeline + let entitlements = entitlements let core = core + let failures = failures let subscriptionStatusReadiness = subscriptionStatusReadiness - let readinessLease = readinessLease - let task = Task { - let result: Result + let startupCompletion = startupCompletion + + let updatesTask = Task.detached { + await source.runUpdates( + { lifecycle.beginProducerIteration() }, + { delivery in + await pipeline.processBackground( + delivery, + source: .updates + ) + } + ) + } + let subscriptionStatusTask = Task.detached { + await source.runSubscriptionStatusUpdates( + { lifecycle.beginProducerIteration() }, + { + _ = try? await subscriptionStatusReadiness.terminalValue() + await pipeline.refreshEntitlements() + } + ) + } + let startupTask = Task.detached { + let reservation = await entitlements.reserve( + retryFailedTransactions: false + ) + let result: Result do { - result = .success( - StoreTransactionReadiness( - entitlements: try await reservation.receipt.terminalValue(), - refreshToken: reservation.token - )) - } catch let owned as StoreTransactionFailureWithReportingOwner { - result = .failure(owned.underlyingError) + _ = try await reservation.receipt.terminalValue() + result = .success(()) } catch { - result = .failure(error) + let propagation = StoreTransactionFailurePropagation(error) + let exposed: any Error + if let catalogFailure = + propagation.underlyingError + as? StoreTransactionCatalogFailure + { + exposed = catalogFailure.error + } else { + exposed = propagation.underlyingError + } + if !propagation.hasReportingOwner { + let report = StoreTransactionBackgroundFailure( + source: .entitlementRefresh, + transactionID: nil, + productID: nil, + underlyingError: exposed + ) + if let claimed = reservation.reportingAuthority + .failWithoutParticipant(report: report) + { + await failures.enqueue(claimed) + } + } + result = .failure(exposed) } await core.completeInitialAttempt() subscriptionStatusReadiness.succeed(()) - readinessLease.end() switch result { - case .success(let readiness): - completion.succeed(readiness) + case .success: + startupCompletion.succeed(()) case .failure(let error): - completion.fail( - StoreTransactionReadinessFailure( - refreshToken: reservation.token, - underlyingError: error - ) - ) + startupCompletion.fail(error) } } - finiteTasks.insert(task) - - do { - return try await completion.value() - } catch is ProcessingReceiptWaiterCancellation { - throw CancellationError() + let inserted = tasks.withLock { tasks in + guard tasks == nil else { return false } + tasks = RuntimeTasks( + updates: updatesTask, + subscriptionStatus: subscriptionStatusTask, + startup: startupTask + ) + return true } + precondition(inserted, "A transaction runtime can start only once.") + producerCancellation.insert(updatesTask) + producerCancellation.insert(subscriptionStatusTask) + finiteTasks.insert(startupTask) } package func process( @@ -152,32 +215,20 @@ package final class StoreTransactionRuntime: Sendable { ) async throws -> StorePurchaseOutcome { switch result { case .success(let verificationResult): - return try await process( + return try await processAccepted( source.purchaseDelivery(verificationResult), leases: leases ) case .pending: - do { - try Task.checkCancellation() - } catch { - leases.work.end() - leases.observer.end() - throw error - } - leases.work.end() - leases.observer.end() - return .pending + return try finishImmediate( + leases: leases, + outcome: .pending + ) case .userCancelled: - do { - try Task.checkCancellation() - } catch { - leases.work.end() - leases.observer.end() - throw error - } - leases.work.end() - leases.observer.end() - return .userCancelled + return try finishImmediate( + leases: leases, + outcome: .userCancelled + ) @unknown default: leases.work.end() leases.observer.end() @@ -187,69 +238,106 @@ package final class StoreTransactionRuntime: Sendable { package func process( _ delivery: StoreTransactionDelivery, - leases: FiniteOperationLeases + leases: FiniteOperationLeases, + didAdmit: @escaping @Sendable () async -> Void = {} ) async throws -> StorePurchaseOutcome { + return try await processAccepted( + delivery, + leases: leases, + didAdmit: didAdmit + ) + } + + private func processAccepted( + _ delivery: StoreTransactionDelivery, + leases: FiniteOperationLeases, + didAdmit: @escaping @Sendable () async -> Void = {} + ) async throws -> StorePurchaseOutcome { + if case .unverified(_, let error) = delivery { + return try await failAdmittedDelivery( + error, + leases: leases, + didAdmit: didAdmit + ) + } + let accepted: ( snapshot: StoreTransactionSnapshot, acceptance: ProcessingAcceptance, retryFailedTransactions: Bool ) + let observation = DirectOperationObservation() do { - accepted = try await pipeline.accept(delivery) + accepted = try await pipeline.accept( + delivery, + directObservation: observation + ) } catch { leases.work.end() leases.observer.end() - throw error + throw exposedError(error) } - let observation = DirectOperationObservation() - let transactionBinding = observation.bind( - to: accepted.acceptance.reportingAuthority - ) + + guard let binding = accepted.acceptance.directBinding else { + preconditionFailure("A direct transaction lost its reporting binding.") + } + let claim = await accepted.acceptance.claimCausalResolutionIfOwner() + await didAdmit() let operationReceipt = ProcessingReceipt() - let entitlements = entitlements let task = Task { defer { leases.work.end() } - let snapshot: StoreTransactionSnapshot do { - snapshot = try await accepted.acceptance.receipt - .terminalValue() + _ = try await accepted.acceptance.receipt.terminalValue() } catch { + if let claim { + await entitlements.resolve(claim, failure: error) + } + _ = try? await accepted.acceptance.causalReceipt.terminalValue() operationReceipt.fail( await directFailure( observation: observation, - binding: transactionBinding, + binding: binding, propagating: error, - reportsWhenAbandoned: - accepted.acceptance.role == .owner, + reportsWhenAbandoned: true, operation: .processPurchase, snapshot: accepted.snapshot ) ) return } - observation.succeed(transactionBinding) - let refresh = await entitlements.reserve( - retryFailedTransactions: - accepted.retryFailedTransactions - ) - let refreshBinding = observation.bind( - to: refresh.reportingAuthority - ) + if let claim { + let refresh = await entitlements.reserve( + retryFailedTransactions: accepted.retryFailedTransactions, + reportingAuthority: + accepted.acceptance.reportingAuthority + ) + do { + _ = try await refresh.receipt.terminalValue() + await claim.succeed() + } catch { + await claim.fail(error) + } + } + do { - _ = try await refresh.receipt.terminalValue() - observation.succeed(refreshBinding) - operationReceipt.succeed(snapshot) + _ = try await accepted.acceptance.causalReceipt.terminalValue() + observation.succeed(binding) + operationReceipt.succeed(accepted.snapshot) } catch { operationReceipt.fail( await directFailure( observation: observation, - binding: refreshBinding, - propagating: error, - reportsWhenAbandoned: refresh.role == .owner, + binding: binding, + propagating: completedTransactionFailure( + snapshot: accepted.snapshot, + error: error + ), + reportsWhenAbandoned: true, operation: .processPurchase, - snapshot: accepted.snapshot + snapshot: accepted.snapshot, + backgroundError: exposedError(error) ) ) } @@ -262,25 +350,58 @@ package final class StoreTransactionRuntime: Sendable { ) { .completed($0) } } + private func failAdmittedDelivery( + _ error: any Error, + leases: FiniteOperationLeases, + didAdmit: @escaping @Sendable () async -> Void + ) async throws -> StorePurchaseOutcome { + let observation = DirectOperationObservation() + let binding = observation.bind( + to: DirectOperationReportingAuthority() + ) + await didAdmit() + let operationReceipt = ProcessingReceipt() + let task = Task { + defer { leases.work.end() } + operationReceipt.fail( + await directFailure( + observation: observation, + binding: binding, + propagating: error, + reportsWhenAbandoned: true, + operation: .processPurchase, + snapshot: nil + ) + ) + } + finiteTasks.insert(task) + return try await outcome( + receipt: operationReceipt, + observation: observation, + observerLease: leases.observer + ) { .completed($0) } + } + package func currentEntitlements( leases: FiniteOperationLeases ) async throws -> StoreEntitlements { let retryFailedTransactions = await core.retryFailedTransactionsInNewAttempt() + let observation = DirectOperationObservation() let refresh = await entitlements.reserve( - retryFailedTransactions: retryFailedTransactions + retryFailedTransactions: retryFailedTransactions, + directObservation: observation ) - let observation = DirectOperationObservation() - let binding = observation.bind(to: refresh.reportingAuthority) - let operationReceipt = ProcessingReceipt() + guard let binding = refresh.directBinding else { + preconditionFailure("A direct refresh lost its reporting binding.") + } + let operationReceipt = ProcessingReceipt>() let task = Task { defer { leases.work.end() } do { - let value = try await refresh.receipt.terminalValue() + let publication = try await refresh.receipt.terminalValue() observation.succeed(binding) - operationReceipt.succeed( - value - ) + operationReceipt.succeed(publication) } catch { operationReceipt.fail( await directFailure( @@ -299,7 +420,7 @@ package final class StoreTransactionRuntime: Sendable { receipt: operationReceipt, observation: observation, observerLease: leases.observer - ) { $0 } + ) { $0.entitlements } } package func history( @@ -311,7 +432,6 @@ package final class StoreTransactionRuntime: Sendable { to: DirectOperationReportingAuthority() ) let operationReceipt = ProcessingReceipt<[StoreTransactionSnapshot]>() - let source = source let task = Task { defer { leases.work.end() } do { @@ -345,42 +465,43 @@ package final class StoreTransactionRuntime: Sendable { ) async throws -> StoreEntitlements { let retryFailedTransactions = await core.retryFailedTransactionsInNewAttempt() - let restore = await restoreCoordinator.reserve( - retryFailedTransactions: retryFailedTransactions - ) let observation = DirectOperationObservation() - let restoreBinding = observation.bind( - to: restore.reportingAuthority + let restore = await restoreCoordinator.reserve( + retryFailedTransactions: retryFailedTransactions, + directObservation: observation ) - let operationReceipt = ProcessingReceipt() + guard let binding = restore.directBinding else { + preconditionFailure("A direct restore lost its reporting binding.") + } + let operationReceipt = ProcessingReceipt>() let task = Task { defer { leases.work.end() } do { - let value = try await restore.receipt.terminalValue() - observation.succeed(restoreBinding) - operationReceipt.succeed( - value - ) + let publication = try await restore.receipt.terminalValue() + observation.succeed(binding) + operationReceipt.succeed(publication) } catch let failure as RestoreCoordinatorFailure { - let failureBinding: DirectOperationObservation.Binding - if failure.reportingAuthority === restore.reportingAuthority { - failureBinding = restoreBinding - } else { - observation.succeed(restoreBinding) - failureBinding = observation.bind( - to: failure.reportingAuthority + let exposed = exposedError(failure.underlyingError) + let directError: any Error + if failure.synchronized { + directError = StoreTransactionError.entitlementRefreshFailed( + after: .synchronizedPurchases, + underlyingError: exposed ) + } else { + directError = exposed } operationReceipt.fail( await directFailure( observation: observation, - binding: failureBinding, - propagating: failure.underlyingError, + binding: binding, + propagating: directError, reportsWhenAbandoned: restore.role == .owner && failure.reportsWhenAbandoned, operation: .restorePurchases, - snapshot: nil + snapshot: nil, + backgroundError: exposed ) ) } catch { @@ -394,24 +515,62 @@ package final class StoreTransactionRuntime: Sendable { receipt: operationReceipt, observation: observation, observerLease: leases.observer - ) { $0 } + ) { $0.entitlements } + } + + package func waitForInitialReadiness() async throws { + do { + try await startupCompletion.value() + } catch is ProcessingReceiptWaiterCancellation { + throw CancellationError() + } } - package func close() async { + package func shutdown() async { + let tasks = tasks.withLock { tasks -> RuntimeTasks in + guard let tasks else { + preconditionFailure("A transaction runtime was closed before start.") + } + return tasks + } producerCancellation.cancel() - await updatesTask.value - await subscriptionStatusTask.value - await operations.stopAdmissionAndWait() + tasks.startup.cancel() + await tasks.updates.value + await tasks.subscriptionStatus.value + await lifecycle.waitForProducerIterations() + await lifecycle.waitForOperations() await finiteTasks.waitForAll() await entitlements.sealAndDrain() await core.finishInputAndDrain() await failures.sealAndDrain() + delegate.release() producerCancellation.removeAll() } package func cancelSynchronously() { + lifecycle.sealSynchronously() producerCancellation.cancel() finiteTasks.cancel() + restoreCoordinator.cancelSynchronously() + core.cancelSynchronously() + entitlements.cancelSynchronously() + failures.cancelSynchronously() + } + + private func finishImmediate( + leases: FiniteOperationLeases, + outcome: StorePurchaseOutcome + ) throws -> StorePurchaseOutcome { + do { + try Task.checkCancellation() + } catch { + leases.work.end() + leases.observer.end() + throw error + } + leases.work.end() + leases.observer.end() + return outcome } private func outcome( @@ -448,16 +607,18 @@ package final class StoreTransactionRuntime: Sendable { propagating error: any Error, reportsWhenAbandoned: Bool, operation: StoreTransactionOperation, - snapshot: StoreTransactionSnapshot? + snapshot: StoreTransactionSnapshot?, + backgroundError: (any Error)? = nil ) async -> DirectOperationFailure { let propagation = StoreTransactionFailurePropagation(error) + let exposed = exposedError(propagation.underlyingError) let report: StoreTransactionBackgroundFailure? if reportsWhenAbandoned && !propagation.hasReportingOwner { report = StoreTransactionBackgroundFailure( source: .abandonedDirectOperation(operation), transactionID: snapshot?.id, productID: snapshot?.productID, - underlyingError: propagation.underlyingError + underlyingError: backgroundError.map(exposedError) ?? exposed ) } else { report = nil @@ -466,8 +627,35 @@ package final class StoreTransactionRuntime: Sendable { await failures.enqueue(claimed) } return DirectOperationFailure( - underlyingError: propagation.underlyingError + underlyingError: exposed + ) + } + + private func completedTransactionFailure( + snapshot: StoreTransactionSnapshot, + error: any Error + ) -> any Error { + let propagation = StoreTransactionFailurePropagation(error) + let publicError = StoreTransactionError.entitlementRefreshFailed( + after: .finishedTransaction(snapshot), + underlyingError: exposedError(propagation.underlyingError) ) + if propagation.hasReportingOwner { + return StoreTransactionFailureWithReportingOwner( + underlyingError: publicError + ) + } + return publicError + } + + private func exposedError(_ error: any Error) -> any Error { + let propagation = StoreTransactionFailurePropagation(error) + if let catalogFailure = + propagation.underlyingError as? StoreTransactionCatalogFailure + { + return catalogFailure.error + } + return propagation.underlyingError } package static func historyOrder( diff --git a/Sources/StoreTransactionKit/Runtime/TaskCancellationBag.swift b/Sources/StoreTransactionKit/Runtime/TaskCancellationBag.swift index 6066df3..4263113 100644 --- a/Sources/StoreTransactionKit/Runtime/TaskCancellationBag.swift +++ b/Sources/StoreTransactionKit/Runtime/TaskCancellationBag.swift @@ -1,22 +1,36 @@ import Synchronization package final class TaskCancellationBag: Sendable { - private let tasks = Mutex<[Task]>([]) + private struct State { + var tasks: [Task] = [] + var isCancelled = false + } + + private let state = Mutex(State()) package init() {} package func insert(_ task: Task) { - tasks.withLock { $0.append(task) } + let shouldCancel = state.withLock { state in + state.tasks.append(task) + return state.isCancelled + } + if shouldCancel { + task.cancel() + } } package func cancel() { - let snapshot = tasks.withLock { $0 } + let snapshot = state.withLock { state in + state.isCancelled = true + return state.tasks + } for task in snapshot { task.cancel() } } package func removeAll() { - tasks.withLock { $0.removeAll(keepingCapacity: false) } + state.withLock { $0.tasks.removeAll(keepingCapacity: false) } } } diff --git a/Sources/StoreTransactionKit/Runtime/TaskCompletionBag.swift b/Sources/StoreTransactionKit/Runtime/TaskCompletionBag.swift index 8264cd8..72b63d6 100644 --- a/Sources/StoreTransactionKit/Runtime/TaskCompletionBag.swift +++ b/Sources/StoreTransactionKit/Runtime/TaskCompletionBag.swift @@ -2,58 +2,40 @@ import Foundation import Synchronization package final class TaskCompletionBag: Sendable { - private struct State { - var tasks: [UUID: Task] = [:] - var emptyWaiters: [CheckedContinuation] = [] - } - - private let state = Mutex(State()) + private let tasks = Mutex<[UUID: Task]>([:]) package init() {} package func insert(_ task: Task) { let id = UUID() - state.withLock { $0.tasks[id] = task } - Task { [weak self] in - await task.value - self?.remove(id) - } + tasks.withLock { $0[id] = task } } package func waitForAll() async { - await withCheckedContinuation { continuation in - let isEmpty = state.withLock { state in - guard !state.tasks.isEmpty else { return true } - state.emptyWaiters.append(continuation) - return false + while true { + let snapshot = tasks.withLock { Array($0) } + guard !snapshot.isEmpty else { return } + + for (_, task) in snapshot { + await task.value } - if isEmpty { - continuation.resume() + + tasks.withLock { tasks in + for (id, _) in snapshot { + tasks.removeValue(forKey: id) + } } } } package func cancel() { - let snapshot = state.withLock { Array($0.tasks.values) } + let snapshot = tasks.withLock { Array($0.values) } for task in snapshot { task.cancel() } } package func retainedTaskCount() -> Int { - state.withLock { $0.tasks.count } - } - - private func remove(_ id: UUID) { - let waiters = state.withLock { state -> [CheckedContinuation] in - state.tasks.removeValue(forKey: id) - guard state.tasks.isEmpty else { return [] } - let waiters = state.emptyWaiters - state.emptyWaiters.removeAll(keepingCapacity: false) - return waiters - } - for waiter in waiters { - waiter.resume() - } + tasks.withLock(\.count) } } diff --git a/Sources/StoreTransactionKit/Runtime/TransactionStoreDelegateReference.swift b/Sources/StoreTransactionKit/Runtime/TransactionStoreDelegateReference.swift new file mode 100644 index 0000000..c376995 --- /dev/null +++ b/Sources/StoreTransactionKit/Runtime/TransactionStoreDelegateReference.swift @@ -0,0 +1,27 @@ +import Synchronization + +package final class TransactionStoreDelegateReference: Sendable { + private let delegate: Mutex<(any TransactionStoreDelegate)?> + + package init(_ delegate: (any TransactionStoreDelegate)?) { + self.delegate = Mutex(delegate) + } + + package func decidePolicy( + for transaction: StoreTransactionSnapshot + ) async throws -> StoreTransactionHandlingPolicy { + let delegate = delegate.withLock { $0 } + return try await delegate?.decidePolicy(for: transaction) ?? .automatic + } + + package func didFail( + with failure: StoreTransactionBackgroundFailure + ) async { + let delegate = delegate.withLock { $0 } + await delegate?.didFail(with: failure) + } + + package func release() { + delegate.withLock { $0 = nil } + } +} diff --git a/Sources/StoreTransactionKit/Runtime/TransactionStoreLifecycle.swift b/Sources/StoreTransactionKit/Runtime/TransactionStoreLifecycle.swift new file mode 100644 index 0000000..3bdeaab --- /dev/null +++ b/Sources/StoreTransactionKit/Runtime/TransactionStoreLifecycle.swift @@ -0,0 +1,152 @@ +import Synchronization + +package final class TransactionStoreLifecycle: Sendable { + private enum Phase: Sendable { + case running + case closing(ProcessingReceipt) + case closed + } + + private struct State: Sendable { + var phase: Phase = .running + var liveLease: LiveTransactionStoreLease? + } + + private enum CloseAction: Sendable { + case start(ProcessingReceipt) + case join(ProcessingReceipt) + case complete + } + + private let state: Mutex + private let operations = FiniteOperationRegistry() + private let producerIterations = FiniteOperationRegistry() + private let didSeal = ProcessingReceipt() + + package init(liveLease: LiveTransactionStoreLease? = nil) { + state = Mutex(State(liveLease: liveLease)) + } + + package func validateRunning() throws { + switch phase() { + case .running: + return + case .closing: + throw StoreTransactionError.closing + case .closed: + throw StoreTransactionError.closed + } + } + + package func beginOperation() throws -> FiniteOperationLeases { + try Task.checkCancellation() + switch phase() { + case .running: + guard let leases = operations.beginPair() else { + throw lifecycleError() + } + return leases + case .closing: + throw StoreTransactionError.closing + case .closed: + throw StoreTransactionError.closed + } + } + + package func beginProducerIteration() -> FiniteOperationLease? { + guard case .running = phase() else { return nil } + return producerIterations.begin() + } + + package func close( + shutdown: @escaping @Sendable () async -> Void + ) async { + let action = state.withLock { state -> CloseAction in + switch state.phase { + case .running: + let receipt = ProcessingReceipt() + state.phase = .closing(receipt) + operations.seal() + producerIterations.seal() + return .start(receipt) + case .closing(let receipt): + return .join(receipt) + case .closed: + return .complete + } + } + + switch action { + case .start(let receipt): + didSeal.succeed(()) + Task.detached { [self] in + await shutdown() + finishClose(receipt: receipt) + } + _ = try? await receipt.terminalValue() + case .join(let receipt): + _ = try? await receipt.terminalValue() + case .complete: + return + } + } + + package func waitForOperations() async { + await operations.waitForDrain() + } + + package func waitForProducerIterations() async { + await producerIterations.waitForDrain() + } + + package func waitUntilSealed() async { + _ = try? await didSeal.terminalValue() + } + + package func sealSynchronously() { + let shouldSeal = state.withLock { state in + guard case .running = state.phase else { return false } + state.phase = .closing(ProcessingReceipt()) + operations.seal() + producerIterations.seal() + return true + } + guard shouldSeal else { return } + didSeal.succeed(()) + } + + private func phase() -> Phase { + state.withLock(\.phase) + } + + private func lifecycleError() -> StoreTransactionError { + switch phase() { + case .running, .closing: + .closing + case .closed: + .closed + } + } + + private func finishClose(receipt: ProcessingReceipt) { + let lease = state.withLock { state -> LiveTransactionStoreLease? in + guard case .closing(let activeReceipt) = state.phase, + activeReceipt === receipt + else { + preconditionFailure("Close completed without owning lifecycle shutdown.") + } + state.phase = .closed + defer { state.liveLease = nil } + return state.liveLease + } + lease?.release() + receipt.succeed(()) + } + + deinit { + state.withLock { state in + state.liveLease?.release() + state.liveLease = nil + } + } +} diff --git a/Sources/StoreTransactionKit/StoreKitSource/LiveStoreTransactionSource.swift b/Sources/StoreTransactionKit/StoreKitSource/LiveStoreTransactionSource.swift index a7f82e6..5774eb3 100644 --- a/Sources/StoreTransactionKit/StoreKitSource/LiveStoreTransactionSource.swift +++ b/Sources/StoreTransactionKit/StoreKitSource/LiveStoreTransactionSource.swift @@ -1,51 +1,60 @@ import StoreKit package extension StoreTransactionSource { - static let live = StoreTransactionSource( - runUpdates: { consume in - for await result in Transaction.updates { - await consume(LiveTransactionAdapter.delivery(result)) - } - }, - runSubscriptionStatusUpdates: { consume in - for await _ in Product.SubscriptionInfo.Status.updates { - await consume() - } - }, - currentEntitlements: { - var snapshots: [StoreTransactionSnapshot] = [] - var verificationFailures: [StoreTransactionVerificationError] = [] - for await result in Transaction.currentEntitlements { - do { + static var live: StoreTransactionSource { + StoreTransactionSource( + runUpdates: { beginIteration, consume in + var iterator = Transaction.updates.makeAsyncIterator() + while let lease = beginIteration() { + defer { lease.end() } + guard let result = await iterator.next() else { return } + await consume(LiveTransactionAdapter.delivery(result)) + } + }, + runSubscriptionStatusUpdates: { beginIteration, consume in + var iterator = Product.SubscriptionInfo.Status.updates + .makeAsyncIterator() + while let lease = beginIteration() { + defer { lease.end() } + guard await iterator.next() != nil else { return } + await consume() + } + }, + currentEntitlements: { + var snapshots: [StoreTransactionSnapshot] = [] + var verificationFailures: [StoreTransactionVerificationError] = [] + for await result in Transaction.currentEntitlements { + do { + snapshots.append(try LiveTransactionAdapter.snapshot(result)) + } catch let error as StoreTransactionVerificationError { + verificationFailures.append(error) + } + } + return CurrentEntitlementQueryResult( + snapshots: snapshots, + verificationFailures: verificationFailures + ) + }, + queryUnfinished: { + var deliveries: [StoreTransactionDelivery] = [] + for await result in Transaction.unfinished { + deliveries.append(LiveTransactionAdapter.delivery(result)) + } + return deliveries + }, + history: { productID in + var snapshots: [StoreTransactionSnapshot] = [] + for await result in Transaction.all(for: productID) { snapshots.append(try LiveTransactionAdapter.snapshot(result)) - } catch let error as StoreTransactionVerificationError { - verificationFailures.append(error) } + return snapshots + }, + synchronize: { + try await AppStore.sync() + }, + purchaseDelivery: { result in + LiveTransactionAdapter.delivery(result) } - return CurrentEntitlementQueryResult( - snapshots: snapshots, - verificationFailures: verificationFailures - ) - }, - queryUnfinished: { - var deliveries: [StoreTransactionDelivery] = [] - for await result in Transaction.unfinished { - deliveries.append(LiveTransactionAdapter.delivery(result)) - } - return deliveries - }, - history: { productID in - var snapshots: [StoreTransactionSnapshot] = [] - for await result in Transaction.all(for: productID) { - snapshots.append(try LiveTransactionAdapter.snapshot(result)) - } - return snapshots - }, - synchronize: { - try await AppStore.sync() - }, - purchaseDelivery: { result in - LiveTransactionAdapter.delivery(result) - } - ) + ) + } } diff --git a/Sources/StoreTransactionKit/StoreKitSource/StoreTransactionSource.swift b/Sources/StoreTransactionKit/StoreKitSource/StoreTransactionSource.swift index c9e8f40..3339d1c 100644 --- a/Sources/StoreTransactionKit/StoreKitSource/StoreTransactionSource.swift +++ b/Sources/StoreTransactionKit/StoreKitSource/StoreTransactionSource.swift @@ -22,10 +22,12 @@ package struct CurrentEntitlementQueryResult: Sendable { package struct StoreTransactionSource: Sendable { package let runUpdates: @Sendable ( + @Sendable () -> FiniteOperationLease?, @Sendable (StoreTransactionDelivery) async -> Void ) async -> Void package let runSubscriptionStatusUpdates: @Sendable ( + @Sendable () -> FiniteOperationLease?, @Sendable () async -> Void ) async -> Void package let currentEntitlements: @Sendable () async throws -> CurrentEntitlementQueryResult @@ -37,10 +39,12 @@ package struct StoreTransactionSource: Sendable { package init( runUpdates: @escaping @Sendable ( + @Sendable () -> FiniteOperationLease?, @Sendable (StoreTransactionDelivery) async -> Void ) async -> Void, runSubscriptionStatusUpdates: @escaping @Sendable ( + @Sendable () -> FiniteOperationLease?, @Sendable () async -> Void ) async -> Void, currentEntitlements: diff --git a/Sources/StoreTransactionKit/StoreKitSource/SyntheticStoreTransactionSource.swift b/Sources/StoreTransactionKit/StoreKitSource/SyntheticStoreTransactionSource.swift new file mode 100644 index 0000000..6c74c7b --- /dev/null +++ b/Sources/StoreTransactionKit/StoreKitSource/SyntheticStoreTransactionSource.swift @@ -0,0 +1,41 @@ +import Foundation +import StoreKit + +package struct SyntheticStoreTransactionSource: Sendable { + package let source: StoreTransactionSource + + package init( + currentEntitlements: + @escaping @Sendable () async -> [StoreTransactionSnapshot] + ) { + source = StoreTransactionSource( + runUpdates: { _, _ in }, + runSubscriptionStatusUpdates: { _, _ in }, + currentEntitlements: { + CurrentEntitlementQueryResult( + snapshots: await currentEntitlements(), + verificationFailures: [] + ) + }, + queryUnfinished: { [] }, + history: { _ in preconditionFailure() }, + synchronize: { preconditionFailure() }, + purchaseDelivery: { _ in preconditionFailure() } + ) + } +} + +package extension StoreTransactionDelivery { + static func synthetic( + snapshot: StoreTransactionSnapshot, + acknowledge: @escaping @Sendable () async -> Void + ) -> StoreTransactionDelivery { + .verified( + ProcessingEnvelope( + revision: Data(snapshot.jwsRepresentation.utf8), + value: snapshot, + finish: acknowledge + ) + ) + } +} diff --git a/Sources/StoreTransactionKit/StoreTransactionSession.swift b/Sources/StoreTransactionKit/StoreTransactionSession.swift deleted file mode 100644 index 65701aa..0000000 --- a/Sources/StoreTransactionKit/StoreTransactionSession.swift +++ /dev/null @@ -1,294 +0,0 @@ -import Foundation -import StoreKit - -/// The package-owned StoreKit 2 transaction and entitlement session. -/// -/// Create exactly one session in the application's process composition root and -/// call ``start()`` as early as the app's durable dependencies are available. -/// The session monitors StoreKit updates and startup unfinished transactions; -/// presentation of purchase UI remains the application's responsibility. -/// -/// Explicitly call ``close()`` from controlled shutdown and test lifecycles. -/// Dropping the last reference is not an awaitable shutdown mechanism. -package actor StoreTransactionSession { - private struct Configuration: Sendable { - let source: StoreTransactionSource - let handleTransaction: @Sendable (StoreTransactionSnapshot) async throws -> Void - let entitlementsDidChange: @Sendable (StoreEntitlements) async -> Void - let entitlementRefreshDidSucceed: @Sendable (EntitlementRefreshSuccess) async -> Void - let reportFailure: @Sendable (StoreTransactionBackgroundFailure) async -> Void - } - - private enum State: Sendable { - case initialized(Configuration) - case running(StoreTransactionRuntime) - case closing(Task) - case closed - } - - private let sessionID: UUID - private var state: State - - /// Creates a StoreKit transaction session. - /// - /// - Parameters: - /// - handleTransaction: Applies the durable business effect for a verified - /// transaction. StoreTransactionKit exposes an at-least-once - /// handler-delivery contract, so the handler must be idempotent. - /// StoreTransactionKit calls `finish()` only after this closure returns - /// successfully. - /// - entitlementsDidChange: Receives complete, ordered entitlement - /// snapshots when the current entitlement content changes. - /// - reportFailure: Receives failures owned by process background work, - /// including background deliveries, reconciliation verification, and - /// direct operations abandoned by every caller. Admitted failures are - /// delivered serially and losslessly with backpressure. The callback - /// must return promptly; ``close()`` waits for every admitted callback - /// to finish. - /// - /// None of the callbacks may call back into the same session, directly or - /// through an awaited child or detached task. - package init( - sessionID: UUID = UUID(), - handleTransaction: - @escaping @Sendable (StoreTransactionSnapshot) async throws -> Void, - entitlementsDidChange: - @escaping @Sendable (StoreEntitlements) async -> Void = { _ in }, - reportFailure: - @escaping @Sendable (StoreTransactionBackgroundFailure) async -> Void - ) { - self.sessionID = sessionID - self.state = .initialized( - Configuration( - source: .live, - handleTransaction: handleTransaction, - entitlementsDidChange: entitlementsDidChange, - entitlementRefreshDidSucceed: { _ in }, - reportFailure: reportFailure - )) - } - - package init( - sessionID: UUID = UUID(), - source: StoreTransactionSource, - handleTransaction: - @escaping @Sendable (StoreTransactionSnapshot) async throws -> Void, - entitlementsDidChange: - @escaping @Sendable (StoreEntitlements) async -> Void = { _ in }, - entitlementRefreshDidSucceed: - @escaping @Sendable (EntitlementRefreshSuccess) async -> Void = { _ in }, - reportFailure: - @escaping @Sendable (StoreTransactionBackgroundFailure) async -> Void - ) { - self.sessionID = sessionID - self.state = .initialized( - Configuration( - source: source, - handleTransaction: handleTransaction, - entitlementsDidChange: entitlementsDidChange, - entitlementRefreshDidSucceed: entitlementRefreshDidSucceed, - reportFailure: reportFailure - )) - } - - /// Starts transaction monitoring, reconciles unfinished work, and publishes initial entitlements. - /// - /// All StoreKit producer tasks are retained before this method first - /// suspends. The method returns only after the startup unfinished sequence, - /// the initial entitlement query, and any initial entitlement callback have - /// completed. - /// - /// If the waiting caller cancels after startup begins, the process-owned - /// session remains running. Its lifecycle owner must call ``close()``. - /// - /// - Returns: The initial entitlement readiness value. - /// - Throws: A lifecycle or callback reentrancy error, an entitlement query - /// error, or `CancellationError` when the attached waiter cancels. - package func start() async throws -> StoreTransactionReadiness { - do { - return try await startPreservingReadinessFailure() - } catch let failure as StoreTransactionReadinessFailure { - throw failure.underlyingError - } - } - - package func startForTransactionStore() async throws -> StoreTransactionReadiness { - try await startPreservingReadinessFailure() - } - - private func startPreservingReadinessFailure() async throws - -> StoreTransactionReadiness - { - guard case .initialized(let configuration) = state else { - switch state { - case .running: throw StoreTransactionLifecycleError.alreadyStarted - case .closing: throw StoreTransactionError.closing - case .closed: throw StoreTransactionError.closed - case .initialized: preconditionFailure() - } - } - - let runtime = StoreTransactionRuntime( - sessionID: sessionID, - source: configuration.source, - handleTransaction: configuration.handleTransaction, - entitlementsDidChange: configuration.entitlementsDidChange, - entitlementRefreshDidSucceed: - configuration.entitlementRefreshDidSucceed, - reportFailure: configuration.reportFailure - ) - state = .running(runtime) - - let readiness = try await runtime.readiness() - guard case .running(let activeRuntime) = state, - activeRuntime === runtime - else { - throw StoreTransactionError.closing - } - return readiness - } - - /// Processes the result of purchase UI presented by the application. - /// - /// A verified success enters the same durable FIFO used by updates and - /// unfinished transactions. Pending and user-cancelled results are returned - /// as values and never reported as failures. - /// - /// - Parameter result: The purchase result returned by StoreKit. - /// - Returns: The semantic outcome after any required durable handling, - /// finish, and entitlement refresh. - /// - Throws: Verification, durable handler, entitlement refresh, lifecycle, - /// callback reentrancy, or caller cancellation errors. - package func process( - _ result: Product.PurchaseResult - ) async throws -> StorePurchaseOutcome { - let runtime = try runningRuntime(operation: .processPurchase) - guard let leases = runtime.beginOperation() else { - throw StoreTransactionError.closing - } - return try await runtime.process(result, leases: leases) - } - - /// Refreshes and returns the complete current entitlement projection. - /// - /// Concurrent refresh reservations are coalesced only when they precede the - /// same physical query cutoff. A changed result is returned after its ordered - /// callback completes. - /// - /// - Returns: The current entitlement publication. - /// - Throws: A StoreKit verification or query error, a lifecycle or callback - /// reentrancy error, or `CancellationError` for an abandoned waiter. - package func currentEntitlements() async throws -> StoreEntitlements { - let runtime = try runningRuntime(operation: .refreshEntitlements) - guard let leases = runtime.beginOperation() else { - throw StoreTransactionError.closing - } - return try await runtime.currentEntitlements(leases: leases) - } - - /// Returns verified transaction history for a product in newest-first order. - /// - /// The query is all-or-nothing: one unverified element fails the complete - /// result. Revoked and refunded transactions remain in this audit projection. - /// - /// - Parameter productID: The StoreKit product identifier to query. - /// - Returns: Verified snapshots ordered by purchase date, signed date, and - /// transaction identifier descending, then exact JWS UTF-8 bytes - /// ascending. - /// - Throws: A StoreKit verification or query error, a lifecycle or callback - /// reentrancy error, or `CancellationError` for an abandoned waiter. - package func history( - for productID: Product.ID - ) async throws -> [StoreTransactionSnapshot] { - let runtime = try runningRuntime(operation: .history) - guard let leases = runtime.beginOperation() else { - throw StoreTransactionError.closing - } - return try await runtime.history(for: productID, leases: leases) - } - - /// Explicitly synchronizes App Store purchases and refreshes entitlements. - /// - /// Call this method only from a user-initiated restore action because - /// ``StoreKit/AppStore/sync()`` can present authentication UI. Concurrent - /// callers share one synchronization operation. - /// - /// - Returns: The entitlement publication from a query reserved after sync succeeds. - /// - Throws: A synchronization, verification, query, lifecycle, callback - /// reentrancy, or caller cancellation error. StoreKit may throw - /// `StoreKitError.userCancelled` when the user dismisses - /// authentication; callers should treat that as a normal user outcome. - package func restorePurchases() async throws -> StoreEntitlements { - let runtime = try runningRuntime(operation: .restorePurchases) - guard let leases = runtime.beginOperation() else { - throw StoreTransactionError.closing - } - return try await runtime.restorePurchases(leases: leases) - } - - /// Stops producers and drains every operation and callback accepted before closing. - /// - /// The first caller creates one shared noncancellable close completion; - /// concurrent callers join it. Calling this method before ``start()`` closes - /// the session without acquiring StoreKit resources. Calling it again after - /// closure succeeds without effect. - /// - /// - Throws: A callback reentrancy error when one of this session's injected - /// callbacks attempts to close the session that is executing it. - package func close() async throws { - try rejectReentrancy(operation: .close) - switch state { - case .initialized: - state = .closed - case .running(let runtime): - let closeTask = Task { - await runtime.close() - } - state = .closing(closeTask) - await closeTask.value - state = .closed - case .closing(let closeTask): - await closeTask.value - state = .closed - case .closed: - return - } - } - - private func runningRuntime( - operation: StoreTransactionOperation - ) throws -> StoreTransactionRuntime { - try rejectReentrancy(operation: operation) - switch state { - case .initialized: - throw StoreTransactionLifecycleError.notStarted - case .running(let runtime): - return runtime - case .closing: - throw StoreTransactionError.closing - case .closed: - throw StoreTransactionError.closed - } - } - - private func rejectReentrancy( - operation: StoreTransactionOperation - ) throws { - if let invocation = StoreTransactionCallbackContext.current, - invocation.sessionID == sessionID - { - throw StoreTransactionError.reentrantOperation(operation: operation) - } - } - - isolated deinit { - switch state { - case .running(let runtime): - runtime.cancelSynchronously() - case .closing(let closeTask): - closeTask.cancel() - case .initialized, .closed: - break - } - } -} diff --git a/Sources/StoreTransactionKit/Subscriptions/AutoRenewableSubscriptionCatalog.swift b/Sources/StoreTransactionKit/Subscriptions/AutoRenewableSubscriptionCatalog.swift index 495b175..9e349ec 100644 --- a/Sources/StoreTransactionKit/Subscriptions/AutoRenewableSubscriptionCatalog.swift +++ b/Sources/StoreTransactionKit/Subscriptions/AutoRenewableSubscriptionCatalog.swift @@ -43,7 +43,7 @@ where Entitlement: Hashable & Sendable { package func activeEntitlements( in entitlements: StoreEntitlements - ) throws -> Set { + ) throws(AutoRenewableSubscriptionCatalogError) -> Set { var activeEntitlements: Set = [] for transaction in entitlements.transactions { @@ -63,7 +63,9 @@ where Entitlement: Hashable & Sendable { package func classification( of transaction: StoreTransactionSnapshot - ) throws -> AutoRenewableSubscriptionClassification { + ) throws(AutoRenewableSubscriptionCatalogError) + -> AutoRenewableSubscriptionClassification + { switch try validatedTransaction(transaction) { case .declared, .retiredUpgraded: .managed @@ -83,7 +85,7 @@ where Entitlement: Hashable & Sendable { private func validatedTransaction( _ transaction: StoreTransactionSnapshot - ) throws -> ValidatedTransaction { + ) throws(AutoRenewableSubscriptionCatalogError) -> ValidatedTransaction { if let entitlement = entitlementsByProductID[transaction.productID] { guard transaction.productType == .autoRenewable else { throw AutoRenewableSubscriptionCatalogError.productTypeMismatch( diff --git a/Sources/StoreTransactionKit/Subscriptions/AutoRenewableSubscriptionCatalogError.swift b/Sources/StoreTransactionKit/Subscriptions/AutoRenewableSubscriptionCatalogError.swift index a04ffed..4babcd9 100644 --- a/Sources/StoreTransactionKit/Subscriptions/AutoRenewableSubscriptionCatalogError.swift +++ b/Sources/StoreTransactionKit/Subscriptions/AutoRenewableSubscriptionCatalogError.swift @@ -40,3 +40,7 @@ public enum AutoRenewableSubscriptionCatalogError: LocalizedError, Sendable { } } } + +package struct StoreTransactionCatalogFailure: Error, Sendable { + package let error: AutoRenewableSubscriptionCatalogError +} diff --git a/Sources/StoreTransactionKit/TransactionStore.swift b/Sources/StoreTransactionKit/TransactionStore.swift index 6a45003..146855f 100644 --- a/Sources/StoreTransactionKit/TransactionStore.swift +++ b/Sources/StoreTransactionKit/TransactionStore.swift @@ -1,238 +1,359 @@ -import Foundation import Observation import StoreKit -/// An observable, process-owned StoreKit store. -/// -/// Create one store in the application's process composition root. The store -/// starts transaction monitoring during initialization and publishes complete -/// current-entitlement snapshots on the main actor. -/// Public operations other than ``close()`` wait for the startup attempt, -/// including durable handling of startup unfinished transactions, to complete. -/// -/// `EntitlementID` is an app-defined, string-backed identifier. Values whose -/// raw values match a current StoreKit entitlement appear in -/// ``activeEntitlements``. The complete verified projection remains available -/// through ``entitlements`` so identifiers outside that app-defined type are -/// never hidden. -/// -/// describes the delivery, -/// reconciliation, and failure-reporting model behind this type. +/// An observable, process-owned StoreKit transaction and entitlement store. @MainActor @Observable -public final class TransactionStore -where - EntitlementID: RawRepresentable & Hashable & Sendable, - EntitlementID.RawValue == String -{ - /// The latest verified current-entitlement projection. - /// - /// The value is `nil` until the first entitlement query succeeds. An empty - /// projection is non-`nil` and means StoreKit reported no current - /// entitlements. Elements that StoreKit can't verify are omitted and - /// reported through the failure callback. - public private(set) var entitlements: StoreEntitlements? - - /// App-defined identifiers represented by the latest active entitlements. - /// - /// The value is `nil` until the first entitlement query succeeds. An empty - /// set is non-`nil` and means none of the app-defined identifiers is - /// currently entitled. Transactions superseded by a subscription upgrade - /// remain available through ``entitlements`` but don't appear in this set. - public var activeEntitlements: Set? { - entitlements.map { entitlements in - Set( - entitlements.transactions.compactMap { - guard !$0.isUpgraded else { return nil } - return EntitlementID(rawValue: $0.productID) - }) +public final class TransactionStore +where Entitlement: Hashable & Sendable { + private enum EntitlementAvailability { + case loading + case failed(any Error) + case ready( + entitlements: StoreEntitlements, + activeEntitlements: Set + ) + case overridden(activeEntitlements: Set) + } + + private enum Backend: Sendable { + case liveRuntime( + StoreTransactionRuntime, + TransactionStoreLifecycle + ) + case syntheticRuntime( + StoreTransactionRuntime, + TransactionStoreLifecycle, + @Sendable (StoreTransactionOperation) -> any Error + ) + case override + } + + private struct RuntimeAdmission: Sendable { + let runtime: StoreTransactionRuntime + let leases: FiniteOperationLeases + } + + /// The availability of the typed entitlement projection. + public var entitlementStatus: EntitlementStatus { + switch availability { + case .loading: + .loading + case .failed(let error): + .failed(error) + case .ready: + .ready + case .overridden: + .overridden } } - /// The error from the initial readiness attempt. - /// - /// Startup includes durable handling of every verified transaction still - /// reported by `Transaction.unfinished`. Transaction monitoring remains - /// active after a recoverable startup failure. A later successful - /// entitlement refresh retries unfinished work and clears this value. - public private(set) var startupError: (any Error)? + /// The latest complete raw StoreKit entitlement projection. + public var entitlements: StoreEntitlements? { + guard case .ready(let entitlements, _) = availability else { + return nil + } + return entitlements + } + + /// The app-defined entitlements granted by the current catalog projection. + public var activeEntitlements: Set? { + switch availability { + case .ready(_, let activeEntitlements), + .overridden(let activeEntitlements): + activeEntitlements + case .loading, .failed: + nil + } + } @ObservationIgnored private let sessionID: UUID - @ObservationIgnored private let transactionSession: StoreTransactionSession - @ObservationIgnored private let startupCompletion: ProcessingReceipt - @ObservationIgnored private var startupTask: Task? - @ObservationIgnored private var startupOrdering = TransactionStoreStartupOrdering() - - /// Creates and starts an observable StoreKit store. - /// - /// - Parameters: - /// - handleTransaction: Applies the durable business effect for a verified - /// transaction. StoreTransactionKit exposes an at-least-once - /// handler-delivery contract, so the handler must be idempotent. - /// StoreTransactionKit calls `finish()` only after the closure returns - /// successfully. The handler must not call back into the same store, - /// directly or through an awaited child or detached task, because doing - /// so creates a dependency cycle with the operation being handled. - /// - reportFailure: Receives failures owned by process background work, - /// including background deliveries, unfinished and current-entitlement - /// verification, and direct operations abandoned by every caller. A - /// background-owned failure can also fail an enclosing startup or - /// refresh. Admitted failures are delivered serially with backpressure, - /// and ``close()`` waits for every callback to return. Record or enqueue - /// each failure promptly. This callback must not call back into the same - /// store, directly or through an awaited child or detached task. + @ObservationIgnored private let backend: Backend + private var availability: EntitlementAvailability + + /// Creates one live StoreKit store for an auto-renewable subscription catalog. public convenience init( - handleTransaction: - @escaping @Sendable (StoreTransactionSnapshot) async throws -> Void, - reportFailure: - @escaping @Sendable (StoreTransactionBackgroundFailure) async -> Void + subscriptionCatalog: AutoRenewableSubscriptionCatalog, + delegate: (any TransactionStoreDelegate)? = nil ) { + let liveLease = LiveTransactionStoreLease.acquire() + let lifecycle = TransactionStoreLifecycle(liveLease: liveLease) self.init( source: .live, - handleTransaction: handleTransaction, - reportFailure: reportFailure + lifecycle: lifecycle, + backendKind: .live, + subscriptionCatalog: subscriptionCatalog, + delegate: delegate + ) + } + + /// Creates a StoreKit-free store with one authoritative entitlement set. + public convenience init( + subscriptionCatalog: AutoRenewableSubscriptionCatalog, + overridingEntitlements: some Sequence + ) { + _ = subscriptionCatalog + self.init( + sessionID: UUID(), + availability: .overridden( + activeEntitlements: Set(overridingEntitlements) + ), + backend: .override ) } - package init( + convenience init( + source: StoreTransactionSource, + subscriptionCatalog: AutoRenewableSubscriptionCatalog, + delegate: (any TransactionStoreDelegate)? = nil + ) { + self.init( + source: source, + lifecycle: TransactionStoreLifecycle(), + subscriptionCatalog: subscriptionCatalog, + delegate: delegate + ) + } + + convenience init( + source: StoreTransactionSource, + lifecycle: TransactionStoreLifecycle, + subscriptionCatalog: AutoRenewableSubscriptionCatalog, + delegate: (any TransactionStoreDelegate)? = nil + ) { + self.init( + source: source, + lifecycle: lifecycle, + backendKind: .live, + subscriptionCatalog: subscriptionCatalog, + delegate: delegate + ) + } + + package convenience init( + subscriptionCatalog: AutoRenewableSubscriptionCatalog, + syntheticSource: SyntheticStoreTransactionSource, + delegate: (any TransactionStoreDelegate)? = nil, + unavailableOperationError: + @escaping @Sendable (StoreTransactionOperation) -> any Error + ) { + self.init( + source: syntheticSource.source, + lifecycle: TransactionStoreLifecycle(), + backendKind: .synthetic(unavailableOperationError), + subscriptionCatalog: subscriptionCatalog, + delegate: delegate + ) + } + + private enum BackendKind: Sendable { + case live + case synthetic(@Sendable (StoreTransactionOperation) -> any Error) + } + + private convenience init( source: StoreTransactionSource, - handleTransaction: - @escaping @Sendable (StoreTransactionSnapshot) async throws -> Void, - reportFailure: - @escaping @Sendable (StoreTransactionBackgroundFailure) async -> Void + lifecycle: TransactionStoreLifecycle, + backendKind: BackendKind, + subscriptionCatalog: AutoRenewableSubscriptionCatalog, + delegate: (any TransactionStoreDelegate)? ) { - let owner = TransactionStoreOwner() let sessionID = UUID() - let startupCompletion = ProcessingReceipt() - let transactionSession = StoreTransactionSession( + let owner = TransactionStoreAvailabilityOwner() + let runtime = StoreTransactionRuntime( sessionID: sessionID, source: source, - handleTransaction: handleTransaction, - entitlementsDidChange: { _ in }, - entitlementRefreshDidSucceed: { success in - await owner.apply(success) - }, - reportFailure: reportFailure - ) - self.sessionID = sessionID - self.transactionSession = transactionSession - self.startupCompletion = startupCompletion - owner.store = self - - startupTask = Task { [weak self, transactionSession] in - defer { startupCompletion.succeed(()) } - do { - _ = try await transactionSession.startForTransactionStore() - } catch let failure as StoreTransactionReadinessFailure { - guard !Task.isCancelled else { return } - self?.applyStartupFailure( - token: failure.refreshToken, - error: failure.underlyingError - ) - } catch { - // Explicit close cancels only this readiness waiter. The - // session's close operation owns draining accepted work. - guard !Task.isCancelled else { return } - self?.startupOrdering.recordUnsequencedFailure() - self?.startupError = error + lifecycle: lifecycle, + subscriptionCatalog: subscriptionCatalog, + delegate: delegate, + entitlementOutcome: { outcome in + await owner.apply(outcome) } + ) + let backend: Backend + switch backendKind { + case .live: + backend = .liveRuntime(runtime, lifecycle) + case .synthetic(let unavailableOperationError): + backend = .syntheticRuntime( + runtime, + lifecycle, + unavailableOperationError + ) } + self.init( + sessionID: sessionID, + availability: .loading, + backend: backend + ) + owner.attach(self) + runtime.start() + } + + private init( + sessionID: UUID, + availability: EntitlementAvailability, + backend: Backend + ) { + self.sessionID = sessionID + self.availability = availability + self.backend = backend + } + + /// Returns whether the exact app-defined entitlement is active. + public func isEntitled(to entitlement: Entitlement) -> Bool { + activeEntitlements?.contains(entitlement) == true } /// Processes a direct result from custom purchase UI. - /// - /// StoreKit views deliver successful purchases through - /// `Transaction.updates` by default and don't need to call this - /// method. Use it for a result returned directly by a `Product` purchase - /// API or by a custom StoreKit view completion action. public func process( _ result: Product.PurchaseResult ) async throws -> StorePurchaseOutcome { - try await waitForStartupAttempt(operation: .processPurchase) - return try await transactionSession.process(result) + let admission = try admit(operation: .processPurchase) + return try await admission.runtime.process( + result, + leases: admission.leases + ) + } + + func process( + _ delivery: StoreTransactionDelivery, + didAdmit: @escaping @Sendable () async -> Void = {} + ) async throws -> StorePurchaseOutcome { + let admission = try admit(operation: .processPurchase) + return try await admission.runtime.process( + delivery, + leases: admission.leases, + didAdmit: didAdmit + ) + } + + @discardableResult + package func processSyntheticDelivery( + _ delivery: StoreTransactionDelivery + ) async throws -> StoreTransactionSnapshot { + try rejectReentrancy(operation: .processPurchase) + guard case .syntheticRuntime(let runtime, let lifecycle, _) = backend else { + preconditionFailure( + "Synthetic deliveries require a synthetic TransactionStore." + ) + } + let leases = try lifecycle.beginOperation() + let outcome = try await runtime.process(delivery, leases: leases) + guard case .completed(let snapshot) = outcome else { + preconditionFailure("A synthetic delivery must complete a transaction.") + } + return snapshot } - /// Refreshes current entitlements and updates observable store state. - /// - /// Before publishing the result, the store durably handles every verified - /// transaction currently reported by `Transaction.unfinished`, including - /// consumables. A handler failure leaves the transaction unfinished, fails - /// this refresh, and allows a later refresh to retry it. - /// - /// - Returns: The complete verified entitlement projection. + /// Refreshes and publishes the current StoreKit entitlement projection. @discardableResult public func refreshEntitlements() async throws -> StoreEntitlements { - try await waitForStartupAttempt(operation: .refreshEntitlements) - return try await transactionSession.currentEntitlements() + let admission = try admit(operation: .refreshEntitlements) + return try await admission.runtime.currentEntitlements( + leases: admission.leases + ) } - /// Returns verified transaction history for a product in newest-first order. - /// - /// StoreKit omits finished consumables unless the app enables - /// `SKIncludeConsumableInAppPurchaseHistory` in its information property - /// list. Revoked and refunded transactions remain in the returned history. - /// Results are ordered by purchase date, signed date, and transaction - /// identifier descending, then exact JWS UTF-8 bytes ascending. + /// Returns verified transaction history for one product. public func history( for productID: Product.ID ) async throws -> [StoreTransactionSnapshot] { - try await waitForStartupAttempt(operation: .history) - return try await transactionSession.history(for: productID) + let admission = try admit(operation: .history) + return try await admission.runtime.history( + for: productID, + leases: admission.leases + ) } - /// Synchronizes App Store purchases after an explicit user restore action. - /// - /// This method can present authentication UI. It refreshes observable - /// entitlement state before returning. StoreKit may throw - /// `StoreKitError.userCancelled` when the user dismisses - /// authentication; treat that as a normal user outcome rather than a - /// diagnostic failure. + /// Synchronizes App Store purchases and refreshes entitlements. @discardableResult public func restorePurchases() async throws -> StoreEntitlements { - try await waitForStartupAttempt(operation: .restorePurchases) - return try await transactionSession.restorePurchases() + let admission = try admit(operation: .restorePurchases) + return try await admission.runtime.restorePurchases( + leases: admission.leases + ) } - /// Stops transaction producers and drains every accepted operation. - /// - /// Production apps normally retain the store for process lifetime. Call - /// this method from controlled shutdown and test lifecycles. - /// - /// - Throws: ``StoreTransactionError/reentrantOperation(operation:)`` when - /// an injected callback attempts to close the store that is executing it. + /// Stops producers and drains every operation accepted before closing. public func close() async throws { try rejectReentrancy(operation: .close) - startupTask?.cancel() - try await transactionSession.close() - await startupTask?.value - startupTask = nil + switch backend { + case .liveRuntime(let runtime, let lifecycle), + .syntheticRuntime(let runtime, let lifecycle, _): + await lifecycle.close { + await runtime.shutdown() + } + case .override: + return + } } - fileprivate func apply(_ success: EntitlementRefreshSuccess) { - entitlements = success.entitlements - if startupOrdering.recordSuccess(token: success.token) { - startupError = nil + package func waitForInitialReadiness() async throws { + switch backend { + case .liveRuntime(let runtime, _), + .syntheticRuntime(let runtime, _, _): + try await runtime.waitForInitialReadiness() + case .override: + return } } - private func applyStartupFailure( - token: UInt64, - error: any Error + package func waitUntilClosing() async { + switch backend { + case .liveRuntime(_, let lifecycle), + .syntheticRuntime(_, let lifecycle, _): + await lifecycle.waitUntilSealed() + case .override: + return + } + } + + fileprivate func apply( + _ outcome: EntitlementRefreshOutcome ) { - if startupOrdering.recordFailure(token: token) { - startupError = error + switch outcome { + case .success(let publication): + availability = .ready( + entitlements: publication.entitlements, + activeEntitlements: publication.activeEntitlements + ) + case .transientFailure(let error): + guard case .ready = availability else { + availability = .failed(error) + return + } + case .catalogFailure(let error): + availability = .failed(error) } } - private func waitForStartupAttempt( + private func admit( operation: StoreTransactionOperation - ) async throws { + ) throws -> RuntimeAdmission { try rejectReentrancy(operation: operation) - try Task.checkCancellation() - do { - try await startupCompletion.value() - } catch is ProcessingReceiptWaiterCancellation { - throw CancellationError() + switch backend { + case .liveRuntime(let runtime, let lifecycle): + return RuntimeAdmission( + runtime: runtime, + leases: try lifecycle.beginOperation() + ) + case .syntheticRuntime( + let runtime, + let lifecycle, + let unavailableOperationError + ): + let leases = try lifecycle.beginOperation() + guard operation == .refreshEntitlements else { + leases.work.end() + leases.observer.end() + throw unavailableOperationError(operation) + } + return RuntimeAdmission(runtime: runtime, leases: leases) + case .override: + throw StoreTransactionError.operationUnavailableInOverride( + operation: operation + ) } } @@ -246,47 +367,30 @@ where } } - package func waitForStartup() async { - _ = try? await startupCompletion.terminalValue() - } - isolated deinit { - startupTask?.cancel() + switch backend { + case .liveRuntime(let runtime, let lifecycle), + .syntheticRuntime(let runtime, let lifecycle, _): + lifecycle.sealSynchronously() + runtime.cancelSynchronously() + case .override: + return + } } } -package struct TransactionStoreStartupOrdering: Sendable { - private var latestSuccessfulToken: UInt64 = 0 - private var failureToken: UInt64? - - package mutating func recordSuccess(token: UInt64) -> Bool { - precondition(token > latestSuccessfulToken) - latestSuccessfulToken = token - guard let failureToken, token > failureToken else { return false } - self.failureToken = nil - return true - } - - package mutating func recordFailure(token: UInt64) -> Bool { - guard latestSuccessfulToken < token else { return false } - failureToken = token - return true - } +@MainActor +private final class TransactionStoreAvailabilityOwner: Sendable +where Entitlement: Hashable & Sendable { + private weak var store: TransactionStore? - package mutating func recordUnsequencedFailure() { - failureToken = latestSuccessfulToken + func attach(_ store: TransactionStore) { + precondition(self.store == nil) + self.store = store } -} -@MainActor -private final class TransactionStoreOwner: Sendable -where - EntitlementID: RawRepresentable & Hashable & Sendable, - EntitlementID.RawValue == String -{ - weak var store: TransactionStore? - - func apply(_ success: EntitlementRefreshSuccess) { - store?.apply(success) + func apply(_ outcome: EntitlementRefreshOutcome) { + guard let store else { return } + store.apply(outcome) } } diff --git a/Tests/StoreTransactionKitTests/CompletedDeliveryRefreshTests.swift b/Tests/StoreTransactionKitTests/CompletedDeliveryRefreshTests.swift deleted file mode 100644 index 2a6eea2..0000000 --- a/Tests/StoreTransactionKitTests/CompletedDeliveryRefreshTests.swift +++ /dev/null @@ -1,81 +0,0 @@ -import Testing -@testable import StoreTransactionKit - -@Suite("Completed delivery entitlement refresh", .timeLimit(.minutes(1))) -struct CompletedDeliveryRefreshTests { - @Test("a completed redelivery retries a failed entitlement refresh") - func completedRedeliveryRetriesRefresh() async { - let snapshot = makeSnapshot( - id: 51, - productID: "lifetime.completed", - productType: .nonConsumable, - jws: "completed-redelivery" - ) - let query = FailingOnceEntitlementQuery(recovered: [snapshot]) - let handlerCalls = TestSignal() - let finishes = TestSignal() - let publications = UInt64Recorder() - let reports = StringRecorder() - let core = TransactionProcessingCore { _ in - await handlerCalls.send() - } - let entitlements = EntitlementRefreshCoordinator( - query: { _ in try await query.next() }, - didChange: { value in - await publications.append(UInt64(value.transactions.count)) - } - ) - let failures = FailureReporterDispatcher { failure in - await reports.append( - "\(failure.source)-\(failure.transactionID ?? 0)-\(failure.productID ?? "")" - ) - } - let pipeline = StoreTransactionPipeline( - core: core, - entitlements: entitlements, - failures: failures - ) - let delivery = StoreTransactionDelivery.verified( - makeEnvelope(snapshot: snapshot) { - await finishes.send() - } - ) - - await pipeline.processBackground(delivery, source: .updates) - await pipeline.processBackground(delivery, source: .unfinished) - - await core.finishInputAndDrain() - await entitlements.sealAndDrain() - await failures.sealAndDrain() - #expect(await query.count() == 2) - #expect(await handlerCalls.value() == 1) - #expect(await finishes.value() == 1) - #expect(await publications.snapshot() == [1]) - #expect( - await reports.snapshot() == [ - "entitlementRefresh-51-lifetime.completed" - ] - ) - } -} - -private actor FailingOnceEntitlementQuery { - private let recovered: [StoreTransactionSnapshot] - private var invocationCount = 0 - - init(recovered: [StoreTransactionSnapshot]) { - self.recovered = recovered - } - - func next() throws -> [StoreTransactionSnapshot] { - invocationCount += 1 - if invocationCount == 1 { - throw TestFailure() - } - return recovered - } - - func count() -> Int { - invocationCount - } -} diff --git a/Tests/StoreTransactionKitTests/DirectOperationReportingTests.swift b/Tests/StoreTransactionKitTests/DirectOperationReportingTests.swift index aa14bb0..ccc71c2 100644 --- a/Tests/StoreTransactionKitTests/DirectOperationReportingTests.swift +++ b/Tests/StoreTransactionKitTests/DirectOperationReportingTests.swift @@ -62,6 +62,31 @@ struct DirectOperationReportingTests { #expect(owner.fail(ownerBinding, report: makeReport(id: 3)) == nil) } + @Test("merged physical owners select one background report") + func mergedAuthoritiesReportOnce() throws { + let processAuthority = DirectOperationReportingAuthority() + let refreshAuthority = DirectOperationReportingAuthority() + let process = DirectOperationObservation() + let refresh = DirectOperationObservation() + let processBinding = process.bind(to: processAuthority) + let refreshBinding = refresh.bind(to: refreshAuthority) + processAuthority.merge(into: refreshAuthority) + + #expect(process.abandon() == nil) + #expect(refresh.abandon() == nil) + let claimed = process.fail( + processBinding, + report: makeReport(id: 4) + ) + let duplicate = refresh.fail( + refreshBinding, + report: makeReport(id: 5) + ) + + #expect(claimed?.transactionID == 4) + #expect(duplicate == nil) + } + private func makeReport(id: UInt64) -> StoreTransactionBackgroundFailure { StoreTransactionBackgroundFailure( source: .abandonedDirectOperation(.refreshEntitlements), diff --git a/Tests/StoreTransactionKitTests/EntitlementRefreshCoordinatorTests.swift b/Tests/StoreTransactionKitTests/EntitlementRefreshCoordinatorTests.swift index 93d9c99..cf6aecd 100644 --- a/Tests/StoreTransactionKitTests/EntitlementRefreshCoordinatorTests.swift +++ b/Tests/StoreTransactionKitTests/EntitlementRefreshCoordinatorTests.swift @@ -1,185 +1,188 @@ +import Foundation import Testing @testable import StoreTransactionKit @Suite("EntitlementRefreshCoordinator", .timeLimit(.minutes(1))) struct EntitlementRefreshCoordinatorTests { - @Test("reservations that arrive during a query run in the next cycle") - func cutoffReservations() async throws { - let query = ControlledEntitlementQuery() - let publicationSizes = UInt64Recorder() - let coordinator = EntitlementRefreshCoordinator( - query: { _ in try await query.next() }, - didChange: { value in - await publicationSizes.append(UInt64(value.transactions.count)) - } - ) + @Test("a reservation after the physical query cutoff starts a later query") + func physicalQueryCutoff() async throws { + let query = ControlledReconciliationQuery() + let failures = FailureReporterDispatcher() + let coordinator = makeCoordinator(query: query, failures: failures) let first = await coordinator.reserve() try await query.waitForRequest(1) let second = await coordinator.reserve() - await query.succeed([makeSnapshot(id: 1, productID: "b")]) - let firstValue = try await first.receipt.terminalValue() - #expect(firstValue.transactions.map(\.productID) == ["b"]) + #expect(first.role == .owner) + #expect(second.role == .owner) + await query.succeed([]) + _ = try await first.receipt.terminalValue() + try await query.waitForRequest(2) - await query.succeed([ - makeSnapshot(id: 2, productID: "a"), - makeSnapshot(id: 1, productID: "b"), - ]) - let secondValue = try await second.receipt.terminalValue() - - #expect(secondValue.transactions.map(\.productID) == ["a", "b"]) - #expect(await publicationSizes.snapshot() == [1, 2]) + await query.succeed([]) + _ = try await second.receipt.terminalValue() + await coordinator.sealAndDrain() + await failures.sealAndDrain() } - @Test("equal content completes reservations without a new publication") - func equalContentDoesNotPublish() async throws { - let query = ControlledEntitlementQuery() - let publicationSizes = UInt64Recorder() - let successfulTokens = UInt64Recorder() - let coordinator = EntitlementRefreshCoordinator( + @Test("publication callback completes before the reservation receipt") + func publicationPrecedesReceipt() async throws { + let query = ControlledReconciliationQuery() + let failures = FailureReporterDispatcher() + let callbackStarted = TestSignal() + let callbackGate = TestGate() + let receiptCompleted = TestSignal() + let coordinator = EntitlementRefreshCoordinator( query: { _ in try await query.next() }, - didChange: { value in - await publicationSizes.append(UInt64(value.transactions.count)) + project: { + (_: StoreEntitlements) throws(AutoRenewableSubscriptionCatalogError) + -> Set in + [.tier1] }, - didSucceed: { success in - await successfulTokens.append(success.token) - } + didComplete: { _ in + await callbackStarted.send() + try? await callbackGate.wait() + }, + failures: failures ) - let snapshot = makeSnapshot(id: 4) - let first = await coordinator.reserve() + let reservation = await coordinator.reserve() try await query.waitForRequest(1) - await query.succeed([snapshot]) - _ = try await first.receipt.terminalValue() + await query.succeed([]) + let waiter = Task { + _ = try await reservation.receipt.terminalValue() + await receiptCompleted.send() + } + try await callbackStarted.wait(for: 1) + #expect(await receiptCompleted.value() == 0) - let second = await coordinator.reserve() - try await query.waitForRequest(2) - await query.succeed([snapshot]) - let value = try await second.receipt.terminalValue() + await callbackGate.open() + try await waiter.value + #expect(await receiptCompleted.value() == 1) - #expect(value.transactions == [snapshot]) - #expect(await publicationSizes.snapshot() == [1]) - #expect(await successfulTokens.snapshot() == [1, 2]) await coordinator.sealAndDrain() + await failures.sealAndDrain() } - @Test("an unverified or failed query never publishes a partial replacement") - func failedQueryDoesNotPublish() async throws { - let query = ControlledEntitlementQuery() - let publicationSizes = UInt64Recorder() - let coordinator = EntitlementRefreshCoordinator( + @Test("catalog projection failure is classified at the projection boundary") + func catalogFailureClassification() async throws { + let query = ControlledReconciliationQuery() + let failures = FailureReporterDispatcher() + let outcomes = RefreshOutcomeRecorder() + let catalogError = AutoRenewableSubscriptionCatalogError.undeclaredProduct( + productID: "undeclared", + subscriptionGroupID: TestPlans.id + ) + let coordinator = EntitlementRefreshCoordinator( query: { _ in try await query.next() }, - didChange: { value in - await publicationSizes.append(UInt64(value.transactions.count)) - } + project: { + (_: StoreEntitlements) throws(AutoRenewableSubscriptionCatalogError) + -> Set in + throw catalogError + }, + didComplete: { outcome in await outcomes.append(outcome) }, + failures: failures ) - let failed = await coordinator.reserve() + let reservation = await coordinator.reserve() try await query.waitForRequest(1) - await query.fail(TestFailure()) - await #expect(throws: TestFailure.self) { - _ = try await failed.receipt.terminalValue() + await query.succeed([]) + await #expect(throws: AutoRenewableSubscriptionCatalogError.self) { + _ = try await reservation.receipt.terminalValue() } - #expect(await publicationSizes.snapshot().isEmpty) - let recovered = await coordinator.reserve() - try await query.waitForRequest(2) - await query.succeed([]) - let value = try await recovered.receipt.terminalValue() - #expect(value.transactions.isEmpty) - #expect(await publicationSizes.snapshot() == [0]) + #expect(await outcomes.kinds() == [.catalogFailure]) await coordinator.sealAndDrain() + await failures.sealAndDrain() } - @Test("each pending query batch has one reporting owner") - func pendingBatchReportingAuthority() async throws { - let query = ControlledEntitlementQuery() - let coordinator = EntitlementRefreshCoordinator( + private func makeCoordinator( + query: ControlledReconciliationQuery, + failures: FailureReporterDispatcher + ) -> EntitlementRefreshCoordinator { + EntitlementRefreshCoordinator( query: { _ in try await query.next() }, - didChange: { _ in } + project: { + (_: StoreEntitlements) throws(AutoRenewableSubscriptionCatalogError) + -> Set in + [] + }, + didComplete: { _ in }, + failures: failures ) + } +} - let active = await coordinator.reserve() - try await query.waitForRequest(1) - let nextOwner = await coordinator.reserve() - let nextObserver = await coordinator.reserve() +private actor ControlledReconciliationQuery { + private struct Request { + let id: UUID + let continuation: CheckedContinuation + } - #expect(active.role == .owner) - #expect(nextOwner.role == .owner) - #expect(nextObserver.role == .observer) - #expect(active.reportingAuthority !== nextOwner.reportingAuthority) - #expect(nextOwner.reportingAuthority === nextObserver.reportingAuthority) + private var requests: [Request] = [] + private let started = TestSignal() - await query.succeed([]) - _ = try await active.receipt.terminalValue() - try await query.waitForRequest(2) - await query.succeed([]) - _ = try await nextOwner.receipt.terminalValue() - _ = try await nextObserver.receipt.terminalValue() - await coordinator.sealAndDrain() + func next() async throws -> CurrentEntitlementReconciliation { + try Task.checkCancellation() + let id = UUID() + return try await withTaskCancellationHandler { + try await withCheckedThrowingContinuation { continuation in + requests.append(Request(id: id, continuation: continuation)) + Task { await started.send() } + } + } onCancel: { + Task { await self.cancel(id) } + } } - @Test("mixed retry policies form contiguous query batches") - func mixedRetryPolicyBatches() async throws { - let query = ControlledEntitlementQuery() - let policies = StringRecorder() - let coordinator = EntitlementRefreshCoordinator( - query: { retryFailedTransactions in - await policies.append(String(retryFailedTransactions)) - return try await query.next() - }, - didChange: { _ in } - ) + func waitForRequest(_ count: Int) async throws { + try await started.wait(for: count) + } - let active = await coordinator.reserve( - retryFailedTransactions: false + func succeed(_ snapshots: [StoreTransactionSnapshot]) { + precondition(!requests.isEmpty) + requests.removeFirst().continuation.resume( + returning: CurrentEntitlementReconciliation( + snapshots: snapshots, + causalClaims: [], + diagnostics: [] + ) ) - try await query.waitForRequest(1) - let falseOwner = await coordinator.reserve( - retryFailedTransactions: false - ) - let falseObserver = await coordinator.reserve( - retryFailedTransactions: false - ) - let trueOwner = await coordinator.reserve( - retryFailedTransactions: true - ) - let trueObserver = await coordinator.reserve( - retryFailedTransactions: true - ) - let trailingFalseOwner = await coordinator.reserve( - retryFailedTransactions: false - ) - - #expect(falseOwner.role == .owner) - #expect(falseObserver.role == .observer) - #expect(trueOwner.role == .owner) - #expect(trueObserver.role == .observer) - #expect(trailingFalseOwner.role == .owner) + } - await query.succeed([]) - _ = try await active.receipt.terminalValue() + private func cancel(_ id: UUID) { + guard let index = requests.firstIndex(where: { $0.id == id }) else { + return + } + requests.remove(at: index).continuation.resume( + throwing: CancellationError() + ) + } +} - try await query.waitForRequest(2) - await query.succeed([]) - _ = try await falseOwner.receipt.terminalValue() - _ = try await falseObserver.receipt.terminalValue() +private actor RefreshOutcomeRecorder { + enum Kind: Equatable, Sendable { + case success + case transientFailure + case catalogFailure + } - try await query.waitForRequest(3) - await query.succeed([]) - _ = try await trueOwner.receipt.terminalValue() - _ = try await trueObserver.receipt.terminalValue() + private var values: [Kind] = [] - try await query.waitForRequest(4) - await query.succeed([]) - _ = try await trailingFalseOwner.receipt.terminalValue() + func append(_ outcome: EntitlementRefreshOutcome) { + switch outcome { + case .success: + values.append(.success) + case .transientFailure: + values.append(.transientFailure) + case .catalogFailure: + values.append(.catalogFailure) + } + } - #expect( - await policies.snapshot() == [ - "false", "false", "true", "false", - ]) - await coordinator.sealAndDrain() + func kinds() -> [Kind] { + values } } diff --git a/Tests/StoreTransactionKitTests/LifecycleResidualTests.swift b/Tests/StoreTransactionKitTests/LifecycleResidualTests.swift deleted file mode 100644 index 58769e2..0000000 --- a/Tests/StoreTransactionKitTests/LifecycleResidualTests.swift +++ /dev/null @@ -1,232 +0,0 @@ -import StoreKit -import Testing -@testable import StoreTransactionKit - -@Suite("Failure reporter dispatcher", .timeLimit(.minutes(1))) -struct FailureReporterDispatcherTests { - @Test("capacity one delivers every admitted failure before enqueue returns") - func boundedDeliveryIsLossless() async throws { - let callbackEntered = TestSignal() - let callbackGate = TestGate() - let callbackValues = UInt64Recorder() - let enqueueCompleted = TestSignal() - let dispatcher = FailureReporterDispatcher(capacity: 1) { failure in - guard let transactionID = failure.transactionID else { - Issue.record("The test failure lost its transaction identifier.") - return - } - await callbackValues.append(transactionID) - await callbackEntered.send() - _ = try? await callbackGate.wait() - } - - let first = Task { - await dispatcher.enqueue(makeFailure(id: 1)) - await enqueueCompleted.send() - } - try await callbackEntered.wait(for: 1) - #expect(await enqueueCompleted.value() == 0) - - let second = Task { - await dispatcher.enqueue(makeFailure(id: 2)) - await enqueueCompleted.send() - } - let third = Task { - await dispatcher.enqueue(makeFailure(id: 3)) - await enqueueCompleted.send() - } - - await callbackGate.open() - await first.value - await second.value - await third.value - await dispatcher.sealAndDrain() - - let values = await callbackValues.snapshot() - #expect(values.first == 1) - #expect(Set(values) == [1, 2, 3]) - #expect(await enqueueCompleted.value() == 3) - } - - @Test("seal waits for an active failure callback and its enqueue receipt") - func sealDrainsActiveCallback() async throws { - let callbackEntered = TestSignal() - let callbackGate = TestGate() - let enqueueCompleted = TestSignal() - let sealStarted = TestSignal() - let sealCompleted = TestSignal() - let dispatcher = FailureReporterDispatcher(capacity: 1) { _ in - await callbackEntered.send() - _ = try? await callbackGate.wait() - } - - let enqueue = Task { - await dispatcher.enqueue(makeFailure(id: 1)) - await enqueueCompleted.send() - } - try await callbackEntered.wait(for: 1) - - let seal = Task { - await sealStarted.send() - await dispatcher.sealAndDrain() - await sealCompleted.send() - } - try await sealStarted.wait(for: 1) - #expect(await enqueueCompleted.value() == 0) - #expect(await sealCompleted.value() == 0) - - await callbackGate.open() - await enqueue.value - await seal.value - - #expect(await enqueueCompleted.value() == 1) - #expect(await sealCompleted.value() == 1) - } - - private func makeFailure(id: UInt64) -> StoreTransactionBackgroundFailure { - StoreTransactionBackgroundFailure( - source: .updates, - transactionID: id, - productID: "product-\(id)", - underlyingError: TestFailure() - ) - } -} - -@Suite("Restore coordinator failures", .timeLimit(.minutes(1))) -struct RestoreCoordinatorFailureTests { - @Test("coalesced waiters share a failure and the next reservation retries") - func coalescedFailureAllowsRetry() async throws { - let synchronization = ControlledRestoreSynchronization() - let entitlementQueryCount = TestSignal() - let entitlements = EntitlementRefreshCoordinator( - query: { _ in - await entitlementQueryCount.send() - return [] - }, - didChange: { _ in } - ) - let coordinator = RestoreCoordinator( - synchronize: { try await synchronization.run() }, - entitlements: entitlements - ) - - let first = await coordinator.reserve() - try await synchronization.waitForAttempt(1) - let second = await coordinator.reserve() - #expect(first.role == .owner) - #expect(second.role == .observer) - #expect(first.receipt === second.receipt) - #expect(first.reportingAuthority === second.reportingAuthority) - - await synchronization.releaseFirstAttempt() - await #expect(throws: RestoreCoordinatorFailure.self) { - _ = try await first.receipt.terminalValue() - } - await #expect(throws: RestoreCoordinatorFailure.self) { - _ = try await second.receipt.terminalValue() - } - - let retry = await coordinator.reserve() - #expect(retry.role == .owner) - #expect(retry.receipt !== first.receipt) - #expect(retry.reportingAuthority !== first.reportingAuthority) - try await synchronization.waitForAttempt(2) - let value = try await retry.receipt.terminalValue() - - #expect(value.transactions.isEmpty) - #expect(await synchronization.attemptCount() == 2) - #expect(await entitlementQueryCount.value() == 1) - await entitlements.sealAndDrain() - } -} - -@Suite("Session closing admission", .timeLimit(.minutes(1))) -struct SessionClosingAdmissionTests { - @Test("closing rejects every new session operation") - func closingRejectsNewOperations() async throws { - let query = ControlledEntitlementQuery() - let fixture = TestSourceFixture( - currentEntitlements: { try await query.next() } - ) - let session = StoreTransactionSession( - source: fixture.source, - handleTransaction: { _ in }, - reportFailure: { _ in } - ) - - let startup = Task { try await session.start() } - try await query.waitForRequest(1) - await query.succeed([]) - _ = try await startup.value - - let acceptedRefresh = Task { - try await session.currentEntitlements() - } - try await query.waitForRequest(2) - - let close = Task { try await session.close() } - try await fixture.updateTermination.wait(for: 1) - - await expectClosing("start") { - _ = try await session.start() - } - await expectClosing("process") { - _ = try await session.process(.pending) - } - await expectClosing("currentEntitlements") { - _ = try await session.currentEntitlements() - } - await expectClosing("history") { - _ = try await session.history(for: "product") - } - await expectClosing("restorePurchases") { - _ = try await session.restorePurchases() - } - - await query.succeed([]) - _ = try await acceptedRefresh.value - try await close.value - } - - private func expectClosing( - _ operationName: String, - operation: () async throws -> Void - ) async { - do { - try await operation() - Issue.record("\(operationName) accepted new work while closing.") - } catch StoreTransactionError.closing { - } catch { - Issue.record("\(operationName) returned an unexpected error: \(error)") - } - } -} - -private actor ControlledRestoreSynchronization { - private let started = TestSignal() - private let firstAttemptGate = TestGate() - private var attempts = 0 - - func run() async throws { - attempts += 1 - let attempt = attempts - await started.send() - if attempt == 1 { - try await firstAttemptGate.wait() - throw TestFailure() - } - } - - func waitForAttempt(_ count: Int) async throws { - try await started.wait(for: count) - } - - func releaseFirstAttempt() async { - await firstAttemptGate.open() - } - - func attemptCount() -> Int { - attempts - } -} diff --git a/Tests/StoreTransactionKitTests/ReconciliationFixedPointTests.swift b/Tests/StoreTransactionKitTests/ReconciliationFixedPointTests.swift deleted file mode 100644 index e28ac42..0000000 --- a/Tests/StoreTransactionKitTests/ReconciliationFixedPointTests.swift +++ /dev/null @@ -1,568 +0,0 @@ -import StoreKit -import Testing -@testable import StoreTransactionKit - -@Suite("Current entitlement reconciliation fixed point", .timeLimit(.minutes(1))) -struct ReconciliationFixedPointTests { - @Test("reconciliation repeats until no new entitlement revision remains") - func repeatsUntilNoNewRevision() async throws { - let first = makeSnapshot( - id: 41, - productID: "lifetime.first", - productType: .nonConsumable, - jws: "fixed-point-first" - ) - let second = makeSnapshot( - id: 42, - productID: "lifetime.second", - productType: .nonConsumable, - jws: "fixed-point-second" - ) - let current = EntitlementValueSource([]) - let unfinished = UnfinishedValueSource() - let currentQueryCount = TestSignal() - let unfinishedQueryCount = TestSignal() - let handled = UInt64Recorder() - let finished = UInt64Recorder() - let reports = StringRecorder() - - let persistentFirst = StoreTransactionDelivery.verified( - makeEnvelope(snapshot: first) - ) - let secondDelivery = StoreTransactionDelivery.verified( - makeEnvelope(snapshot: second) { - await finished.append(second.id) - } - ) - let firstDelivery = StoreTransactionDelivery.verified( - makeEnvelope(snapshot: first) { - await finished.append(first.id) - await current.replace(with: [first, second]) - await unfinished.replace( - with: [persistentFirst, secondDelivery] - ) - } - ) - await unfinished.replace(with: [firstDelivery]) - - let core = TransactionProcessingCore { snapshot in - await handled.append(snapshot.id) - } - let failures = FailureReporterDispatcher { failure in - await reports.append("\(failure.source)") - } - let reconciler = CurrentEntitlementReconciler( - query: { - await currentQueryCount.send() - return CurrentEntitlementQueryResult( - snapshots: await current.read(), - verificationFailures: [] - ) - }, - queryUnfinished: { - await unfinishedQueryCount.send() - return await unfinished.read() - }, - core: core, - failures: failures - ) - - let snapshots = try await reconciler.query( - retryFailedTransactions: false - ) - - await core.finishInputAndDrain() - await failures.sealAndDrain() - #expect(snapshots == [first, second]) - #expect(await handled.snapshot() == [first.id, second.id]) - #expect(await finished.snapshot() == [first.id, second.id]) - #expect(await currentQueryCount.value() == 1) - #expect(await unfinishedQueryCount.value() == 4) - #expect(await reports.snapshot().isEmpty) - } - - @Test("only the stable query reports current entitlement verification failures") - func reportsStableVerificationFailuresOnce() async throws { - let first = makeSnapshot( - id: 43, - productID: "lifetime.first", - productType: .nonConsumable, - jws: "fixed-point-verification-first" - ) - let second = makeSnapshot( - id: 47, - productID: "lifetime.second", - productType: .nonConsumable, - jws: "fixed-point-verification-second" - ) - let current = EntitlementValueSource([]) - let unfinished = UnfinishedValueSource() - let currentQueryCount = TestSignal() - let unfinishedQueryCount = TestSignal() - let handlerCalls = TestSignal() - let finishes = TestSignal() - let reports = StringRecorder() - - let persistentFirst = StoreTransactionDelivery.verified( - makeEnvelope(snapshot: first) - ) - let persistentSecond = StoreTransactionDelivery.verified( - makeEnvelope(snapshot: second) - ) - let secondDelivery = StoreTransactionDelivery.verified( - makeEnvelope(snapshot: second) { - await current.replace(with: [first, second]) - await unfinished.replace( - with: [persistentFirst, persistentSecond] - ) - await finishes.send() - } - ) - let firstDelivery = StoreTransactionDelivery.verified( - makeEnvelope(snapshot: first) { - await current.replace(with: [first]) - await unfinished.replace(with: [persistentFirst]) - await finishes.send() - } - ) - await unfinished.replace(with: [firstDelivery]) - - let core = TransactionProcessingCore { _ in - await handlerCalls.send() - } - let failures = FailureReporterDispatcher { failure in - guard failure.source == .currentEntitlementVerification, - let verificationFailure = - failure.underlyingError as? StoreTransactionVerificationError - else { - await reports.append("unexpected") - return - } - switch verificationFailure.underlyingError { - case is DiscardedVerificationFailure: - await reports.append("discarded") - case is StableVerificationFailure: - await reports.append("stable") - default: - await reports.append("unexpected") - } - } - let reconciler = CurrentEntitlementReconciler( - query: { - await currentQueryCount.send() - let queryNumber = await currentQueryCount.value() - if queryNumber == 1 { - await unfinished.replace( - with: [persistentFirst, secondDelivery] - ) - } - let underlyingError: any Error = - if queryNumber == 1 { - DiscardedVerificationFailure() - } else { - StableVerificationFailure() - } - return CurrentEntitlementQueryResult( - snapshots: await current.read(), - verificationFailures: [ - StoreTransactionVerificationError( - underlyingError: underlyingError - ) - ] - ) - }, - queryUnfinished: { - await unfinishedQueryCount.send() - return await unfinished.read() - }, - core: core, - failures: failures - ) - - let snapshots = try await reconciler.query( - retryFailedTransactions: false - ) - - await core.finishInputAndDrain() - await failures.sealAndDrain() - #expect(snapshots == [first, second]) - #expect(await currentQueryCount.value() == 2) - #expect(await unfinishedQueryCount.value() == 5) - #expect(await handlerCalls.value() == 2) - #expect(await finishes.value() == 2) - #expect(await reports.snapshot() == ["stable"]) - } - - @Test("a failed unfinished consumable blocks readiness and remains retryable") - func failedUnfinishedConsumableIsRetryable() async throws { - let snapshot = makeSnapshot( - id: 44, - productID: "consumable.tokens", - productType: .consumable, - jws: "unfinished-consumable" - ) - let unfinished = UnfinishedValueSource() - let handlerCalls = TestSignal() - let finishes = TestSignal() - let reports = StringRecorder() - let delivery = StoreTransactionDelivery.verified( - makeEnvelope(snapshot: snapshot) { - await finishes.send() - await unfinished.replace(with: []) - } - ) - await unfinished.replace(with: [delivery]) - - let core = TransactionProcessingCore { _ in - await handlerCalls.send() - if await handlerCalls.value() == 1 { - throw TestFailure() - } - } - let failures = FailureReporterDispatcher { failure in - guard failure.source == .unfinished, - failure.transactionID == snapshot.id, - failure.productID == snapshot.productID, - failure.underlyingError is TestFailure - else { - await reports.append("unexpected") - return - } - await reports.append("unfinished-\(snapshot.id)") - } - let reconciler = CurrentEntitlementReconciler( - query: { - CurrentEntitlementQueryResult( - snapshots: [], - verificationFailures: [] - ) - }, - queryUnfinished: { await unfinished.read() }, - core: core, - failures: failures - ) - - do { - _ = try await reconciler.query( - retryFailedTransactions: false - ) - Issue.record("A failed unfinished transaction unexpectedly reconciled.") - } catch let owned as StoreTransactionFailureWithReportingOwner { - #expect(owned.underlyingError is TestFailure) - } catch { - Issue.record("Unexpected reconciliation error: \(error)") - } - #expect(await handlerCalls.value() == 1) - #expect(await finishes.value() == 0) - #expect(await reports.snapshot() == ["unfinished-44"]) - - let snapshots = try await reconciler.query( - retryFailedTransactions: true - ) - - #expect(snapshots.isEmpty) - #expect(await handlerCalls.value() == 2) - #expect(await finishes.value() == 1) - #expect(await reports.snapshot() == ["unfinished-44"]) - await core.finishInputAndDrain() - await failures.sealAndDrain() - } - - @Test("unfinished work is durable before a current entitlement query failure") - func handlesUnfinishedBeforeCurrentQueryFailure() async throws { - let snapshot = makeSnapshot( - id: 48, - productID: "consumable.before-query-failure", - productType: .consumable, - jws: "unfinished-before-query-failure" - ) - let unfinished = UnfinishedValueSource() - let events = StringRecorder() - let reports = StringRecorder() - let delivery = StoreTransactionDelivery.verified( - makeEnvelope(snapshot: snapshot) { - await events.append("finish") - await unfinished.replace(with: []) - } - ) - await unfinished.replace(with: [delivery]) - - let core = TransactionProcessingCore { _ in - await events.append("handle") - } - let failures = FailureReporterDispatcher { failure in - await reports.append("\(failure.source)") - } - let reconciler = CurrentEntitlementReconciler( - query: { - await events.append("current-entitlements") - throw CurrentEntitlementQueryFailure() - }, - queryUnfinished: { - await events.append("unfinished") - return await unfinished.read() - }, - core: core, - failures: failures - ) - - await #expect(throws: CurrentEntitlementQueryFailure.self) { - _ = try await reconciler.query( - retryFailedTransactions: false - ) - } - - #expect( - await events.snapshot() == [ - "unfinished", - "handle", - "finish", - "unfinished", - "current-entitlements", - ]) - #expect(await reports.snapshot().isEmpty) - await core.finishInputAndDrain() - await failures.sealAndDrain() - } - - @Test("a persistent unverified unfinished delivery is reported by the stable query once") - func persistentUnverifiedUnfinishedReportsOnce() async throws { - let snapshot = makeSnapshot( - id: 45, - productID: "lifetime.verified", - productType: .nonConsumable, - jws: "unfinished-verification-fixed-point" - ) - let current = EntitlementValueSource([]) - let unfinished = UnfinishedValueSource() - let currentQueryCount = TestSignal() - let unfinishedQueryCount = TestSignal() - let handlerCalls = TestSignal() - let finishes = TestSignal() - let reports = StringRecorder() - let unverified = StoreTransactionDelivery.unverified( - revision: Data("persistent-unverified".utf8), - error: PersistentUnfinishedVerificationFailure() - ) - let verified = StoreTransactionDelivery.verified( - makeEnvelope(snapshot: snapshot) { - await finishes.send() - await current.replace(with: [snapshot]) - await unfinished.replace(with: [unverified]) - } - ) - await unfinished.replace(with: [unverified, verified]) - - let core = TransactionProcessingCore { _ in - await handlerCalls.send() - } - let failures = FailureReporterDispatcher { failure in - guard failure.source == .unfinished, - failure.transactionID == nil, - failure.productID == nil, - failure.underlyingError - is PersistentUnfinishedVerificationFailure - else { - await reports.append("unexpected") - return - } - await reports.append("unverified") - } - let reconciler = CurrentEntitlementReconciler( - query: { - await currentQueryCount.send() - return CurrentEntitlementQueryResult( - snapshots: await current.read(), - verificationFailures: [] - ) - }, - queryUnfinished: { - await unfinishedQueryCount.send() - return await unfinished.read() - }, - core: core, - failures: failures - ) - - let snapshots = try await reconciler.query( - retryFailedTransactions: false - ) - - #expect(snapshots == [snapshot]) - #expect(await currentQueryCount.value() == 1) - #expect(await unfinishedQueryCount.value() == 3) - #expect(await handlerCalls.value() == 1) - #expect(await finishes.value() == 1) - #expect(await reports.snapshot() == ["unverified"]) - await core.finishInputAndDrain() - await failures.sealAndDrain() - } - - @Test("an observed unverified unfinished delivery is reported after it disappears") - func disappearingUnverifiedUnfinishedIsReported() async throws { - let snapshot = makeSnapshot( - id: 49, - productID: "lifetime.disappearing-verification", - productType: .nonConsumable, - jws: "disappearing-unfinished-verification" - ) - let current = EntitlementValueSource([]) - let unfinished = UnfinishedValueSource() - let reports = StringRecorder() - let unverified = StoreTransactionDelivery.unverified( - revision: Data("disappearing-unverified".utf8), - error: DisappearingUnfinishedVerificationFailure() - ) - let verified = StoreTransactionDelivery.verified( - makeEnvelope(snapshot: snapshot) { - await current.replace(with: [snapshot]) - await unfinished.replace(with: []) - } - ) - await unfinished.replace(with: [unverified, verified]) - - let core = TransactionProcessingCore { _ in } - let failures = FailureReporterDispatcher { failure in - guard failure.source == .unfinished, - failure.transactionID == nil, - failure.productID == nil, - failure.underlyingError - is DisappearingUnfinishedVerificationFailure - else { - await reports.append("unexpected") - return - } - await reports.append("unverified") - } - let reconciler = CurrentEntitlementReconciler( - query: { - CurrentEntitlementQueryResult( - snapshots: await current.read(), - verificationFailures: [] - ) - }, - queryUnfinished: { await unfinished.read() }, - core: core, - failures: failures - ) - - let snapshots = try await reconciler.query( - retryFailedTransactions: false - ) - - #expect(snapshots == [snapshot]) - #expect(await reports.snapshot() == ["unverified"]) - await core.finishInputAndDrain() - await failures.sealAndDrain() - } - - @Test("a terminal handler failure preserves verification diagnostics") - func handlerFailurePreservesVerificationDiagnostics() async throws { - let snapshot = makeSnapshot( - id: 46, - productID: "consumable.diagnostics", - productType: .consumable - ) - let finishes = TestSignal() - let reports = StringRecorder() - let unfinishedQueryCount = TestSignal() - let currentFailure = StoreTransactionVerificationError( - underlyingError: TerminalCurrentVerificationFailure() - ) - let core = TransactionProcessingCore { _ in - throw TestFailure() - } - let failures = FailureReporterDispatcher { failure in - switch failure.source { - case .unfinished: - if failure.transactionID == snapshot.id, - failure.underlyingError is TestFailure - { - await reports.append("handler") - } else if failure.transactionID == nil, - failure.underlyingError - is TerminalUnfinishedVerificationFailure - { - await reports.append("unfinished-verification") - } else { - await reports.append("unexpected") - } - case .currentEntitlementVerification: - guard - let verificationFailure = - failure.underlyingError - as? StoreTransactionVerificationError, - verificationFailure.underlyingError - is TerminalCurrentVerificationFailure - else { - await reports.append("unexpected") - return - } - await reports.append("current-verification") - default: - await reports.append("unexpected") - } - } - let reconciler = CurrentEntitlementReconciler( - query: { - CurrentEntitlementQueryResult( - snapshots: [], - verificationFailures: [currentFailure] - ) - }, - queryUnfinished: { - await unfinishedQueryCount.send() - guard await unfinishedQueryCount.value() > 1 else { - return [] - } - return [ - .unverified( - revision: Data("terminal-unverified".utf8), - error: TerminalUnfinishedVerificationFailure() - ), - .verified( - makeEnvelope(snapshot: snapshot) { - await finishes.send() - }), - ] - }, - core: core, - failures: failures - ) - - do { - _ = try await reconciler.query( - retryFailedTransactions: false - ) - Issue.record("A failed handler unexpectedly reconciled.") - } catch let owned as StoreTransactionFailureWithReportingOwner { - #expect(owned.underlyingError is TestFailure) - } catch { - Issue.record("Unexpected reconciliation error: \(error)") - } - - #expect(await finishes.value() == 0) - #expect( - await reports.snapshot() == [ - "handler", - "unfinished-verification", - "current-verification", - ]) - await core.finishInputAndDrain() - await failures.sealAndDrain() - } -} - -private struct DiscardedVerificationFailure: Error, Sendable {} - -private struct StableVerificationFailure: Error, Sendable {} - -private struct PersistentUnfinishedVerificationFailure: Error, Sendable {} - -private struct DisappearingUnfinishedVerificationFailure: Error, Sendable {} - -private struct TerminalUnfinishedVerificationFailure: Error, Sendable {} - -private struct TerminalCurrentVerificationFailure: Error, Sendable {} - -private struct CurrentEntitlementQueryFailure: Error, Sendable {} diff --git a/Tests/StoreTransactionKitTests/RuntimeContractTests.swift b/Tests/StoreTransactionKitTests/RuntimeContractTests.swift deleted file mode 100644 index 2765952..0000000 --- a/Tests/StoreTransactionKitTests/RuntimeContractTests.swift +++ /dev/null @@ -1,1192 +0,0 @@ -import Foundation -import StoreKit -import Testing -@testable import StoreTransactionKit - -@Suite("Runtime contracts", .timeLimit(.minutes(1))) -struct RuntimeContractTests { - @Test("refresh success and readiness failure preserve their physical order") - func readinessFailureOrdering() async throws { - let query = ControlledEntitlementQuery() - let successfulTokens = UInt64Recorder() - let fixture = TestSourceFixture( - currentEntitlements: { try await query.next() } - ) - let runtime = StoreTransactionRuntime( - sessionID: UUID(), - source: fixture.source, - handleTransaction: { _ in }, - entitlementsDidChange: { _ in }, - entitlementRefreshDidSucceed: { success in - await successfulTokens.append(success.token) - }, - reportFailure: { _ in } - ) - fixture.updates.yield( - .verified( - makeEnvelope( - snapshot: makeSnapshot( - id: 91, - productType: .nonConsumable - ) - ) - ) - ) - - try await query.waitForRequest(1) - let readiness = Task { try await runtime.readiness() } - await query.succeed([makeSnapshot(id: 91, productType: .nonConsumable)]) - try await query.waitForRequest(2) - await query.fail(TestFailure()) - - do { - _ = try await readiness.value - Issue.record("Readiness unexpectedly succeeded.") - } catch let failure as StoreTransactionReadinessFailure { - #expect(failure.refreshToken == 2) - #expect(failure.underlyingError is TestFailure) - } catch { - Issue.record("Unexpected readiness failure: \(error)") - } - #expect(await successfulTokens.snapshot() == [1]) - await runtime.close() - } - - @Test("receipt waiter cancellation is distinct from terminal cancellation failure") - func receiptCancellationIdentity() async throws { - let terminalFailure = ProcessingReceipt() - terminalFailure.fail(CancellationError()) - - do { - try await terminalFailure.value() - Issue.record("A terminal cancellation failure unexpectedly succeeded.") - } catch is ProcessingReceiptWaiterCancellation { - Issue.record("A terminal failure was mistaken for waiter cancellation.") - } catch is CancellationError { - // The dependency's terminal failure remains intact. - } catch { - Issue.record("Unexpected terminal receipt error: \(error)") - } - - let pending = ProcessingReceipt() - let gate = TestGate() - let waiter = Task { - _ = try? await gate.wait() - do { - try await pending.value() - return false - } catch is ProcessingReceiptWaiterCancellation { - return true - } catch { - Issue.record("Unexpected cancelled waiter error: \(error)") - return false - } - } - waiter.cancel() - await gate.open() - - #expect(await waiter.value) - } - - @Test("immediate purchase outcomes return their semantic values") - func immediatePurchaseOutcomes() async throws { - let fixture = TestSourceFixture() - let handlerCalls = TestSignal() - let reports = StringRecorder() - let session = StoreTransactionSession( - source: fixture.source, - handleTransaction: { _ in - await handlerCalls.send() - }, - reportFailure: { failure in - await reports.append("\(failure.source)") - } - ) - _ = try await session.start() - - let pending = try await session.process(.pending) - let userCancelled = try await session.process(.userCancelled) - - #expect(pending == .pending) - #expect(userCancelled == .userCancelled) - #expect(await handlerCalls.value() == 0) - #expect(await fixture.entitlementQueryCount.value() == 1) - #expect(await reports.snapshot().isEmpty) - try await session.close() - } - - @Test("immediate purchase outcomes honor caller cancellation") - func immediatePurchaseOutcomeCancellation() async throws { - let fixture = TestSourceFixture() - let session = StoreTransactionSession( - source: fixture.source, - handleTransaction: { _ in }, - reportFailure: { _ in } - ) - _ = try await session.start() - - for result: Product.PurchaseResult in [.pending, .userCancelled] { - let gate = TestGate() - let process = Task { - _ = try? await gate.wait() - return try await session.process(result) - } - process.cancel() - await gate.open() - - await #expect(throws: CancellationError.self) { - _ = try await process.value - } - } - - try await session.close() - } - - @Test("concurrent restore callers share one synchronization") - func restoreCoalescing() async throws { - let synchronizationStarted = TestSignal() - let synchronizationGate = TestGate() - let entitlementQueryCount = TestSignal() - let entitlements = EntitlementRefreshCoordinator( - query: { _ in - await entitlementQueryCount.send() - return [] - }, - didChange: { _ in } - ) - let coordinator = RestoreCoordinator( - synchronize: { - await synchronizationStarted.send() - try await synchronizationGate.wait() - }, - entitlements: entitlements - ) - - let first = await coordinator.reserve() - try await synchronizationStarted.wait(for: 1) - let second = await coordinator.reserve() - - #expect(first.role == .owner) - #expect(second.role == .observer) - #expect(first.receipt === second.receipt) - - await synchronizationGate.open() - let firstValue = try await first.receipt.terminalValue() - let secondValue = try await second.receipt.terminalValue() - - #expect(firstValue == secondValue) - #expect(await synchronizationStarted.value() == 1) - #expect(await entitlementQueryCount.value() == 1) - await entitlements.sealAndDrain() - } - - @Test("a cancelled restore observer does not report an attached failure") - func cancelledRestoreObserverDoesNotReportAttachedFailure() async throws { - let synchronizationStarted = TestSignal() - let synchronizationGate = TestGate() - let reports = StringRecorder() - let fixture = TestSourceFixture( - synchronize: { - await synchronizationStarted.send() - try await synchronizationGate.wait() - throw TestFailure() - } - ) - let runtime = StoreTransactionRuntime( - sessionID: UUID(), - source: fixture.source, - handleTransaction: { _ in }, - entitlementsDidChange: { _ in }, - reportFailure: { failure in - switch failure.source { - case .abandonedDirectOperation(.restorePurchases): - await reports.append("restore") - default: - await reports.append("unexpected") - } - } - ) - _ = try await runtime.readiness() - - let ownerLeases = try #require(runtime.beginOperation()) - let owner = Task { - try await runtime.restorePurchases(leases: ownerLeases) - } - try await synchronizationStarted.wait(for: 1) - - let observerLeases = try #require(runtime.beginOperation()) - let observer = Task { - try await runtime.restorePurchases(leases: observerLeases) - } - observer.cancel() - await #expect(throws: CancellationError.self) { - _ = try await observer.value - } - - await synchronizationGate.open() - await #expect(throws: TestFailure.self) { - _ = try await owner.value - } - await runtime.close() - - #expect(await reports.snapshot().isEmpty) - } - - @Test("cancelled coalesced restore callers report one physical failure") - func cancelledRestoreCallersReportOnce() async throws { - let synchronizationStarted = TestSignal() - let synchronizationGate = TestGate() - let reports = StringRecorder() - let fixture = TestSourceFixture( - synchronize: { - await synchronizationStarted.send() - try await synchronizationGate.wait() - throw TestFailure() - } - ) - let runtime = StoreTransactionRuntime( - sessionID: UUID(), - source: fixture.source, - handleTransaction: { _ in }, - entitlementsDidChange: { _ in }, - reportFailure: { failure in - switch failure.source { - case .abandonedDirectOperation(.restorePurchases): - await reports.append("restore") - default: - await reports.append("unexpected") - } - } - ) - _ = try await runtime.readiness() - - let ownerLeases = try #require(runtime.beginOperation()) - let owner = Task { - try await runtime.restorePurchases(leases: ownerLeases) - } - try await synchronizationStarted.wait(for: 1) - owner.cancel() - await #expect(throws: CancellationError.self) { - _ = try await owner.value - } - - let observerLeases = try #require(runtime.beginOperation()) - let observer = Task { - try await runtime.restorePurchases(leases: observerLeases) - } - observer.cancel() - await #expect(throws: CancellationError.self) { - _ = try await observer.value - } - - await synchronizationGate.open() - await runtime.close() - - #expect(await reports.snapshot() == ["restore"]) - } - - @Test("an abandoned refresh reports its later failure exactly once") - func abandonedRefreshFailure() async throws { - let query = ControlledEntitlementQuery() - let reported = TestSignal() - let reports = StringRecorder() - let fixture = TestSourceFixture( - currentEntitlements: { try await query.next() } - ) - let session = StoreTransactionSession( - source: fixture.source, - handleTransaction: { _ in }, - reportFailure: { failure in - switch failure.source { - case .abandonedDirectOperation(.refreshEntitlements): - await reports.append("abandoned-refresh") - default: - await reports.append("unexpected") - } - await reported.send() - } - ) - - let startup = Task { try await session.start() } - try await query.waitForRequest(1) - await query.succeed([]) - _ = try await startup.value - - let refresh = Task { try await session.currentEntitlements() } - try await query.waitForRequest(2) - refresh.cancel() - await #expect(throws: CancellationError.self) { - _ = try await refresh.value - } - await query.fail(TestFailure()) - try await reported.wait(for: 1) - - try await session.close() - #expect(await reports.snapshot() == ["abandoned-refresh"]) - } - - @Test("an attached refresh receives the reported underlying failure and can retry") - func attachedRefreshUnwrapsReportedFailure() async throws { - let unfinished = UnfinishedValueSource() - let fixture = TestSourceFixture( - queryUnfinished: { await unfinished.read() } - ) - let handlerCalls = TestSignal() - let finishes = TestSignal() - let reports = StringRecorder() - let snapshot = makeSnapshot( - id: 26, - productID: "consumable.refresh", - productType: .consumable - ) - let session = StoreTransactionSession( - source: fixture.source, - handleTransaction: { _ in - await handlerCalls.send() - if await handlerCalls.value() == 1 { - throw TestFailure() - } - }, - reportFailure: { failure in - if failure.source == .unfinished, - failure.transactionID == snapshot.id, - failure.underlyingError is TestFailure - { - await reports.append("unfinished-26") - } else { - await reports.append("unexpected") - } - } - ) - _ = try await session.start() - await unfinished.replace( - with: [ - .verified( - makeEnvelope(snapshot: snapshot) { - await finishes.send() - await unfinished.replace(with: []) - }) - ] - ) - - await #expect(throws: TestFailure.self) { - _ = try await session.currentEntitlements() - } - #expect(await handlerCalls.value() == 1) - #expect(await finishes.value() == 0) - #expect(await reports.snapshot() == ["unfinished-26"]) - - let entitlements = try await session.currentEntitlements() - - #expect(entitlements.transactions.isEmpty) - #expect(await handlerCalls.value() == 2) - #expect(await finishes.value() == 1) - #expect(await reports.snapshot() == ["unfinished-26"]) - try await session.close() - } - - @Test("a direct process unwraps a reported refresh failure and can retry") - func directProcessUnwrapsReportedRefreshFailure() async throws { - let unfinished = UnfinishedValueSource() - let fixture = TestSourceFixture( - queryUnfinished: { await unfinished.read() } - ) - let handled = UInt64Recorder() - let consumableAttempts = TestSignal() - let directFinishes = TestSignal() - let consumableFinishes = TestSignal() - let reports = StringRecorder() - let direct = makeSnapshot( - id: 27, - productID: "lifetime.direct", - productType: .nonConsumable - ) - let consumable = makeSnapshot( - id: 28, - productID: "consumable.process", - productType: .consumable - ) - let runtime = StoreTransactionRuntime( - sessionID: UUID(), - source: fixture.source, - handleTransaction: { snapshot in - await handled.append(snapshot.id) - if snapshot.id == consumable.id { - await consumableAttempts.send() - if await consumableAttempts.value() == 1 { - throw TestFailure() - } - } - }, - entitlementsDidChange: { _ in }, - reportFailure: { failure in - if failure.source == .unfinished, - failure.transactionID == consumable.id, - failure.underlyingError is TestFailure - { - await reports.append("unfinished-28") - } else { - await reports.append("unexpected") - } - } - ) - _ = try await runtime.readiness() - await unfinished.replace( - with: [ - .verified( - makeEnvelope(snapshot: consumable) { - await consumableFinishes.send() - await unfinished.replace(with: []) - }) - ] - ) - let directDelivery = StoreTransactionDelivery.verified( - makeEnvelope(snapshot: direct) { - await directFinishes.send() - } - ) - - let firstLeases = try #require(runtime.beginOperation()) - await #expect(throws: TestFailure.self) { - _ = try await runtime.process( - directDelivery, - leases: firstLeases - ) - } - #expect(await handled.snapshot() == [direct.id, consumable.id]) - #expect(await directFinishes.value() == 1) - #expect(await consumableFinishes.value() == 0) - #expect(await reports.snapshot() == ["unfinished-28"]) - - let secondLeases = try #require(runtime.beginOperation()) - let outcome = try await runtime.process( - directDelivery, - leases: secondLeases - ) - - #expect(outcome == .completed(direct)) - #expect( - await handled.snapshot() == [ - direct.id, consumable.id, consumable.id, - ] - ) - #expect(await directFinishes.value() == 1) - #expect(await consumableFinishes.value() == 1) - #expect(await reports.snapshot() == ["unfinished-28"]) - await runtime.close() - } - - @Test("restore unwraps a reported refresh failure and can retry") - func restoreUnwrapsReportedRefreshFailure() async throws { - let unfinished = UnfinishedValueSource() - let synchronizations = TestSignal() - let fixture = TestSourceFixture( - queryUnfinished: { await unfinished.read() }, - synchronize: { await synchronizations.send() } - ) - let handlerCalls = TestSignal() - let finishes = TestSignal() - let reports = StringRecorder() - let snapshot = makeSnapshot( - id: 29, - productID: "consumable.restore", - productType: .consumable - ) - let session = StoreTransactionSession( - source: fixture.source, - handleTransaction: { _ in - await handlerCalls.send() - if await handlerCalls.value() == 1 { - throw TestFailure() - } - }, - reportFailure: { failure in - if failure.source == .unfinished, - failure.transactionID == snapshot.id, - failure.underlyingError is TestFailure - { - await reports.append("unfinished-29") - } else { - await reports.append("unexpected") - } - } - ) - _ = try await session.start() - await unfinished.replace( - with: [ - .verified( - makeEnvelope(snapshot: snapshot) { - await finishes.send() - await unfinished.replace(with: []) - }) - ] - ) - - await #expect(throws: TestFailure.self) { - _ = try await session.restorePurchases() - } - #expect(await synchronizations.value() == 1) - #expect(await reports.snapshot() == ["unfinished-29"]) - - let entitlements = try await session.restorePurchases() - - #expect(entitlements.transactions.isEmpty) - #expect(await synchronizations.value() == 2) - #expect(await handlerCalls.value() == 2) - #expect(await finishes.value() == 1) - #expect(await reports.snapshot() == ["unfinished-29"]) - try await session.close() - } - - @Test("an abandoned refresh does not report an owned reconciliation failure twice") - func abandonedRefreshDoesNotDuplicateReportedFailure() async throws { - let unfinished = UnfinishedValueSource() - let fixture = TestSourceFixture( - queryUnfinished: { await unfinished.read() } - ) - let handlerStarted = TestSignal() - let handlerGate = TestGate() - let reported = TestSignal() - let reports = StringRecorder() - let snapshot = makeSnapshot( - id: 30, - productID: "consumable.abandoned", - productType: .consumable - ) - let session = StoreTransactionSession( - source: fixture.source, - handleTransaction: { _ in - await handlerStarted.send() - try await handlerGate.wait() - throw TestFailure() - }, - reportFailure: { failure in - if failure.source == .unfinished, - failure.transactionID == snapshot.id, - failure.underlyingError is TestFailure - { - await reports.append("unfinished-30") - } else { - await reports.append("unexpected") - } - await reported.send() - } - ) - _ = try await session.start() - await unfinished.replace( - with: [.verified(makeEnvelope(snapshot: snapshot))] - ) - - let refresh = Task { - try await session.currentEntitlements() - } - try await handlerStarted.wait(for: 1) - refresh.cancel() - await #expect(throws: CancellationError.self) { - _ = try await refresh.value - } - - await handlerGate.open() - try await reported.wait(for: 1) - try await session.close() - - #expect(await reports.snapshot() == ["unfinished-30"]) - } - - @Test("an abandoned restore does not report an owned reconciliation failure twice") - func abandonedRestoreDoesNotDuplicateReportedFailure() async throws { - let unfinished = UnfinishedValueSource() - let synchronizations = TestSignal() - let fixture = TestSourceFixture( - queryUnfinished: { await unfinished.read() }, - synchronize: { await synchronizations.send() } - ) - let handlerStarted = TestSignal() - let handlerGate = TestGate() - let reported = TestSignal() - let reports = StringRecorder() - let snapshot = makeSnapshot( - id: 32, - productID: "consumable.abandoned-restore", - productType: .consumable - ) - let session = StoreTransactionSession( - source: fixture.source, - handleTransaction: { _ in - await handlerStarted.send() - try await handlerGate.wait() - throw TestFailure() - }, - reportFailure: { failure in - if failure.source == .unfinished, - failure.transactionID == snapshot.id, - failure.underlyingError is TestFailure - { - await reports.append("unfinished-32") - } else { - await reports.append("unexpected") - } - await reported.send() - } - ) - _ = try await session.start() - await unfinished.replace( - with: [.verified(makeEnvelope(snapshot: snapshot))] - ) - - let restore = Task { - try await session.restorePurchases() - } - try await handlerStarted.wait(for: 1) - restore.cancel() - await #expect(throws: CancellationError.self) { - _ = try await restore.value - } - - await handlerGate.open() - try await reported.wait(for: 1) - try await session.close() - - #expect(await synchronizations.value() == 1) - #expect(await reports.snapshot() == ["unfinished-32"]) - } - - @Test("history is newest first and retains revoked transactions") - func historyOrderAndMembership() async throws { - let sharedDate = Date(timeIntervalSince1970: 100) - let older = makeSnapshot(id: 1, purchaseDate: Date(timeIntervalSince1970: 10)) - let lowerID = makeSnapshot( - id: 2, - purchaseDate: sharedDate, - signedDate: Date(timeIntervalSince1970: 200) - ) - let higherIDRevoked = makeSnapshot( - id: 3, - purchaseDate: sharedDate, - signedDate: Date(timeIntervalSince1970: 200), - revocationDate: Date(timeIntervalSince1970: 300) - ) - let newestSigned = makeSnapshot( - id: 4, - purchaseDate: sharedDate, - signedDate: Date(timeIntervalSince1970: 201) - ) - let fixture = TestSourceFixture( - history: { _ in - [older, lowerID, higherIDRevoked, newestSigned] - } - ) - let session = StoreTransactionSession( - source: fixture.source, - handleTransaction: { _ in }, - reportFailure: { _ in } - ) - _ = try await session.start() - - let history = try await session.history(for: "product") - - #expect(history.map(\.id) == [4, 3, 2, 1]) - #expect(history[1].revocationDate != nil) - try await session.close() - } - - @Test("background entitlement refresh failures have their own source") - func backgroundEntitlementRefreshFailure() async throws { - let query = ControlledEntitlementQuery() - let reported = TestSignal() - let reports = StringRecorder() - let fixture = TestSourceFixture( - currentEntitlements: { try await query.next() } - ) - let session = StoreTransactionSession( - source: fixture.source, - handleTransaction: { _ in }, - reportFailure: { failure in - await reports.append( - "\(failure.source)-\(failure.transactionID ?? 0)-\(failure.productID ?? "")" - ) - await reported.send() - } - ) - - let startup = Task { try await session.start() } - try await query.waitForRequest(1) - await query.succeed([]) - _ = try await startup.value - - fixture.updates.yield( - .verified(makeEnvelope(snapshot: makeSnapshot(id: 19))) - ) - try await query.waitForRequest(2) - await query.fail(TestFailure()) - try await reported.wait(for: 1) - - try await session.close() - #expect(await reports.snapshot() == ["entitlementRefresh-19-product"]) - } - - @Test("an update owner prevents an observer refresh from reporting the same failure") - func observerRefreshUsesTransactionReportingOwner() async throws { - let handlerStarted = TestSignal() - let handlerGate = TestGate() - let reports = StringRecorder() - let snapshot = makeSnapshot( - id: 31, - productID: "consumable.observer", - productType: .consumable - ) - let envelope = makeEnvelope(snapshot: snapshot) - let core = TransactionProcessingCore { _ in - await handlerStarted.send() - try await handlerGate.wait() - throw TestFailure() - } - let failures = FailureReporterDispatcher { failure in - switch failure.source { - case .updates where failure.underlyingError is TestFailure: - await reports.append("updates") - case .unfinished, .entitlementRefresh, - .abandonedDirectOperation: - await reports.append("duplicate") - default: - await reports.append("unexpected") - } - } - let reconciler = CurrentEntitlementReconciler( - query: { - CurrentEntitlementQueryResult( - snapshots: [], - verificationFailures: [] - ) - }, - queryUnfinished: { [] }, - core: core, - failures: failures - ) - - let owner = await core.accept(envelope) - try await handlerStarted.wait(for: 1) - let observer = await core.accept(envelope) - #expect(owner.role == .owner) - #expect(observer.role == .inFlightObserver) - - let entitlements = EntitlementRefreshCoordinator( - query: { _ in - try await reconciler.drain([ - CurrentEntitlementReconciler.AcceptedTransaction( - snapshot: snapshot, - acceptance: observer - ) - ]) - return [] - }, - didChange: { _ in } - ) - let pipeline = StoreTransactionPipeline( - core: core, - entitlements: entitlements, - failures: failures - ) - let update = Task { - await pipeline.processAcceptedBackground( - snapshot: snapshot, - acceptance: owner, - source: .updates - ) - } - let subscriptionRefresh = Task { - await pipeline.refreshEntitlements() - } - - await handlerGate.open() - await update.value - await subscriptionRefresh.value - - #expect(await reports.snapshot() == ["updates"]) - await core.finishInputAndDrain() - await entitlements.sealAndDrain() - await failures.sealAndDrain() - } - - @Test("reconciliation handles a new unfinished revision before querying entitlements") - func reconciliationHandlesUnfinishedBeforeQuerying() async throws { - let entitlementQueryCount = TestSignal() - let unfinishedQueryStarted = TestSignal() - let unfinishedQueryGate = TestGate() - let handlerCalls = TestSignal() - let finishes = TestSignal() - let currentEntitlements = EntitlementValueSource([]) - let reports = StringRecorder() - let snapshot = makeSnapshot( - id: 24, - productType: .nonConsumable - ) - let delivery = StoreTransactionDelivery.verified( - makeEnvelope(snapshot: snapshot, revision: "arrived-after-query") { - await currentEntitlements.replace(with: [snapshot]) - await finishes.send() - } - ) - let core = TransactionProcessingCore { _ in - await handlerCalls.send() - } - let failures = FailureReporterDispatcher { failure in - await reports.append("\(failure.source)") - } - let reconciler = CurrentEntitlementReconciler( - query: { - await entitlementQueryCount.send() - return CurrentEntitlementQueryResult( - snapshots: await currentEntitlements.read(), - verificationFailures: [] - ) - }, - queryUnfinished: { - await unfinishedQueryStarted.send() - _ = try? await unfinishedQueryGate.wait() - return [delivery] - }, - core: core, - failures: failures - ) - - let query = Task { - try await reconciler.query(retryFailedTransactions: false) - } - try await unfinishedQueryStarted.wait(for: 1) - #expect(await entitlementQueryCount.value() == 0) - - await unfinishedQueryGate.open() - let snapshots = try await query.value - - #expect(snapshots == [snapshot]) - #expect(await entitlementQueryCount.value() == 1) - #expect(await handlerCalls.value() == 1) - #expect(await finishes.value() == 1) - await core.finishInputAndDrain() - await failures.sealAndDrain() - #expect(await reports.snapshot().isEmpty) - } - - @Test("duplicate background deliveries report one handler failure") - func duplicateBackgroundDeliveryFailure() async throws { - let handlerStarted = TestSignal() - let handlerGate = TestGate() - let handlerCalls = TestSignal() - let finishes = TestSignal() - let reports = StringRecorder() - let core = TransactionProcessingCore { _ in - await handlerCalls.send() - await handlerStarted.send() - try await handlerGate.wait() - throw TestFailure() - } - let entitlements = EntitlementRefreshCoordinator( - query: { _ in - Issue.record("A failed transaction unexpectedly refreshed entitlements.") - return [] - }, - didChange: { _ in } - ) - let failures = FailureReporterDispatcher { failure in - switch failure.source { - case .updates: - await reports.append("updates") - case .unfinished: - await reports.append("unfinished") - default: - await reports.append("unexpected") - } - } - let pipeline = StoreTransactionPipeline( - core: core, - entitlements: entitlements, - failures: failures - ) - let snapshot = makeSnapshot(id: 21) - let update = try await pipeline.accept( - .verified( - makeEnvelope(snapshot: snapshot, revision: "same") { - await finishes.send() - }) - ) - try await handlerStarted.wait(for: 1) - let unfinished = try await pipeline.accept( - .verified( - makeEnvelope(snapshot: snapshot, revision: "same") { - await finishes.send() - }) - ) - - #expect(update.acceptance.role == .owner) - #expect(unfinished.acceptance.role == .inFlightObserver) - - let updateTask = Task { - await pipeline.processAcceptedBackground( - snapshot: update.snapshot, - acceptance: update.acceptance, - source: .updates - ) - } - let unfinishedTask = Task { - await pipeline.processAcceptedBackground( - snapshot: unfinished.snapshot, - acceptance: unfinished.acceptance, - source: .unfinished - ) - } - await handlerGate.open() - await updateTask.value - await unfinishedTask.value - - #expect(await handlerCalls.value() == 1) - #expect(await finishes.value() == 0) - #expect(await reports.snapshot() == ["updates"]) - - await core.finishInputAndDrain() - await entitlements.sealAndDrain() - await failures.sealAndDrain() - } - - @Test("a later delivery retries after an earlier handler attempt fails") - func laterDeliveryRetriesFailedRevision() async { - let handlerCalls = TestSignal() - let reports = StringRecorder() - let core = TransactionProcessingCore { _ in - await handlerCalls.send() - throw TestFailure() - } - let entitlements = EntitlementRefreshCoordinator( - query: { _ in - Issue.record("A failed transaction unexpectedly refreshed entitlements.") - return [] - }, - didChange: { _ in } - ) - let failures = FailureReporterDispatcher { failure in - await reports.append("\(failure.source)") - } - let pipeline = StoreTransactionPipeline( - core: core, - entitlements: entitlements, - failures: failures - ) - let delivery = StoreTransactionDelivery.verified( - makeEnvelope(snapshot: makeSnapshot(id: 22), revision: "retry") - ) - - await pipeline.processBackground(delivery, source: .updates) - await core.completeInitialAttempt() - await pipeline.processBackground(delivery, source: .unfinished) - - #expect(await handlerCalls.value() == 2) - #expect(await reports.snapshot() == ["updates", "unfinished"]) - await core.finishInputAndDrain() - await entitlements.sealAndDrain() - await failures.sealAndDrain() - } - - @Test("a cancelled direct observer leaves failure reporting with the background owner") - func directObserverCancellationDoesNotDuplicateFailure() async throws { - let handlerStarted = TestSignal() - let handlerGate = TestGate() - let handlerCalls = TestSignal() - let reported = TestSignal() - let reports = StringRecorder() - let fixture = TestSourceFixture() - let runtime = StoreTransactionRuntime( - sessionID: UUID(), - source: fixture.source, - handleTransaction: { _ in - await handlerCalls.send() - await handlerStarted.send() - try await handlerGate.wait() - throw TestFailure() - }, - entitlementsDidChange: { _ in }, - reportFailure: { failure in - await reports.append("\(failure.source)") - await reported.send() - } - ) - _ = try await runtime.readiness() - let snapshot = makeSnapshot(id: 23) - let delivery = StoreTransactionDelivery.verified( - makeEnvelope(snapshot: snapshot, revision: "shared") - ) - fixture.updates.yield(delivery) - try await handlerStarted.wait(for: 1) - - let leases = try #require(runtime.beginOperation()) - let directObserver = Task { - try await runtime.process(delivery, leases: leases) - } - directObserver.cancel() - await #expect(throws: CancellationError.self) { - _ = try await directObserver.value - } - - await handlerGate.open() - try await reported.wait(for: 1) - await runtime.close() - - #expect(await handlerCalls.value() == 1) - #expect(await reports.snapshot() == ["updates"]) - } - - @Test("a cancelled completed observer reports its own refresh failure") - func completedObserverCancellationReportsRefreshFailure() async throws { - let query = ControlledEntitlementQuery() - let handlerCalls = TestSignal() - let finishes = TestSignal() - let reported = TestSignal() - let reports = StringRecorder() - let fixture = TestSourceFixture( - currentEntitlements: { try await query.next() } - ) - let runtime = StoreTransactionRuntime( - sessionID: UUID(), - source: fixture.source, - handleTransaction: { _ in - await handlerCalls.send() - }, - entitlementsDidChange: { _ in }, - reportFailure: { failure in - switch failure.source { - case .abandonedDirectOperation(.processPurchase): - await reports.append("abandoned-process") - default: - await reports.append("unexpected") - } - await reported.send() - } - ) - - let readiness = Task { try await runtime.readiness() } - try await query.waitForRequest(1) - await query.succeed([]) - _ = try await readiness.value - - let snapshot = makeSnapshot( - id: 25, - productType: .nonConsumable - ) - let delivery = StoreTransactionDelivery.verified( - makeEnvelope(snapshot: snapshot, revision: "completed") { - await finishes.send() - } - ) - let firstLeases = try #require(runtime.beginOperation()) - let firstProcess = Task { - try await runtime.process(delivery, leases: firstLeases) - } - try await query.waitForRequest(2) - await query.succeed([snapshot]) - _ = try await firstProcess.value - - let secondLeases = try #require(runtime.beginOperation()) - let completedObserver = Task { - try await runtime.process(delivery, leases: secondLeases) - } - try await query.waitForRequest(3) - completedObserver.cancel() - await #expect(throws: CancellationError.self) { - _ = try await completedObserver.value - } - await query.fail(TestFailure()) - try await reported.wait(for: 1) - - await runtime.close() - #expect(await handlerCalls.value() == 1) - #expect(await finishes.value() == 1) - #expect(await reports.snapshot() == ["abandoned-process"]) - } - - @Test("close completes after accepted handling and finish") - func closeDrainsAcceptedTransaction() async throws { - let handlerStarted = TestSignal() - let handlerGate = TestGate() - let events = StringRecorder() - let closeCallersStarted = TestSignal() - let closeCallersFinished = TestSignal() - let fixture = TestSourceFixture() - let session = StoreTransactionSession( - source: fixture.source, - handleTransaction: { _ in - await events.append("handle-start") - await handlerStarted.send() - try await handlerGate.wait() - await events.append("handle-end") - }, - reportFailure: { _ in } - ) - _ = try await session.start() - fixture.updates.yield( - .verified( - makeEnvelope(snapshot: makeSnapshot(id: 20)) { - await events.append("finish") - })) - try await handlerStarted.wait(for: 1) - - let firstClose = Task { - await closeCallersStarted.send() - try await session.close() - await events.append("close-1") - await closeCallersFinished.send() - } - let secondClose = Task { - await closeCallersStarted.send() - try await session.close() - await events.append("close-2") - await closeCallersFinished.send() - } - try await closeCallersStarted.wait(for: 2) - #expect(await closeCallersFinished.value() == 0) - - await handlerGate.open() - try await firstClose.value - try await secondClose.value - - let recorded = await events.snapshot() - #expect(recorded.prefix(3) == ["handle-start", "handle-end", "finish"]) - #expect(Set(recorded.suffix(2)) == ["close-1", "close-2"]) - } -} - -@Suite("Completed revision cache") -struct CompletedRevisionCacheTests { - @Test("eviction removes the oldest completed revision") - func eviction() { - var cache = CompletedRevisionCache(capacity: 2) - let first = Data("first".utf8) - let second = Data("second".utf8) - let third = Data("third".utf8) - - cache.insert(first) - cache.insert(second) - cache.insert(third) - - #expect(!cache.contains(first)) - #expect(cache.contains(second)) - #expect(cache.contains(third)) - } -} - -@Suite("Task completion bag", .timeLimit(.minutes(1))) -struct TaskCompletionBagTests { - @Test("completed tasks are released when the bag becomes empty") - func completedTasksAreReleased() async throws { - let bag = TaskCompletionBag() - let completed = TestSignal() - - for _ in 0..<32 { - bag.insert( - Task { - await completed.send() - }) - } - try await completed.wait(for: 32) - await bag.waitForAll() - #expect(bag.retainedTaskCount() == 0) - } -} diff --git a/Tests/StoreTransactionKitTests/RuntimeOwnerTests.swift b/Tests/StoreTransactionKitTests/RuntimeOwnerTests.swift new file mode 100644 index 0000000..95d1613 --- /dev/null +++ b/Tests/StoreTransactionKitTests/RuntimeOwnerTests.swift @@ -0,0 +1,700 @@ +import StoreKit +import Testing +@testable import StoreTransactionKit + +@Suite("Runtime owners", .timeLimit(.minutes(1))) +struct RuntimeOwnerTests { + @MainActor + @Test("unfinished reconciliation reaches a fixed point before publication") + func fixedPointReconciliation() async throws { + let first = makeSubscriptionSnapshot( + id: 20, + productID: .tier1Monthly + ) + let second = makeSubscriptionSnapshot( + id: 21, + productID: .tier2Monthly + ) + let unfinished = UnfinishedValueSource() + let finishes = UInt64Recorder() + await unfinished.replace(with: [ + .verified( + makeEnvelope(snapshot: first) { + await finishes.append(first.id) + await unfinished.replace(with: [ + .verified( + makeEnvelope(snapshot: second) { + await finishes.append(second.id) + await unfinished.replace(with: []) + } + ) + ]) + } + ) + ]) + let fixture = TestSourceFixture( + currentEntitlements: { [first, second] }, + queryUnfinished: { await unfinished.read() } + ) + let store = TransactionStore( + source: fixture.source, + subscriptionCatalog: testSubscriptionCatalog + ) + + try await store.waitForInitialReadiness() + + #expect(await finishes.snapshot() == [first.id, second.id]) + #expect(store.entitlements?.transactions == [first, second]) + #expect(store.activeEntitlements == [.tier1, .tier2]) + try await store.close() + } + + @MainActor + @Test("reconciliation reports every exact decision failure once") + func reconciliationReportsEveryDecisionFailure() async throws { + let first = makeSubscriptionSnapshot( + id: 201, + productID: .tier1Monthly, + revision: "failure-201" + ) + let second = makeSubscriptionSnapshot( + id: 202, + productID: .tier2Monthly, + revision: "failure-202" + ) + let delegate = FailingDecisionDelegate() + let fixture = TestSourceFixture( + queryUnfinished: { + [ + .verified(makeEnvelope(snapshot: first)), + .verified(makeEnvelope(snapshot: second)), + ] + } + ) + let store = TransactionStore( + source: fixture.source, + subscriptionCatalog: testSubscriptionCatalog, + delegate: delegate + ) + + await #expect(throws: TransactionDecisionFailure.self) { + try await store.waitForInitialReadiness() + } + try await delegate.waitForFailures(2) + try await store.close() + + let failures = await delegate.failures() + #expect(failures.map(\.transactionID) == [first.id, second.id]) + #expect(failures.allSatisfy { $0.source == .unfinished }) + #expect( + failures.compactMap { + ($0.underlyingError as? TransactionDecisionFailure)?.id + } == [first.id, second.id] + ) + } + + @MainActor + @Test("verified remainder publishes before verification diagnostics") + func verificationFailurePublishesRemainder() async throws { + let snapshot = makeSubscriptionSnapshot( + id: 22, + productID: .tier1Monthly + ) + let holder = TransactionStoreHolder() + let delegate = StateReadingFailureDelegate(holder: holder) + let fixture = TestSourceFixture( + currentEntitlements: { [snapshot] }, + currentEntitlementVerificationFailures: { + [ + StoreTransactionVerificationError( + underlyingError: TestFailure() + ) + ] + } + ) + let store = TransactionStore( + source: fixture.source, + subscriptionCatalog: testSubscriptionCatalog, + delegate: delegate + ) + holder.set(store) + + try await store.waitForInitialReadiness() + try await delegate.waitForFailure() + + #expect(await delegate.observedReadyState()) + #expect(store.entitlements?.transactions == [snapshot]) + #expect(store.activeEntitlements == [.tier1]) + try await store.close() + } + + @MainActor + @Test("an abandoned refresh transfers one terminal failure to the background") + func cancellationTransfersFailureOwnership() async throws { + let query = ControlledEntitlementQuery() + let delegate = FailureRecordingDelegate() + let fixture = TestSourceFixture( + currentEntitlements: { try await query.next() } + ) + let store = TransactionStore( + source: fixture.source, + subscriptionCatalog: testSubscriptionCatalog, + delegate: delegate + ) + try await query.waitForRequest(1) + await query.succeed([]) + try await store.waitForInitialReadiness() + + let refresh = Task { @MainActor in + try await store.refreshEntitlements() + } + try await query.waitForRequest(2) + refresh.cancel() + await #expect(throws: CancellationError.self) { + _ = try await refresh.value + } + await query.fail(TestFailure()) + try await delegate.waitForFailures(1) + + let failures = await delegate.failures() + #expect(failures.count == 1) + #expect( + failures.first?.source + == .abandonedDirectOperation(.refreshEntitlements) + ) + #expect(failures.first?.underlyingError is TestFailure) + try await store.close() + } + + @MainActor + @Test("cancellation after unverified delivery admission transfers failure ownership") + func unverifiedCancellationTransfersFailureOwnership() async throws { + let delegate = FailureRecordingDelegate() + let fixture = TestSourceFixture() + let store = TransactionStore( + source: fixture.source, + subscriptionCatalog: testSubscriptionCatalog, + delegate: delegate + ) + try await store.waitForInitialReadiness() + let admitted = TestSignal() + let release = ProcessingReceipt() + let operation = Task { @MainActor in + try await store.process( + .unverified( + revision: Data("unverified".utf8), + error: TestFailure() + ) + ) { + await admitted.send() + _ = try? await release.terminalValue() + } + } + try await admitted.wait(for: 1) + + operation.cancel() + release.succeed(()) + + await #expect(throws: CancellationError.self) { + _ = try await operation.value + } + try await delegate.waitForFailures(1) + try await store.close() + let failures = await delegate.failures() + #expect(failures.count == 1) + #expect( + failures[0].source + == .abandonedDirectOperation(.processPurchase) + ) + #expect(failures[0].underlyingError is TestFailure) + } + + @MainActor + @Test("close drains a producer element returned before iteration admission sealed") + func producerCloseRaceDrainsPublication() async throws { + let snapshot = makeSubscriptionSnapshot( + id: 23, + productID: .tier2Monthly + ) + let current = EntitlementValueSource([]) + let delegate = GatedDecisionDelegate() + let finishes = TestSignal() + let fixture = TestSourceFixture( + currentEntitlements: { await current.read() } + ) + let store = TransactionStore( + source: fixture.source, + subscriptionCatalog: testSubscriptionCatalog, + delegate: delegate + ) + try await store.waitForInitialReadiness() + await current.replace(with: [snapshot]) + + fixture.updates.yield( + .verified( + makeEnvelope(snapshot: snapshot, revision: "producer-close") { + await finishes.send() + } + ) + ) + try await delegate.waitUntilDecisionStarts() + + let close = Task { @MainActor in try await store.close() } + await store.waitUntilClosing() + let cancelledClose = Task { @MainActor in try await store.close() } + cancelledClose.cancel() + await delegate.allowDecision() + try await close.value + try await cancelledClose.value + + #expect(await finishes.value() == 1) + #expect(store.entitlements?.transactions == [snapshot]) + #expect(store.activeEntitlements == [.tier2]) + } + + @MainActor + @Test("history is all-or-nothing and has deterministic newest-first order") + func historyOrdering() async throws { + let oldest = makeSnapshot( + id: 1, + purchaseDate: Date(timeIntervalSince1970: 1), + signedDate: Date(timeIntervalSince1970: 2) + ) + let newestLowerID = makeSnapshot( + id: 2, + purchaseDate: Date(timeIntervalSince1970: 3), + signedDate: Date(timeIntervalSince1970: 4), + jws: "b" + ) + let newestHigherID = makeSnapshot( + id: 3, + purchaseDate: Date(timeIntervalSince1970: 3), + signedDate: Date(timeIntervalSince1970: 4), + jws: "a" + ) + let fixture = TestSourceFixture( + history: { _ in [oldest, newestLowerID, newestHigherID] } + ) + let store = TransactionStore( + source: fixture.source, + subscriptionCatalog: testSubscriptionCatalog + ) + try await store.waitForInitialReadiness() + + let history = try await store.history(for: "history.product") + + #expect(history == [newestHigherID, newestLowerID, oldest]) + try await store.close() + } + + @MainActor + @Test("restore wraps only a refresh failure that follows successful sync") + func restoreFailureContext() async throws { + let query = ControlledEntitlementQuery() + let syncs = TestSignal() + let fixture = TestSourceFixture( + currentEntitlements: { try await query.next() }, + synchronize: { await syncs.send() } + ) + let store = TransactionStore( + source: fixture.source, + subscriptionCatalog: testSubscriptionCatalog + ) + try await query.waitForRequest(1) + await query.succeed([]) + try await store.waitForInitialReadiness() + + let restore = Task { @MainActor in + try await store.restorePurchases() + } + try await query.waitForRequest(2) + await query.fail(TestFailure()) + + do { + _ = try await restore.value + Issue.record("Expected a post-sync refresh failure.") + } catch StoreTransactionError.entitlementRefreshFailed( + after: .synchronizedPurchases, + underlyingError: let error + ) { + #expect(error is TestFailure) + } + #expect(await syncs.value() == 1) + try await store.close() + } + + @MainActor + @Test("synthetic capability errors precede work but closed state takes priority") + func syntheticOperationGateOrdering() async throws { + let source = SyntheticStoreTransactionSource( + currentEntitlements: { [] } + ) + let store = TransactionStore( + subscriptionCatalog: testSubscriptionCatalog, + syntheticSource: source, + unavailableOperationError: { operation in + StoreTransactionError.operationUnavailableInOverride( + operation: operation + ) + } + ) + try await store.waitForInitialReadiness() + + do { + _ = try await store.history(for: "product") + Issue.record("Synthetic source unexpectedly queried history.") + } catch StoreTransactionError.operationUnavailableInOverride( + operation: .history + ) {} + + try await store.close() + do { + _ = try await store.history(for: "product") + Issue.record("Closed synthetic store exposed capability error.") + } catch StoreTransactionError.closed {} + } + + @MainActor + @Test("synthetic delivery uses production policy, acknowledgement, and publication") + func syntheticDeliveryProductionPath() async throws { + let current = EntitlementValueSource([]) + let syntheticSource = SyntheticStoreTransactionSource( + currentEntitlements: { await current.read() } + ) + let snapshot = makeSubscriptionSnapshot( + id: 24, + productID: .tier1Yearly, + revision: "synthetic-24" + ) + let store = TransactionStore( + subscriptionCatalog: testSubscriptionCatalog, + syntheticSource: syntheticSource, + unavailableOperationError: { + SyntheticOperationError(operation: $0) + } + ) + try await store.waitForInitialReadiness() + + do { + _ = try await store.history(for: snapshot.productID) + Issue.record("Synthetic store unexpectedly queried history.") + } catch let error as SyntheticOperationError { + #expect(error.operation == .history) + } + + let completed = try await store.processSyntheticDelivery( + .synthetic(snapshot: snapshot) { + await current.replace(with: [snapshot]) + } + ) + + #expect(completed == snapshot) + #expect(store.entitlements?.transactions == [snapshot]) + #expect(store.activeEntitlements == [.tier1]) + try await store.close() + } + + @MainActor + @Test( + "delegate decisions reject direct and inherited-child reentrancy", + arguments: [ReentryMode.direct, .child] + ) + func decisionReentrancy(mode: ReentryMode) async throws { + let current = EntitlementValueSource([]) + let fixture = TestSourceFixture( + currentEntitlements: { await current.read() } + ) + let holder = TransactionStoreHolder() + let delegate = ReentrantDecisionDelegate(holder: holder, mode: mode) + let snapshot = makeSubscriptionSnapshot( + id: mode == .direct ? 30 : 31, + productID: .tier1Monthly + ) + let store = TransactionStore( + source: fixture.source, + subscriptionCatalog: testSubscriptionCatalog, + delegate: delegate + ) + holder.set(store) + try await store.waitForInitialReadiness() + await current.replace(with: [snapshot]) + + _ = try await store.process(.verified(makeEnvelope(snapshot: snapshot))) + + #expect(await delegate.sawReentrantError()) + try await store.close() + } + + @MainActor + @Test("failure callbacks reject same-store reentrancy") + func failureCallbackReentrancy() async throws { + let query = ControlledEntitlementQuery() + let fixture = TestSourceFixture( + currentEntitlements: { try await query.next() } + ) + let holder = TransactionStoreHolder() + let delegate = ReentrantFailureDelegate(holder: holder) + let store = TransactionStore( + source: fixture.source, + subscriptionCatalog: testSubscriptionCatalog, + delegate: delegate + ) + holder.set(store) + + try await query.waitForRequest(1) + await query.fail(TestFailure()) + await #expect(throws: TestFailure.self) { + try await store.waitForInitialReadiness() + } + try await delegate.waitUntilCalled() + + #expect(await delegate.sawReentrantError()) + try await store.close() + } + + @MainActor + @Test("dropping an unclosed store cancels physical work before releasing its live lease") + func unclosedStoreCancellation() async throws { + let query = ControlledEntitlementQuery() + let fixture = TestSourceFixture( + currentEntitlements: { try await query.next() } + ) + let liveLease = LiveTransactionStoreLease.acquire() + var store: TransactionStore? = TransactionStore( + source: fixture.source, + lifecycle: TransactionStoreLifecycle(liveLease: liveLease), + subscriptionCatalog: testSubscriptionCatalog + ) + try await query.waitForRequest(1) + weak let weakStore = store + + store = nil + + #expect(weakStore == nil) + try await query.waitForCancellation() + await liveLease.waitUntilReleased() + let replacement = LiveTransactionStoreLease.acquire() + replacement.release() + } + + @Test("the live lease is process-wide and releases explicitly") + func liveLeaseAuthority() async { + await #expect(processExitsWith: .failure) { + let first = LiveTransactionStoreLease.acquire() + let second = LiveTransactionStoreLease.acquire() + _ = (first, second) + } + + let first = LiveTransactionStoreLease.acquire() + first.release() + let second = LiveTransactionStoreLease.acquire() + second.release() + } + + @Test("live-store exclusion crosses TransactionStore generic specializations") + func crossGenericLiveLease() async { + await #expect(processExitsWith: .failure) { + await MainActor.run { + let first = TransactionStore( + subscriptionCatalog: testSubscriptionCatalog + ) + let second = TransactionStore( + subscriptionCatalog: otherSubscriptionCatalog + ) + _ = (first, second) + } + } + } +} + +private enum OtherEntitlement: Hashable, Sendable { + case paid +} + +private enum OtherPlans: AutoRenewableSubscriptionGroup { + static let id = SubscriptionGroupID(rawValue: "other.subscription.group") + + enum ProductID: String, Hashable, Sendable { + case monthly = "other.subscription.monthly" + } + + static var subscriptions: StoreSubscriptions { + StoreSubscription(.monthly, entitlement: .paid) + } +} + +private let otherSubscriptionCatalog = + AutoRenewableSubscriptionCatalog(OtherPlans.self) + +enum ReentryMode: Sendable { + case direct + case child +} + +private struct SyntheticOperationError: Error, Sendable { + let operation: StoreTransactionOperation +} + +private struct TransactionDecisionFailure: Error, Sendable { + let id: UInt64 +} + +private actor FailingDecisionDelegate: TransactionStoreDelegate { + private var recorded: [StoreTransactionBackgroundFailure] = [] + private let signal = TestSignal() + + func decidePolicy( + for transaction: StoreTransactionSnapshot + ) async throws -> StoreTransactionHandlingPolicy { + throw TransactionDecisionFailure(id: transaction.id) + } + + func didFail(with failure: StoreTransactionBackgroundFailure) async { + recorded.append(failure) + await signal.send() + } + + func waitForFailures(_ count: Int) async throws { + try await signal.wait(for: count) + } + + func failures() -> [StoreTransactionBackgroundFailure] { + recorded + } +} + +private actor FailureRecordingDelegate: TransactionStoreDelegate { + private var recorded: [StoreTransactionBackgroundFailure] = [] + private let signal = TestSignal() + + func didFail(with failure: StoreTransactionBackgroundFailure) async { + recorded.append(failure) + await signal.send() + } + + func waitForFailures(_ count: Int) async throws { + try await signal.wait(for: count) + } + + func failures() -> [StoreTransactionBackgroundFailure] { + recorded + } +} + +private actor StateReadingFailureDelegate: TransactionStoreDelegate { + private let holder: TransactionStoreHolder + private let signal = TestSignal() + private var sawReady = false + + init(holder: TransactionStoreHolder) { + self.holder = holder + } + + func didFail(with failure: StoreTransactionBackgroundFailure) async { + sawReady = await MainActor.run { + guard case .ready = holder.get().entitlementStatus else { + return false + } + return true + } + await signal.send() + } + + func waitForFailure() async throws { + try await signal.wait(for: 1) + } + + func observedReadyState() -> Bool { + sawReady + } +} + +private actor GatedDecisionDelegate: TransactionStoreDelegate { + private let started = TestSignal() + private let gate = TestGate() + + func decidePolicy( + for transaction: StoreTransactionSnapshot + ) async throws -> StoreTransactionHandlingPolicy { + await started.send() + try await gate.wait() + return .automatic + } + + func waitUntilDecisionStarts() async throws { + try await started.wait(for: 1) + } + + func allowDecision() async { + await gate.open() + } +} + +private actor ReentrantDecisionDelegate: TransactionStoreDelegate { + private let holder: TransactionStoreHolder + private let mode: ReentryMode + private var rejected = false + + init( + holder: TransactionStoreHolder, + mode: ReentryMode + ) { + self.holder = holder + self.mode = mode + } + + func decidePolicy( + for transaction: StoreTransactionSnapshot + ) async throws -> StoreTransactionHandlingPolicy { + do { + switch mode { + case .direct: + _ = try await holder.get().refreshEntitlements() + case .child: + _ = try await Task { @MainActor in + try await holder.get().refreshEntitlements() + }.value + } + } catch StoreTransactionError.reentrantOperation( + operation: .refreshEntitlements + ) { + rejected = true + } + return .automatic + } + + func sawReentrantError() -> Bool { + rejected + } +} + +private actor ReentrantFailureDelegate: TransactionStoreDelegate { + private let holder: TransactionStoreHolder + private let called = TestSignal() + private var rejected = false + + init(holder: TransactionStoreHolder) { + self.holder = holder + } + + func didFail(with failure: StoreTransactionBackgroundFailure) async { + do { + _ = try await holder.get().refreshEntitlements() + } catch StoreTransactionError.reentrantOperation( + operation: .refreshEntitlements + ) { + rejected = true + } catch { + Issue.record("Unexpected reentrant failure: \(error)") + } + await called.send() + } + + func waitUntilCalled() async throws { + try await called.wait(for: 1) + } + + func sawReentrantError() -> Bool { + rejected + } +} diff --git a/Tests/StoreTransactionKitTests/StoreTests.swift b/Tests/StoreTransactionKitTests/StoreTests.swift deleted file mode 100644 index baaf199..0000000 --- a/Tests/StoreTransactionKitTests/StoreTests.swift +++ /dev/null @@ -1,388 +0,0 @@ -import Testing -@testable import StoreTransactionKit - -@Suite("Observable TransactionStore", .timeLimit(.minutes(1))) -@MainActor -struct StoreTests { - private enum SubscriptionID: String, Hashable, Sendable { - case monthly = "subscription.monthly" - case yearly = "subscription.yearly" - } - - @Test("a later startup failure is preserved after an earlier success") - func startupFailureAfterSuccess() { - var ordering = TransactionStoreStartupOrdering() - - let clearedBySuccess = ordering.recordSuccess(token: 1) - let recordedFailure = ordering.recordFailure(token: 2) - - #expect(!clearedBySuccess) - #expect(recordedFailure) - } - - @Test("a later success clears an earlier startup failure") - func successAfterStartupFailure() { - var ordering = TransactionStoreStartupOrdering() - - let recordedFailure = ordering.recordFailure(token: 1) - let clearedBySuccess = ordering.recordSuccess(token: 2) - - #expect(recordedFailure) - #expect(clearedBySuccess) - } - - @Test("only a success later than the failed readiness boundary recovers startup") - func sequencedStartupRecovery() { - var ordering = TransactionStoreStartupOrdering() - - let clearedBeforeFailure = ordering.recordSuccess(token: 1) - let recordedFailure = ordering.recordFailure(token: 2) - let clearedAfterFailure = ordering.recordSuccess(token: 3) - - #expect(!clearedBeforeFailure) - #expect(recordedFailure) - #expect(clearedAfterFailure) - } - - @Test("app-defined identifiers project current entitlements") - func typedEntitlementProjection() async throws { - let values = EntitlementValueSource([ - makeSnapshot(id: 1, productID: SubscriptionID.monthly.rawValue), - makeSnapshot(id: 2, productID: "nonconsumable.outside-enum"), - ]) - let fixture = TestSourceFixture( - currentEntitlements: { await values.read() } - ) - let store = TransactionStore( - source: fixture.source, - handleTransaction: { _ in }, - reportFailure: { _ in } - ) - - #expect(store.activeEntitlements == nil) - await store.waitForStartup() - - #expect(store.activeEntitlements == [.monthly]) - #expect( - store.entitlements?.transactions.map(\.productID) == [ - "nonconsumable.outside-enum", - SubscriptionID.monthly.rawValue, - ]) - - await values.replace(with: [ - makeSnapshot(id: 3, productID: SubscriptionID.yearly.rawValue) - ]) - _ = try await store.refreshEntitlements() - - #expect(store.activeEntitlements == [.yearly]) - #expect(store.startupError == nil) - try await store.close() - } - - @Test("an upgraded transaction remains in the snapshot without granting access") - func upgradedTransactionProjection() async throws { - let fixture = TestSourceFixture( - currentEntitlements: { - [ - makeSnapshot( - id: 1, - productID: SubscriptionID.monthly.rawValue, - isUpgraded: true - ), - makeSnapshot( - id: 2, - productID: SubscriptionID.yearly.rawValue - ), - ] - } - ) - let store = TransactionStore( - source: fixture.source, - handleTransaction: { _ in }, - reportFailure: { _ in } - ) - - await store.waitForStartup() - - #expect(store.activeEntitlements == [.yearly]) - #expect( - store.entitlements?.transactions.map(\.productID) == [ - SubscriptionID.monthly.rawValue, - SubscriptionID.yearly.rawValue, - ] - ) - try await store.close() - } - - @Test("a later refresh recovers observable state after startup failure") - func startupFailureRecovery() async throws { - let query = ControlledEntitlementQuery() - let fixture = TestSourceFixture( - currentEntitlements: { try await query.next() } - ) - let store = TransactionStore( - source: fixture.source, - handleTransaction: { _ in }, - reportFailure: { _ in } - ) - - #expect(store.activeEntitlements == nil) - try await query.waitForRequest(1) - await query.fail(TestFailure()) - await store.waitForStartup() - #expect(store.entitlements == nil) - #expect(store.activeEntitlements == nil) - #expect(store.startupError != nil) - - let refresh = Task { try await store.refreshEntitlements() } - try await query.waitForRequest(2) - await query.succeed([ - makeSnapshot(id: 4, productID: SubscriptionID.yearly.rawValue) - ]) - _ = try await refresh.value - - #expect(store.activeEntitlements == [.yearly]) - #expect(store.startupError == nil) - try await store.close() - } - - @Test("startup exposes the underlying unfinished handler failure") - func startupUnwrapsReportedHandlerFailure() async throws { - let snapshot = makeSnapshot( - id: 8, - productID: "consumable.startup", - productType: .consumable - ) - let fixture = TestSourceFixture( - queryUnfinished: { - [.verified(makeEnvelope(snapshot: snapshot))] - } - ) - let handlerCalls = TestSignal() - let reports = StringRecorder() - let store = TransactionStore( - source: fixture.source, - handleTransaction: { _ in - await handlerCalls.send() - if await handlerCalls.value() == 1 { - throw TestFailure() - } - }, - reportFailure: { failure in - if failure.source == .unfinished, - failure.transactionID == snapshot.id, - failure.underlyingError is TestFailure - { - await reports.append("unfinished-8") - } else { - await reports.append("unexpected") - } - } - ) - - await store.waitForStartup() - - #expect(store.startupError is TestFailure) - #expect(store.entitlements == nil) - #expect(await handlerCalls.value() == 1) - #expect(await reports.snapshot() == ["unfinished-8"]) - - let entitlements = try await store.refreshEntitlements() - - #expect(entitlements.transactions.isEmpty) - #expect(store.startupError == nil) - #expect(await handlerCalls.value() == 2) - #expect(await reports.snapshot() == ["unfinished-8"]) - try await store.close() - } - - @Test("a background refresh failure preserves resolved entitlement state") - func backgroundFailurePreservesResolvedEntitlements() async throws { - let snapshot = makeSnapshot( - id: 7, - productID: SubscriptionID.monthly.rawValue - ) - let query = ControlledEntitlementQuery() - let reports = StringRecorder() - let reported = TestSignal() - let fixture = TestSourceFixture( - currentEntitlements: { try await query.next() } - ) - let store = TransactionStore( - source: fixture.source, - handleTransaction: { _ in }, - reportFailure: { failure in - if failure.source == .entitlementRefresh, - failure.transactionID == snapshot.id, - failure.productID == snapshot.productID, - failure.underlyingError is TestFailure - { - await reports.append("entitlement-refresh-7") - } else { - await reports.append("unexpected") - } - await reported.send() - } - ) - - try await query.waitForRequest(1) - await query.succeed([snapshot]) - await store.waitForStartup() - - fixture.updates.yield( - .verified(makeEnvelope(snapshot: snapshot)) - ) - try await query.waitForRequest(2) - await query.fail(TestFailure()) - try await reported.wait(for: 1) - - #expect(store.activeEntitlements == [.monthly]) - #expect(store.startupError == nil) - #expect(await reports.snapshot() == ["entitlement-refresh-7"]) - try await store.close() - } - - @Test("a dependency cancellation failure remains a startup error") - func startupCancellationFailure() async throws { - let fixture = TestSourceFixture( - currentEntitlements: { throw CancellationError() } - ) - let store = TransactionStore( - source: fixture.source, - handleTransaction: { _ in }, - reportFailure: { _ in } - ) - - await store.waitForStartup() - - #expect(store.startupError is CancellationError) - #expect(store.activeEntitlements == nil) - try await store.close() - } - - @Test("an empty set means entitlement resolution completed") - func emptyTypedEntitlementProjection() async throws { - let fixture = TestSourceFixture(currentEntitlements: { [] }) - let store = TransactionStore( - source: fixture.source, - handleTransaction: { _ in }, - reportFailure: { _ in } - ) - - #expect(store.activeEntitlements == nil) - await store.waitForStartup() - #expect(store.activeEntitlements == Set()) - try await store.close() - } - - @Test("the TransactionStore facade rejects handler reentry during startup") - func startupHandlerReentrancy() async throws { - let holder = TransactionStoreHolder() - let rejected = TestSignal() - let finished = TestSignal() - let fixture = TestSourceFixture( - queryUnfinished: { - [ - .verified( - makeEnvelope(snapshot: makeSnapshot(id: 5)) { - await finished.send() - }) - ] - } - ) - - let store = TransactionStore( - source: fixture.source, - handleTransaction: { _ in - do { - _ = try await holder.get().refreshEntitlements() - Issue.record("TransactionStore unexpectedly allowed handler reentry.") - } catch StoreTransactionError.reentrantOperation( - operation: .refreshEntitlements - ) { - await rejected.send() - } catch { - Issue.record("Unexpected TransactionStore reentrancy error: \(error)") - } - }, - reportFailure: { _ in } - ) - holder.set(store) - - await store.waitForStartup() - - #expect(await rejected.value() == 1) - #expect(await finished.value() == 1) - try await store.close() - } - - @Test("a cancelled caller stops waiting without cancelling startup") - func startupWaitCancellation() async throws { - let query = ControlledEntitlementQuery() - let fixture = TestSourceFixture( - currentEntitlements: { try await query.next() } - ) - let store = TransactionStore( - source: fixture.source, - handleTransaction: { _ in }, - reportFailure: { _ in } - ) - try await query.waitForRequest(1) - - let refresh = Task { try await store.refreshEntitlements() } - refresh.cancel() - await #expect(throws: CancellationError.self) { - _ = try await refresh.value - } - #expect(await fixture.entitlementQueryCount.value() == 1) - - await query.succeed([]) - await store.waitForStartup() - try await store.close() - } - - @Test("a rejected reentrant close does not cancel startup") - func reentrantClosePreservesStartup() async throws { - let query = ControlledEntitlementQuery() - let fixture = TestSourceFixture( - currentEntitlements: { try await query.next() }, - queryUnfinished: { - [.verified(makeEnvelope(snapshot: makeSnapshot(id: 6)))] - } - ) - let holder = TransactionStoreHolder() - let closeRejected = TestSignal() - let startupCompleted = TestSignal() - - let store = TransactionStore( - source: fixture.source, - handleTransaction: { _ in - do { - try await holder.get().close() - Issue.record("TransactionStore unexpectedly allowed a reentrant close.") - } catch StoreTransactionError.reentrantOperation( - operation: .close - ) { - await closeRejected.send() - } catch { - Issue.record("Unexpected TransactionStore reentrancy error: \(error)") - } - }, - reportFailure: { _ in } - ) - holder.set(store) - let startupWaiter = Task { - await store.waitForStartup() - await startupCompleted.send() - } - - try await query.waitForRequest(1) - #expect(await startupCompleted.value() == 0) - try await closeRejected.wait(for: 1) - - await query.succeed([]) - await startupWaiter.value - #expect(await startupCompleted.value() == 1) - try await store.close() - } -} diff --git a/Tests/StoreTransactionKitTests/StoreTransactionSessionTests.swift b/Tests/StoreTransactionKitTests/StoreTransactionSessionTests.swift deleted file mode 100644 index afb56ff..0000000 --- a/Tests/StoreTransactionKitTests/StoreTransactionSessionTests.swift +++ /dev/null @@ -1,787 +0,0 @@ -import Foundation -import Testing -@testable import StoreTransactionKit - -@Suite("StoreTransactionSession", .timeLimit(.minutes(1))) -struct StoreTransactionSessionTests { - @Test("start publishes initial entitlements and close terminates producers") - func startAndClose() async throws { - let fixture = TestSourceFixture() - let publicationSizes = UInt64Recorder() - let session = StoreTransactionSession( - source: fixture.source, - handleTransaction: { _ in }, - entitlementsDidChange: { value in - await publicationSizes.append(UInt64(value.transactions.count)) - }, - reportFailure: { _ in } - ) - - let readiness = try await session.start() - #expect(readiness.entitlements.transactions.isEmpty) - #expect(await publicationSizes.snapshot() == [0]) - - try await session.close() - try await fixture.updateTermination.wait(for: 1) - try await fixture.subscriptionStatusTermination.wait(for: 1) - } - - @Test("initial entitlement publication joins unfinished processing") - func initialEntitlementsJoinUnfinishedProcessing() async throws { - let snapshot = makeSnapshot(id: 41, productID: "subscription.plus") - let handlerStarted = TestSignal() - let handlerGate = TestGate() - let events = StringRecorder() - let publications = UInt64Recorder() - let fixture = TestSourceFixture( - currentEntitlements: { [snapshot] }, - queryUnfinished: { - [ - .verified( - makeEnvelope(snapshot: snapshot) { - await events.append("finish-41") - }) - ] - } - ) - let session = StoreTransactionSession( - source: fixture.source, - handleTransaction: { transaction in - await events.append("handle-\(transaction.id)") - await handlerStarted.send() - try await handlerGate.wait() - }, - entitlementsDidChange: { value in - await publications.append(UInt64(value.transactions.count)) - }, - reportFailure: { failure in - Issue.record("Unexpected startup failure: \(failure)") - } - ) - - let startup = Task { try await session.start() } - try await handlerStarted.wait(for: 1) - #expect(await publications.snapshot().isEmpty) - - await handlerGate.open() - let readiness = try await startup.value - - #expect(readiness.entitlements.transactions == [snapshot]) - #expect(await events.snapshot() == ["handle-41", "finish-41"]) - #expect(await publications.snapshot() == [1]) - try await session.close() - } - - @Test("startup does not retry an update failure through unfinished reconciliation") - func startupSharesFailedUpdateWithUnfinishedReconciliation() async throws { - let snapshot = makeSnapshot( - id: 42, - productID: "consumable.startup", - productType: .consumable - ) - let envelope = makeEnvelope(snapshot: snapshot) - let handlerCalls = TestSignal() - let finishes = TestSignal() - let reported = TestSignal() - let reports = StringRecorder() - let unfinished = UnfinishedValueSource() - let unfinishedDelivery = StoreTransactionDelivery.verified( - makeEnvelope(snapshot: snapshot) { - await finishes.send() - await unfinished.replace(with: []) - } - ) - let fixture = TestSourceFixture( - currentEntitlements: { - try await reported.wait(for: 1) - return [] - }, - queryUnfinished: { await unfinished.read() } - ) - let session = StoreTransactionSession( - source: fixture.source, - handleTransaction: { _ in - await handlerCalls.send() - if await handlerCalls.value() == 1 { - throw TestFailure() - } - }, - entitlementsDidChange: { _ in }, - reportFailure: { failure in - if failure.source == .updates, - failure.transactionID == snapshot.id, - failure.underlyingError is TestFailure - { - await reports.append("updates-42") - } else { - await reports.append("unexpected") - } - await unfinished.replace(with: [unfinishedDelivery]) - await reported.send() - } - ) - - fixture.updates.yield(.verified(envelope)) - - await #expect(throws: TestFailure.self) { - _ = try await session.start() - } - #expect(await handlerCalls.value() == 1) - #expect(await finishes.value() == 0) - #expect(await reports.snapshot() == ["updates-42"]) - - _ = try await session.currentEntitlements() - - #expect(await handlerCalls.value() == 2) - #expect(await finishes.value() == 1) - #expect(await reports.snapshot() == ["updates-42"]) - try await session.close() - } - - @Test("cancelling the startup waiter does not open a retry boundary") - func cancelledStartupWaiterKeepsInitialAttempt() async throws { - let failedSnapshot = makeSnapshot( - id: 43, - productID: "consumable.cancelled-startup", - productType: .consumable - ) - let markerSnapshot = makeSnapshot(id: 44) - let query = ControlledEntitlementQuery() - let handled = UInt64Recorder() - let failedFinish = TestSignal() - let markerFinish = TestSignal() - let reported = TestSignal() - let reports = StringRecorder() - let unfinished = UnfinishedValueSource() - let failedDelivery = StoreTransactionDelivery.verified( - makeEnvelope(snapshot: failedSnapshot) { - await failedFinish.send() - await unfinished.replace(with: []) - } - ) - let fixture = TestSourceFixture( - currentEntitlements: { try await query.next() }, - queryUnfinished: { await unfinished.read() } - ) - let session = StoreTransactionSession( - source: fixture.source, - handleTransaction: { snapshot in - await handled.append(snapshot.id) - if snapshot.id == failedSnapshot.id { - throw TestFailure() - } - }, - entitlementsDidChange: { _ in }, - reportFailure: { failure in - if failure.source == .updates, - failure.transactionID == failedSnapshot.id, - failure.underlyingError is TestFailure - { - await reports.append("updates-43") - } else { - await reports.append("unexpected") - } - await reported.send() - } - ) - - let startup = Task { try await session.start() } - try await query.waitForRequest(1) - fixture.updates.yield( - .verified(makeEnvelope(snapshot: failedSnapshot)) - ) - try await reported.wait(for: 1) - - startup.cancel() - await #expect(throws: CancellationError.self) { - _ = try await startup.value - } - - fixture.updates.yield( - .verified(makeEnvelope(snapshot: failedSnapshot)) - ) - fixture.updates.yield( - .verified( - makeEnvelope(snapshot: markerSnapshot) { - await markerFinish.send() - }) - ) - try await markerFinish.wait(for: 1) - - #expect(await handled.snapshot() == [43, 44]) - #expect(await failedFinish.value() == 0) - #expect(await reports.snapshot() == ["updates-43"]) - - await unfinished.replace(with: [failedDelivery]) - await query.succeed([]) - try await session.close() - - #expect(await handled.snapshot() == [43, 44]) - #expect(await failedFinish.value() == 0) - #expect(await reports.snapshot() == ["updates-43"]) - } - - @Test("subscription status waits for initial entitlement readiness") - func subscriptionStatusWaitsForReadiness() async throws { - let query = ControlledEntitlementQuery() - let fixture = TestSourceFixture( - currentEntitlements: { try await query.next() } - ) - let session = StoreTransactionSession( - source: fixture.source, - handleTransaction: { _ in }, - entitlementsDidChange: { _ in }, - reportFailure: { failure in - Issue.record("Unexpected status failure: \(failure)") - } - ) - - let startup = Task { try await session.start() } - try await query.waitForRequest(1) - fixture.subscriptionStatusUpdates.yield() - try await fixture.subscriptionStatusDeliveryCount.wait(for: 1) - #expect(await fixture.entitlementQueryCount.value() == 1) - - await query.succeed([]) - _ = try await startup.value - try await query.waitForRequest(2) - await query.succeed([]) - - try await session.close() - } - - @Test("close cancels a status waiter but drains startup readiness") - func closeDuringSubscriptionStatusReadiness() async throws { - let query = ControlledEntitlementQuery() - let fixture = TestSourceFixture( - currentEntitlements: { try await query.next() } - ) - let closeCompleted = TestSignal() - let session = StoreTransactionSession( - source: fixture.source, - handleTransaction: { _ in }, - entitlementsDidChange: { _ in }, - reportFailure: { _ in } - ) - - let startup = Task { try await session.start() } - try await query.waitForRequest(1) - fixture.subscriptionStatusUpdates.yield() - try await fixture.subscriptionStatusDeliveryCount.wait(for: 1) - - let close = Task { - try await session.close() - await closeCompleted.send() - } - try await fixture.subscriptionStatusTermination.wait(for: 1) - #expect(await closeCompleted.value() == 0) - - await query.succeed([]) - do { - _ = try await startup.value - Issue.record("Startup unexpectedly completed after close began.") - } catch StoreTransactionError.closing { - // Closing owns the runtime after readiness drains. - } - try await close.value - #expect(await closeCompleted.value() == 1) - } - - @Test("known subscription status changes refresh without replaying handling") - func knownSubscriptionStatusReconciliation() async throws { - let snapshot = makeSnapshot(id: 1, productID: "subscription.plus") - let values = EntitlementValueSource([snapshot]) - let fixture = TestSourceFixture( - currentEntitlements: { await values.read() } - ) - let handlerCalls = TestSignal() - let publications = UInt64Recorder() - let published = TestSignal() - let session = StoreTransactionSession( - source: fixture.source, - handleTransaction: { _ in - await handlerCalls.send() - }, - entitlementsDidChange: { value in - await publications.append(UInt64(value.transactions.count)) - await published.send() - }, - reportFailure: { failure in - Issue.record("Unexpected background failure: \(failure)") - } - ) - - _ = try await session.start() - await values.replace(with: []) - - fixture.subscriptionStatusUpdates.yield() - try await published.wait(for: 2) - - #expect(await handlerCalls.value() == 0) - #expect(await fixture.entitlementQueryCount.value() == 2) - #expect(await publications.snapshot() == [1, 0]) - try await session.close() - } - - @Test("a new subscription status is handled and finished before publication") - func newSubscriptionStatusUsesTransactionPipeline() async throws { - let snapshot = makeSnapshot(id: 2, productID: "subscription.pro") - let values = EntitlementValueSource([]) - let unfinished = UnfinishedValueSource() - let fixture = TestSourceFixture( - currentEntitlements: { await values.read() }, - queryUnfinished: { await unfinished.read() } - ) - let handlerStarted = TestSignal() - let handlerGate = TestGate() - let events = StringRecorder() - let publications = UInt64Recorder() - let published = TestSignal() - let session = StoreTransactionSession( - source: fixture.source, - handleTransaction: { snapshot in - await events.append("handle-\(snapshot.id)") - await handlerStarted.send() - try await handlerGate.wait() - }, - entitlementsDidChange: { value in - await publications.append(UInt64(value.transactions.count)) - await published.send() - }, - reportFailure: { failure in - Issue.record("Unexpected background failure: \(failure)") - } - ) - - _ = try await session.start() - await values.replace(with: [snapshot]) - await unfinished.replace( - with: [ - .verified( - makeEnvelope(snapshot: snapshot) { - await events.append("finish-2") - }) - ] - ) - fixture.subscriptionStatusUpdates.yield() - - try await handlerStarted.wait(for: 1) - #expect(await publications.snapshot() == [0]) - #expect(await fixture.entitlementQueryCount.value() == 1) - - await handlerGate.open() - try await published.wait(for: 2) - #expect(await events.snapshot() == ["handle-2", "finish-2"]) - #expect(await fixture.entitlementQueryCount.value() == 2) - #expect(await publications.snapshot() == [0, 1]) - try await session.close() - } - - @Test("an unfinished consumable failure blocks publication and reports once") - func unfinishedConsumableFailureBlocksPublication() async throws { - let active = makeSnapshot( - id: 3, - productID: "subscription.plus", - productType: .autoRenewable - ) - let consumable = makeSnapshot( - id: 4, - productID: "consumable.tokens", - productType: .consumable - ) - let values = EntitlementValueSource([active]) - let unfinished = UnfinishedValueSource() - let fixture = TestSourceFixture( - currentEntitlements: { await values.read() }, - queryUnfinished: { await unfinished.read() } - ) - let handlerCalls = TestSignal() - let finishes = TestSignal() - let publications = UInt64Recorder() - let published = TestSignal() - let reports = StringRecorder() - let reported = TestSignal() - let session = StoreTransactionSession( - source: fixture.source, - handleTransaction: { snapshot in - #expect(snapshot.id == consumable.id) - await handlerCalls.send() - if await handlerCalls.value() == 1 { - throw TestFailure() - } - }, - entitlementsDidChange: { value in - await publications.append(UInt64(value.transactions.count)) - await published.send() - }, - reportFailure: { failure in - if failure.source == .unfinished, - failure.transactionID == consumable.id, - failure.productID == consumable.productID, - failure.underlyingError is TestFailure - { - await reports.append("unfinished-4") - } else { - await reports.append("unexpected") - } - await reported.send() - } - ) - - _ = try await session.start() - await values.replace(with: []) - await unfinished.replace( - with: [ - .verified( - makeEnvelope(snapshot: consumable) { - await finishes.send() - await unfinished.replace(with: []) - }) - ] - ) - - fixture.subscriptionStatusUpdates.yield() - try await reported.wait(for: 1) - - #expect(await handlerCalls.value() == 1) - #expect(await finishes.value() == 0) - #expect(await publications.snapshot() == [1]) - - fixture.subscriptionStatusUpdates.yield() - try await published.wait(for: 2) - - #expect(await handlerCalls.value() == 2) - #expect(await finishes.value() == 1) - #expect(await publications.snapshot() == [1, 0]) - #expect(await reports.snapshot() == ["unfinished-4"]) - try await session.close() - } - - @Test("revocation handling finishes before entitlement removal is published") - func revocationPrecedesRemovalPublication() async throws { - let active = makeSnapshot( - id: 31, - productID: "subscription.plus", - productType: .autoRenewable, - jws: "active-31" - ) - let revoked = makeSnapshot( - id: 31, - productID: "subscription.plus", - productType: .autoRenewable, - signedDate: Date(timeIntervalSince1970: 100), - jws: "revoked-31", - revocationDate: Date(timeIntervalSince1970: 99) - ) - let values = EntitlementValueSource([active]) - let unfinished = UnfinishedValueSource() - let fixture = TestSourceFixture( - currentEntitlements: { await values.read() }, - queryUnfinished: { await unfinished.read() } - ) - let handlerStarted = TestSignal() - let handlerGate = TestGate() - let events = StringRecorder() - let publications = UInt64Recorder() - let published = TestSignal() - let session = StoreTransactionSession( - source: fixture.source, - handleTransaction: { snapshot in - #expect(snapshot.revocationDate != nil) - await events.append("handle-\(snapshot.id)") - await handlerStarted.send() - try await handlerGate.wait() - }, - entitlementsDidChange: { value in - await publications.append(UInt64(value.transactions.count)) - await published.send() - }, - reportFailure: { failure in - Issue.record("Unexpected revocation failure: \(failure)") - } - ) - - _ = try await session.start() - await values.replace(with: []) - await unfinished.replace( - with: [ - .verified( - makeEnvelope(snapshot: revoked) { - await events.append("finish-31") - }) - ] - ) - fixture.subscriptionStatusUpdates.yield() - - try await handlerStarted.wait(for: 1) - #expect(await publications.snapshot() == [1]) - - await handlerGate.open() - try await published.wait(for: 2) - #expect(await events.snapshot() == ["handle-31", "finish-31"]) - #expect(await publications.snapshot() == [1, 0]) - try await session.close() - } - - @Test("reconciliation drains and reports every accepted handler failure") - func reconciliationDrainsAllAcceptedFailures() async throws { - let unfinished = UnfinishedValueSource([ - .verified( - makeEnvelope( - snapshot: makeSnapshot( - id: 32, - productID: "subscription.plus", - productType: .autoRenewable - ) - ) - ), - .verified( - makeEnvelope( - snapshot: makeSnapshot( - id: 33, - productID: "lifetime", - productType: .nonConsumable - ) - ) - ), - ]) - let fixture = TestSourceFixture( - queryUnfinished: { await unfinished.read() } - ) - let handlerCalls = TestSignal() - let reports = UInt64Recorder() - let reported = TestSignal() - let session = StoreTransactionSession( - source: fixture.source, - handleTransaction: { _ in - await handlerCalls.send() - throw TestFailure() - }, - reportFailure: { failure in - if failure.source == .unfinished, - let transactionID = failure.transactionID - { - await reports.append(transactionID) - await reported.send() - } - } - ) - - await #expect(throws: TestFailure.self) { - _ = try await session.start() - } - try await reported.wait(for: 2) - - #expect(await handlerCalls.value() == 2) - #expect(await reports.snapshot() == [32, 33]) - try await session.close() - } - - @Test("unverified current elements are reported without hiding verified entitlements") - func mixedCurrentEntitlementVerification() async throws { - let snapshot = makeSnapshot(id: 5, productID: "subscription.plus") - let verificationFailure = StoreTransactionVerificationError( - underlyingError: TestFailure() - ) - let fixture = TestSourceFixture( - currentEntitlements: { [snapshot] }, - currentEntitlementVerificationFailures: { - [verificationFailure] - } - ) - let reported = TestSignal() - let session = StoreTransactionSession( - source: fixture.source, - handleTransaction: { _ in }, - entitlementsDidChange: { _ in }, - reportFailure: { failure in - if case .currentEntitlementVerification = failure.source { - await reported.send() - } - } - ) - - let readiness = try await session.start() - - #expect(readiness.entitlements.transactions == [snapshot]) - try await reported.wait(for: 1) - try await session.close() - } - - @Test("updates use the durable handler then finish and refresh") - func updateProcessing() async throws { - let fixture = TestSourceFixture() - let events = StringRecorder() - let finished = TestSignal() - let session = StoreTransactionSession( - source: fixture.source, - handleTransaction: { snapshot in - await events.append("handle-\(snapshot.id)") - }, - entitlementsDidChange: { _ in }, - reportFailure: { failure in - Issue.record("Unexpected background failure: \(failure)") - } - ) - _ = try await session.start() - - let snapshot = makeSnapshot(id: 10) - fixture.updates.yield( - .verified( - makeEnvelope(snapshot: snapshot) { - await events.append("finish-10") - await finished.send() - })) - try await finished.wait(for: 1) - - #expect(await events.snapshot() == ["handle-10", "finish-10"]) - try await fixture.entitlementQueryCount.wait(for: 2) - try await session.close() - } - - @Test("background handler failures are reported and never finished") - func backgroundFailure() async throws { - let fixture = TestSourceFixture() - let reported = TestSignal() - let finished = TestSignal() - let session = StoreTransactionSession( - source: fixture.source, - handleTransaction: { _ in throw TestFailure() }, - entitlementsDidChange: { _ in }, - reportFailure: { failure in - if failure.transactionID == 11 { - await reported.send() - } - } - ) - _ = try await session.start() - fixture.updates.yield( - .verified( - makeEnvelope( - snapshot: makeSnapshot(id: 11) - ) { - await finished.send() - })) - - try await reported.wait(for: 1) - #expect(await finished.value() == 0) - try await session.close() - } - - @Test("close before start is idempotent and later operations are rejected") - func closeBeforeStart() async throws { - let fixture = TestSourceFixture() - let session = StoreTransactionSession( - source: fixture.source, - handleTransaction: { _ in }, - reportFailure: { _ in } - ) - - try await session.close() - try await session.close() - await #expect(throws: StoreTransactionError.self) { - _ = try await session.start() - } - } - - @Test("callbacks reject reentry into their own session") - func callbackReentrancy() async throws { - let fixture = TestSourceFixture() - let holder = SessionHolder() - let observations = StringRecorder() - let finished = TestSignal() - let failureReported = TestSignal() - let session = StoreTransactionSession( - source: fixture.source, - handleTransaction: { _ in - do { - try await holder.get().close() - await observations.append("handler-unexpected-success") - } catch StoreTransactionError.reentrantOperation( - operation: .close - ) { - await observations.append("handler-rejected") - } catch { - Issue.record("Unexpected handler reentrancy error: \(error)") - } - }, - entitlementsDidChange: { _ in - do { - _ = try await holder.get().currentEntitlements() - await observations.append("entitlements-unexpected-success") - } catch StoreTransactionError.reentrantOperation( - operation: .refreshEntitlements - ) { - await observations.append("entitlements-rejected") - } catch { - Issue.record("Unexpected entitlement reentrancy error: \(error)") - } - }, - reportFailure: { _ in - do { - _ = try await holder.get().history(for: "product") - await observations.append("reporter-unexpected-success") - } catch StoreTransactionError.reentrantOperation( - operation: .history - ) { - await observations.append("reporter-rejected") - } catch { - Issue.record("Unexpected reporter reentrancy error: \(error)") - } - await failureReported.send() - } - ) - holder.set(session) - - _ = try await session.start() - fixture.updates.yield( - .verified( - makeEnvelope(snapshot: makeSnapshot(id: 12)) { - await finished.send() - })) - try await finished.wait(for: 1) - fixture.updates.yield( - .unverified( - revision: Data("update-verification-failure".utf8), - error: TestFailure() - )) - try await failureReported.wait(for: 1) - - #expect( - await observations.snapshot() == [ - "entitlements-rejected", - "handler-rejected", - "reporter-rejected", - ]) - try await session.close() - } - - @Test("a callback may operate on a different session") - func callbackMayUseAnotherSession() async throws { - let otherFixture = TestSourceFixture() - let otherSession = StoreTransactionSession( - source: otherFixture.source, - handleTransaction: { _ in }, - reportFailure: { _ in } - ) - let fixture = TestSourceFixture() - let callbackCompleted = TestSignal() - let session = StoreTransactionSession( - source: fixture.source, - handleTransaction: { _ in }, - entitlementsDidChange: { _ in - do { - try await otherSession.close() - await callbackCompleted.send() - } catch { - Issue.record("A different session rejected the callback: \(error)") - } - }, - reportFailure: { _ in } - ) - - _ = try await session.start() - try await callbackCompleted.wait(for: 1) - try await session.close() - } -} diff --git a/Tests/StoreTransactionKitTests/TestSupport.swift b/Tests/StoreTransactionKitTests/TestSupport.swift index db83e82..9005826 100644 --- a/Tests/StoreTransactionKitTests/TestSupport.swift +++ b/Tests/StoreTransactionKitTests/TestSupport.swift @@ -115,41 +115,18 @@ actor UInt64Recorder { } } -final class SessionHolder: Sendable { - private let storage = Mutex(nil) +final class TransactionStoreHolder: Sendable +where Entitlement: Hashable & Sendable { + private let storage = Mutex?>(nil) - func set(_ session: StoreTransactionSession) { - storage.withLock { value in - precondition(value == nil) - value = session - } - } - - func get() -> StoreTransactionSession { - storage.withLock { value in - guard let value else { - preconditionFailure("SessionHolder was read before initialization.") - } - return value - } - } -} - -final class TransactionStoreHolder: Sendable -where - EntitlementID: RawRepresentable & Hashable & Sendable, - EntitlementID.RawValue == String -{ - private let storage = Mutex?>(nil) - - func set(_ store: TransactionStore) { + func set(_ store: TransactionStore) { storage.withLock { value in precondition(value == nil) value = store } } - func get() -> TransactionStore { + func get() -> TransactionStore { storage.withLock { value in guard let value else { preconditionFailure("TransactionStoreHolder was read before initialization.") @@ -167,6 +144,7 @@ actor ControlledEntitlementQuery { private var requests: [Request] = [] private let started = TestSignal() + private let cancelled = TestSignal() func next() async throws -> [StoreTransactionSnapshot] { try Task.checkCancellation() @@ -185,6 +163,10 @@ actor ControlledEntitlementQuery { try await started.wait(for: count) } + func waitForCancellation(_ count: Int = 1) async throws { + try await cancelled.wait(for: count) + } + func succeed(_ snapshots: [StoreTransactionSnapshot]) { precondition(!requests.isEmpty) requests.removeFirst().continuation.resume(returning: snapshots) @@ -202,6 +184,7 @@ actor ControlledEntitlementQuery { requests.remove(at: index).continuation.resume( throwing: CancellationError() ) + Task { await cancelled.send() } } } @@ -241,6 +224,7 @@ func makeSnapshot( id: UInt64, productID: String = "product", productType: Product.ProductType = .consumable, + subscriptionGroupID: String? = nil, purchaseDate: Date? = nil, signedDate: Date? = nil, jws: String? = nil, @@ -252,7 +236,7 @@ func makeSnapshot( id: id, originalID: id, productID: productID, - subscriptionGroupID: nil, + subscriptionGroupID: subscriptionGroupID, productType: productType, environment: .xcode, offer: nil, @@ -289,6 +273,46 @@ func makeEnvelope( struct TestFailure: Error, Sendable, Equatable {} +enum TestEntitlement: Hashable, Sendable { + case tier1 + case tier2 +} + +enum TestPlans: AutoRenewableSubscriptionGroup { + static let id = SubscriptionGroupID(rawValue: "test.subscription.group") + + enum ProductID: String, Hashable, Sendable { + case tier1Monthly = "test.subscription.tier1.monthly" + case tier1Yearly = "test.subscription.tier1.yearly" + case tier2Monthly = "test.subscription.tier2.monthly" + } + + static var subscriptions: StoreSubscriptions { + StoreSubscription(.tier1Monthly, entitlement: .tier1) + StoreSubscription(.tier1Yearly, entitlement: .tier1) + StoreSubscription(.tier2Monthly, entitlement: .tier2) + } +} + +let testSubscriptionCatalog: AutoRenewableSubscriptionCatalog = + AutoRenewableSubscriptionCatalog(TestPlans.self) + +func makeSubscriptionSnapshot( + id: UInt64, + productID: TestPlans.ProductID, + isUpgraded: Bool = false, + revision: String? = nil +) -> StoreTransactionSnapshot { + makeSnapshot( + id: id, + productID: productID.rawValue, + productType: .autoRenewable, + subscriptionGroupID: TestPlans.id.rawValue, + jws: revision, + isUpgraded: isUpgraded + ) +} + struct TestSourceFixture: Sendable { let source: StoreTransactionSource let updates: AsyncStream.Continuation @@ -332,13 +356,19 @@ struct TestSourceFixture: Sendable { self.subscriptionStatusTermination = subscriptionStatusTermination self.entitlementQueryCount = entitlementQueryCount self.source = StoreTransactionSource( - runUpdates: { consume in - for await delivery in updatePair.stream { + runUpdates: { beginIteration, consume in + var iterator = updatePair.stream.makeAsyncIterator() + while let lease = beginIteration() { + defer { lease.end() } + guard let delivery = await iterator.next() else { return } await consume(delivery) } }, - runSubscriptionStatusUpdates: { consume in - for await _ in subscriptionStatusPair.stream { + runSubscriptionStatusUpdates: { beginIteration, consume in + var iterator = subscriptionStatusPair.stream.makeAsyncIterator() + while let lease = beginIteration() { + defer { lease.end() } + guard await iterator.next() != nil else { return } await subscriptionStatusDeliveryCount.send() await consume() } diff --git a/Tests/StoreTransactionKitTests/TransactionProcessingCoreTests.swift b/Tests/StoreTransactionKitTests/TransactionProcessingCoreTests.swift index e0cbeda..6ea0e86 100644 --- a/Tests/StoreTransactionKitTests/TransactionProcessingCoreTests.swift +++ b/Tests/StoreTransactionKitTests/TransactionProcessingCoreTests.swift @@ -16,16 +16,20 @@ struct TransactionProcessingCoreTests { await events.append("handle-end") } let snapshot = makeSnapshot(id: 1) - let receipt = await core.accept( + let acceptance = await core.accept( makeEnvelope(snapshot: snapshot) { await events.append("finish") } - ).receipt + ) + let claim = try #require( + await acceptance.claimCausalResolutionIfOwner() + ) try await handlerStarted.wait(for: 1) #expect(await events.snapshot() == ["handle-start"]) await gate.open() - _ = try await receipt.terminalValue() + _ = try await acceptance.receipt.terminalValue() + await claim.succeed() #expect( await events.snapshot() == [ "handle-start", "handle-end", "finish", @@ -33,6 +37,29 @@ struct TransactionProcessingCoreTests { await core.finishInputAndDrain() } + @Test("claiming causal resolution during a suspended handler preserves the claim") + func claimDuringSuspendedHandler() async throws { + let gate = TestGate() + let started = TestSignal() + let core = TransactionProcessingCore { _ in + await started.send() + try await gate.wait() + } + let acceptance = await core.accept( + makeEnvelope(snapshot: makeSnapshot(id: 101)) + ) + try await started.wait(for: 1) + + let claim = try #require( + await acceptance.claimCausalResolutionIfOwner() + ) + await gate.open() + _ = try await acceptance.receipt.terminalValue() + await claim.succeed() + _ = try await acceptance.causalReceipt.terminalValue() + await core.finishInputAndDrain() + } + @Test("handler failure leaves the revision retryable and unfinished") func failedHandlerIsRetryable() async throws { let attempts = TestSignal() @@ -49,15 +76,23 @@ struct TransactionProcessingCoreTests { } let first = await core.accept(envelope) + let firstClaim = try #require( + await first.claimCausalResolutionIfOwner() + ) await #expect(throws: TestFailure.self) { _ = try await first.receipt.terminalValue() } + await firstClaim.fail(TestFailure()) #expect(await finishes.value() == 0) await core.completeInitialAttempt() #expect(await core.beginTransactionAttempt()) let second = await core.accept(envelope) + let secondClaim = try #require( + await second.claimCausalResolutionIfOwner() + ) _ = try await second.receipt.terminalValue() + await secondClaim.succeed() #expect(first.role == .owner) #expect(second.role == .owner) #expect(await attempts.value() == 2) @@ -65,6 +100,36 @@ struct TransactionProcessingCoreTests { await core.finishInputAndDrain() } + @Test("a duplicate joins while decision failure awaits causal state commit") + func duplicateDuringDecisionFailureCommitJoins() async throws { + let attempts = TestSignal() + let core = TransactionProcessingCore { _ in + await attempts.send() + throw TestFailure() + } + let snapshot = makeSnapshot(id: 22) + let envelope = makeEnvelope(snapshot: snapshot, revision: "failed-active") + + let first = await core.accept(envelope) + let claim = try #require( + await first.claimCausalResolutionIfOwner() + ) + await #expect(throws: TestFailure.self) { + _ = try await first.receipt.terminalValue() + } + + let duplicate = await core.accept(envelope) + #expect(duplicate.role == .inFlightObserver) + #expect(first.causalReceipt === duplicate.causalReceipt) + #expect(await attempts.value() == 1) + + await claim.fail(TestFailure()) + await #expect(throws: TestFailure.self) { + _ = try await duplicate.causalReceipt.terminalValue() + } + await core.finishInputAndDrain() + } + @Test("equal revisions join in flight and completed revisions are suppressed") func equalRevisionCoalescing() async throws { let handlerGate = TestGate() @@ -85,6 +150,9 @@ struct TransactionProcessingCoreTests { ) { await firstFinish.send() }) + let firstClaim = try #require( + await first.claimCausalResolutionIfOwner() + ) try await handlerStarted.wait(for: 1) let duplicate = await core.accept( makeEnvelope( @@ -97,6 +165,8 @@ struct TransactionProcessingCoreTests { await handlerGate.open() _ = try await first.receipt.terminalValue() _ = try await duplicate.receipt.terminalValue() + await firstClaim.succeed() + _ = try await duplicate.causalReceipt.terminalValue() let completed = await core.accept( makeEnvelope( snapshot: snapshot, @@ -133,15 +203,23 @@ struct TransactionProcessingCoreTests { makeEnvelope(snapshot: makeSnapshot(id: 1)) { await events.append("finish-1") }) + let firstClaim = try #require( + await first.claimCausalResolutionIfOwner() + ) try await firstStarted.wait(for: 1) let second = await core.accept( makeEnvelope(snapshot: makeSnapshot(id: 2)) { await events.append("finish-2") }) + let secondClaim = try #require( + await second.claimCausalResolutionIfOwner() + ) await firstGate.open() _ = try await first.receipt.terminalValue() _ = try await second.receipt.terminalValue() + await firstClaim.succeed() + await secondClaim.succeed() #expect( await events.snapshot() == [ "handle-1", "finish-1", "handle-2", "finish-2", diff --git a/Tests/StoreTransactionKitTests/TransactionStoreTests.swift b/Tests/StoreTransactionKitTests/TransactionStoreTests.swift new file mode 100644 index 0000000..bc61336 --- /dev/null +++ b/Tests/StoreTransactionKitTests/TransactionStoreTests.swift @@ -0,0 +1,554 @@ +import StoreKit +import Testing +@testable import StoreTransactionKit + +@Suite("TransactionStore", .timeLimit(.minutes(1))) +struct TransactionStoreTests { + @MainActor + @Test("override mode publishes an authoritative typed set without raw state") + func overrideState() async throws { + let store = TransactionStore( + subscriptionCatalog: testSubscriptionCatalog, + overridingEntitlements: [ + TestEntitlement.tier1, + .tier1, + ] + ) + + guard case .overridden = store.entitlementStatus else { + Issue.record("Expected override availability.") + return + } + #expect(store.entitlements == nil) + #expect(store.activeEntitlements == [.tier1]) + #expect(store.isEntitled(to: .tier1)) + #expect(!store.isEntitled(to: .tier2)) + + do { + _ = try await store.refreshEntitlements() + Issue.record("Override mode unexpectedly refreshed StoreKit.") + } catch StoreTransactionError.operationUnavailableInOverride( + operation: .refreshEntitlements + ) {} + + try await store.close() + try await store.close() + } + + @MainActor + @Test("initial publication commits raw and typed entitlement state together") + func initialPublication() async throws { + let snapshot = makeSubscriptionSnapshot( + id: 1, + productID: .tier1Monthly + ) + let fixture = TestSourceFixture(currentEntitlements: { [snapshot] }) + let store = TransactionStore( + source: fixture.source, + subscriptionCatalog: testSubscriptionCatalog + ) + + try await store.waitForInitialReadiness() + + guard case .ready = store.entitlementStatus else { + Issue.record("Expected ready availability.") + try await store.close() + return + } + #expect(store.entitlements?.transactions == [snapshot]) + #expect(store.activeEntitlements == [.tier1]) + try await store.close() + } + + @MainActor + @Test("a successful direct delivery decides, finishes, refreshes, and publishes") + func directDelivery() async throws { + let current = EntitlementValueSource([]) + let fixture = TestSourceFixture( + currentEntitlements: { await current.read() } + ) + let finishes = TestSignal() + let snapshot = makeSubscriptionSnapshot( + id: 2, + productID: .tier1Yearly + ) + let store = TransactionStore( + source: fixture.source, + subscriptionCatalog: testSubscriptionCatalog + ) + try await store.waitForInitialReadiness() + await current.replace(with: [snapshot]) + + let outcome = try await store.process( + .verified( + makeEnvelope(snapshot: snapshot, revision: "direct-2") { + await finishes.send() + } + ) + ) + + #expect(outcome == .completed(snapshot)) + #expect(await finishes.value() == 1) + #expect(store.entitlements?.transactions == [snapshot]) + #expect(store.activeEntitlements == [.tier1]) + try await store.close() + } + + @MainActor + @Test("transient refresh failure preserves a prior ready snapshot") + func transientFailurePreservesReadyState() async throws { + let query = ControlledEntitlementQuery() + let fixture = TestSourceFixture( + currentEntitlements: { try await query.next() } + ) + let snapshot = makeSubscriptionSnapshot( + id: 3, + productID: .tier2Monthly + ) + let store = TransactionStore( + source: fixture.source, + subscriptionCatalog: testSubscriptionCatalog + ) + + try await query.waitForRequest(1) + await query.succeed([snapshot]) + try await store.waitForInitialReadiness() + + let refresh = Task { @MainActor in + try await store.refreshEntitlements() + } + try await query.waitForRequest(2) + await query.fail(TestFailure()) + await #expect(throws: TestFailure.self) { + _ = try await refresh.value + } + + guard case .ready = store.entitlementStatus else { + Issue.record("A transient failure discarded ready state.") + try await store.close() + return + } + #expect(store.entitlements?.transactions == [snapshot]) + #expect(store.activeEntitlements == [.tier2]) + try await store.close() + } + + @MainActor + @Test("catalog contradiction fails closed and clears both projections") + func catalogFailureClearsState() async throws { + let current = EntitlementValueSource([ + makeSubscriptionSnapshot(id: 4, productID: .tier1Monthly) + ]) + let fixture = TestSourceFixture( + currentEntitlements: { await current.read() } + ) + let store = TransactionStore( + source: fixture.source, + subscriptionCatalog: testSubscriptionCatalog + ) + try await store.waitForInitialReadiness() + + let undeclared = makeSnapshot( + id: 5, + productID: "test.subscription.retired.current", + productType: .autoRenewable, + subscriptionGroupID: TestPlans.id.rawValue + ) + await current.replace(with: [undeclared]) + + await #expect(throws: AutoRenewableSubscriptionCatalogError.self) { + _ = try await store.refreshEntitlements() + } + guard case .failed(let error) = store.entitlementStatus else { + Issue.record("Expected catalog failure availability.") + try await store.close() + return + } + #expect(error is AutoRenewableSubscriptionCatalogError) + #expect(store.entitlements == nil) + #expect(store.activeEntitlements == nil) + try await store.close() + } + + @MainActor + @Test("post-finish failure retries only the causal refresh on redelivery") + func postFinishRedeliveryRetriesRefreshOnly() async throws { + let query = ControlledEntitlementQuery() + let fixture = TestSourceFixture( + currentEntitlements: { try await query.next() } + ) + let delegate = CountingDelegate(policy: .automatic) + let finishes = TestSignal() + let snapshot = makeSubscriptionSnapshot( + id: 6, + productID: .tier1Monthly, + revision: "post-finish" + ) + let delivery = StoreTransactionDelivery.verified( + makeEnvelope(snapshot: snapshot, revision: "post-finish") { + await finishes.send() + } + ) + let store = TransactionStore( + source: fixture.source, + subscriptionCatalog: testSubscriptionCatalog, + delegate: delegate + ) + + try await query.waitForRequest(1) + await query.succeed([]) + try await store.waitForInitialReadiness() + + let first = Task { @MainActor in + try await store.process(delivery) + } + try await query.waitForRequest(2) + await query.fail(TestFailure()) + do { + _ = try await first.value + Issue.record("Expected post-finish refresh failure.") + } catch StoreTransactionError.entitlementRefreshFailed( + after: .finishedTransaction(let completed), + underlyingError: let error + ) { + #expect(completed == snapshot) + #expect(error is TestFailure) + } + + let second = Task { @MainActor in + try await store.process(delivery) + } + try await query.waitForRequest(3) + await query.succeed([snapshot]) + #expect(try await second.value == .completed(snapshot)) + + #expect(await delegate.decisionCount() == 1) + #expect(await finishes.value() == 1) + #expect(store.activeEntitlements == [.tier1]) + try await store.close() + } + + @MainActor + @Test("a satisfied exact revision suppresses policy, finish, and refresh") + func satisfiedDuplicateIsFullySuppressed() async throws { + let current = EntitlementValueSource([]) + let fixture = TestSourceFixture( + currentEntitlements: { await current.read() } + ) + let delegate = CountingDelegate(policy: .automatic) + let finishes = TestSignal() + let snapshot = makeSubscriptionSnapshot( + id: 7, + productID: .tier1Monthly, + revision: "satisfied" + ) + let delivery = StoreTransactionDelivery.verified( + makeEnvelope(snapshot: snapshot, revision: "satisfied") { + await finishes.send() + } + ) + let store = TransactionStore( + source: fixture.source, + subscriptionCatalog: testSubscriptionCatalog, + delegate: delegate + ) + try await store.waitForInitialReadiness() + await current.replace(with: [snapshot]) + + _ = try await store.process(delivery) + let queryCount = await fixture.entitlementQueryCount.value() + _ = try await store.process(delivery) + + #expect(await delegate.decisionCount() == 1) + #expect(await finishes.value() == 1) + #expect(await fixture.entitlementQueryCount.value() == queryCount) + try await store.close() + } + + @MainActor + @Test("a duplicate admitted during causal refresh joins the exact revision") + func duplicateDuringRefreshJoins() async throws { + let query = ControlledEntitlementQuery() + let delegate = CountingDelegate(policy: .automatic) + let finishes = TestSignal() + let secondAdmitted = TestSignal() + let snapshot = makeSubscriptionSnapshot( + id: 70, + productID: .tier2Monthly, + revision: "joined" + ) + let delivery = StoreTransactionDelivery.verified( + makeEnvelope(snapshot: snapshot, revision: "joined") { + await finishes.send() + } + ) + let fixture = TestSourceFixture( + currentEntitlements: { try await query.next() } + ) + let store = TransactionStore( + source: fixture.source, + subscriptionCatalog: testSubscriptionCatalog, + delegate: delegate + ) + try await query.waitForRequest(1) + await query.succeed([]) + try await store.waitForInitialReadiness() + + let first = Task { @MainActor in try await store.process(delivery) } + try await query.waitForRequest(2) + let second = Task { @MainActor in + try await store.process(delivery) { + await secondAdmitted.send() + } + } + try await secondAdmitted.wait(for: 1) + await query.succeed([snapshot]) + + #expect(try await first.value == .completed(snapshot)) + #expect(try await second.value == .completed(snapshot)) + #expect(await delegate.decisionCount() == 1) + #expect(await finishes.value() == 1) + #expect(await fixture.entitlementQueryCount.value() == 2) + try await store.close() + } + + @MainActor + @Test("a thrown decision is retryable and never finishes its failed attempt") + func decisionFailureRetriesPolicy() async throws { + let current = EntitlementValueSource([]) + let delegate = RetryDecisionDelegate() + let finishes = TestSignal() + let snapshot = makeSubscriptionSnapshot( + id: 71, + productID: .tier1Monthly, + revision: "decision-retry" + ) + let delivery = StoreTransactionDelivery.verified( + makeEnvelope(snapshot: snapshot, revision: "decision-retry") { + await finishes.send() + } + ) + let fixture = TestSourceFixture( + currentEntitlements: { await current.read() } + ) + let store = TransactionStore( + source: fixture.source, + subscriptionCatalog: testSubscriptionCatalog, + delegate: delegate + ) + try await store.waitForInitialReadiness() + + await #expect(throws: TestFailure.self) { + _ = try await store.process(delivery) + } + #expect(await finishes.value() == 0) + #expect(await fixture.entitlementQueryCount.value() == 1) + + await current.replace(with: [snapshot]) + #expect(try await store.process(delivery) == .completed(snapshot)) + #expect(await delegate.decisionCount() == 2) + #expect(await finishes.value() == 1) + try await store.close() + } + + @MainActor + @Test("finish policy permits an app-owned unmanaged transaction") + func finishPolicyHandlesUnmanagedProduct() async throws { + let delegate = CountingDelegate(policy: .finish) + let finishes = TestSignal() + let snapshot = makeSnapshot( + id: 72, + productID: "test.consumable.owned", + productType: .consumable + ) + let fixture = TestSourceFixture() + let store = TransactionStore( + source: fixture.source, + subscriptionCatalog: testSubscriptionCatalog, + delegate: delegate + ) + try await store.waitForInitialReadiness() + + let outcome = try await store.process( + .verified( + makeEnvelope(snapshot: snapshot) { + await finishes.send() + } + ) + ) + + #expect(outcome == .completed(snapshot)) + #expect(await delegate.decisionCount() == 1) + #expect(await finishes.value() == 1) + #expect(store.activeEntitlements == []) + try await store.close() + } + + @MainActor + @Test("automatic policy rejects unmanaged products without finishing") + func automaticRejectsUnmanagedProduct() async throws { + let fixture = TestSourceFixture() + let finishes = TestSignal() + let snapshot = makeSnapshot( + id: 8, + productID: "test.consumable", + productType: .consumable + ) + let store = TransactionStore( + source: fixture.source, + subscriptionCatalog: testSubscriptionCatalog + ) + try await store.waitForInitialReadiness() + + do { + _ = try await store.process( + .verified( + makeEnvelope(snapshot: snapshot) { + await finishes.send() + } + ) + ) + Issue.record("An unmanaged product was finished automatically.") + } catch StoreTransactionError.unhandledTransaction( + productID: let productID, + productType: let productType + ) { + #expect(productID == snapshot.productID) + #expect(productType == .consumable) + } + #expect(await finishes.value() == 0) + try await store.close() + } + + @MainActor + @Test("startup failure commits state before notifying the delegate") + func failureNotificationFollowsStateCommit() async throws { + let query = ControlledEntitlementQuery() + let fixture = TestSourceFixture( + currentEntitlements: { try await query.next() } + ) + let holder = TransactionStoreHolder() + let delegate = StateReadingDelegate(holder: holder) + let store = TransactionStore( + source: fixture.source, + subscriptionCatalog: testSubscriptionCatalog, + delegate: delegate + ) + holder.set(store) + + try await query.waitForRequest(1) + await query.fail(TestFailure()) + await #expect(throws: TestFailure.self) { + try await store.waitForInitialReadiness() + } + try await delegate.waitForFailure() + + #expect(await delegate.observedFailedState()) + try await store.close() + } + + @MainActor + @Test("close seals new admission, drains accepted work, and is shared") + func closeDrainsAcceptedWork() async throws { + let query = ControlledEntitlementQuery() + let fixture = TestSourceFixture( + currentEntitlements: { try await query.next() } + ) + let store = TransactionStore( + source: fixture.source, + subscriptionCatalog: testSubscriptionCatalog + ) + try await query.waitForRequest(1) + await query.succeed([]) + try await store.waitForInitialReadiness() + + let refresh = Task { @MainActor in + try await store.refreshEntitlements() + } + try await query.waitForRequest(2) + let firstClose = Task { @MainActor in try await store.close() } + let secondClose = Task { @MainActor in try await store.close() } + + await store.waitUntilClosing() + do { + _ = try await store.refreshEntitlements() + Issue.record("Closing store accepted a new refresh.") + } catch StoreTransactionError.closing {} + + await query.succeed([]) + _ = try await refresh.value + try await firstClose.value + try await secondClose.value + + do { + _ = try await store.refreshEntitlements() + Issue.record("Closed store accepted a new refresh.") + } catch StoreTransactionError.closed {} + } +} + +private actor CountingDelegate: TransactionStoreDelegate { + private let policy: StoreTransactionHandlingPolicy + private var decisions = 0 + + init(policy: StoreTransactionHandlingPolicy) { + self.policy = policy + } + + func decidePolicy( + for transaction: StoreTransactionSnapshot + ) async throws -> StoreTransactionHandlingPolicy { + decisions += 1 + return policy + } + + func decisionCount() -> Int { + decisions + } +} + +private actor RetryDecisionDelegate: TransactionStoreDelegate { + private var decisions = 0 + + func decidePolicy( + for transaction: StoreTransactionSnapshot + ) async throws -> StoreTransactionHandlingPolicy { + decisions += 1 + if decisions == 1 { + throw TestFailure() + } + return .automatic + } + + func decisionCount() -> Int { + decisions + } +} + +private actor StateReadingDelegate: TransactionStoreDelegate { + private let holder: TransactionStoreHolder + private let failure = TestSignal() + private var sawFailedState = false + + init(holder: TransactionStoreHolder) { + self.holder = holder + } + + func didFail(with failure: StoreTransactionBackgroundFailure) async { + sawFailedState = await MainActor.run { + guard case .failed = holder.get().entitlementStatus else { + return false + } + return true + } + await self.failure.send() + } + + func waitForFailure() async throws { + try await failure.wait(for: 1) + } + + func observedFailedState() -> Bool { + sawFailedState + } +} From 27c1e664a617047bf059f9b24a684927e6149eef Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Thu, 23 Jul 2026 11:21:30 +0900 Subject: [PATCH 14/26] fix(runtime): release completed finite tasks --- .../Runtime/StoreTransactionRuntime.swift | 44 ++++++--- .../Runtime/TaskCompletionBag.swift | 95 ++++++++++++++++--- .../TaskCompletionBagTests.swift | 68 +++++++++++++ 3 files changed, 182 insertions(+), 25 deletions(-) create mode 100644 Tests/StoreTransactionKitTests/TaskCompletionBagTests.swift diff --git a/Sources/StoreTransactionKit/Runtime/StoreTransactionRuntime.swift b/Sources/StoreTransactionKit/Runtime/StoreTransactionRuntime.swift index 1a985a6..7ffe691 100644 --- a/Sources/StoreTransactionKit/Runtime/StoreTransactionRuntime.swift +++ b/Sources/StoreTransactionKit/Runtime/StoreTransactionRuntime.swift @@ -130,6 +130,7 @@ where Entitlement: Hashable & Sendable { let failures = failures let subscriptionStatusReadiness = subscriptionStatusReadiness let startupCompletion = startupCompletion + let startupRegistration = finiteTasks.reserve() let updatesTask = Task.detached { await source.runUpdates( @@ -152,6 +153,7 @@ where Entitlement: Hashable & Sendable { ) } let startupTask = Task.detached { + defer { startupRegistration.complete() } let reservation = await entitlements.reserve( retryFailedTransactions: false ) @@ -194,6 +196,7 @@ where Entitlement: Hashable & Sendable { startupCompletion.fail(error) } } + startupRegistration.attach(startupTask) let inserted = tasks.withLock { tasks in guard tasks == nil else { return false } tasks = RuntimeTasks( @@ -206,7 +209,6 @@ where Entitlement: Hashable & Sendable { precondition(inserted, "A transaction runtime can start only once.") producerCancellation.insert(updatesTask) producerCancellation.insert(subscriptionStatusTask) - finiteTasks.insert(startupTask) } package func process( @@ -285,8 +287,12 @@ where Entitlement: Hashable & Sendable { let claim = await accepted.acceptance.claimCausalResolutionIfOwner() await didAdmit() let operationReceipt = ProcessingReceipt() + let registration = finiteTasks.reserve() let task = Task { - defer { leases.work.end() } + defer { + leases.work.end() + registration.complete() + } do { _ = try await accepted.acceptance.receipt.terminalValue() } catch { @@ -342,7 +348,7 @@ where Entitlement: Hashable & Sendable { ) } } - finiteTasks.insert(task) + registration.attach(task) return try await outcome( receipt: operationReceipt, observation: observation, @@ -361,8 +367,12 @@ where Entitlement: Hashable & Sendable { ) await didAdmit() let operationReceipt = ProcessingReceipt() + let registration = finiteTasks.reserve() let task = Task { - defer { leases.work.end() } + defer { + leases.work.end() + registration.complete() + } operationReceipt.fail( await directFailure( observation: observation, @@ -374,7 +384,7 @@ where Entitlement: Hashable & Sendable { ) ) } - finiteTasks.insert(task) + registration.attach(task) return try await outcome( receipt: operationReceipt, observation: observation, @@ -396,8 +406,12 @@ where Entitlement: Hashable & Sendable { preconditionFailure("A direct refresh lost its reporting binding.") } let operationReceipt = ProcessingReceipt>() + let registration = finiteTasks.reserve() let task = Task { - defer { leases.work.end() } + defer { + leases.work.end() + registration.complete() + } do { let publication = try await refresh.receipt.terminalValue() observation.succeed(binding) @@ -415,7 +429,7 @@ where Entitlement: Hashable & Sendable { ) } } - finiteTasks.insert(task) + registration.attach(task) return try await outcome( receipt: operationReceipt, observation: observation, @@ -432,8 +446,12 @@ where Entitlement: Hashable & Sendable { to: DirectOperationReportingAuthority() ) let operationReceipt = ProcessingReceipt<[StoreTransactionSnapshot]>() + let registration = finiteTasks.reserve() let task = Task { - defer { leases.work.end() } + defer { + leases.work.end() + registration.complete() + } do { let snapshots = try await source.history(productID) .sorted(by: Self.historyOrder) @@ -452,7 +470,7 @@ where Entitlement: Hashable & Sendable { ) } } - finiteTasks.insert(task) + registration.attach(task) return try await outcome( receipt: operationReceipt, observation: observation, @@ -474,8 +492,12 @@ where Entitlement: Hashable & Sendable { preconditionFailure("A direct restore lost its reporting binding.") } let operationReceipt = ProcessingReceipt>() + let registration = finiteTasks.reserve() let task = Task { - defer { leases.work.end() } + defer { + leases.work.end() + registration.complete() + } do { let publication = try await restore.receipt.terminalValue() observation.succeed(binding) @@ -510,7 +532,7 @@ where Entitlement: Hashable & Sendable { ) } } - finiteTasks.insert(task) + registration.attach(task) return try await outcome( receipt: operationReceipt, observation: observation, diff --git a/Sources/StoreTransactionKit/Runtime/TaskCompletionBag.swift b/Sources/StoreTransactionKit/Runtime/TaskCompletionBag.swift index 72b63d6..0ea6af5 100644 --- a/Sources/StoreTransactionKit/Runtime/TaskCompletionBag.swift +++ b/Sources/StoreTransactionKit/Runtime/TaskCompletionBag.swift @@ -2,40 +2,107 @@ import Foundation import Synchronization package final class TaskCompletionBag: Sendable { - private let tasks = Mutex<[UUID: Task]>([:]) + package struct Registration: Sendable { + private let attachTask: @Sendable (Task) -> Void + private let completeTask: @Sendable () -> Void + + fileprivate init( + attachTask: @escaping @Sendable (Task) -> Void, + completeTask: @escaping @Sendable () -> Void + ) { + self.attachTask = attachTask + self.completeTask = completeTask + } + + package func attach(_ task: Task) { + attachTask(task) + } + + package func complete() { + completeTask() + } + } + + private struct Entry: Sendable { + var task: Task? + let completion: ProcessingReceipt + } + + private struct State: Sendable { + var entries: [UUID: Entry] = [:] + var isCancelled = false + } + + private let state = Mutex(State()) package init() {} - package func insert(_ task: Task) { + package func reserve() -> Registration { let id = UUID() - tasks.withLock { $0[id] = task } + let completion = ProcessingReceipt() + state.withLock { state in + state.entries[id] = Entry( + task: nil, + completion: completion + ) + } + return Registration( + attachTask: { [weak self] task in + self?.attach(task, to: id) + }, + completeTask: { [weak self] in + self?.complete(id) + } + ) } package func waitForAll() async { while true { - let snapshot = tasks.withLock { Array($0) } - guard !snapshot.isEmpty else { return } - - for (_, task) in snapshot { - await task.value + let snapshot = state.withLock { + $0.entries.values.map(\.completion) } + guard !snapshot.isEmpty else { return } - tasks.withLock { tasks in - for (id, _) in snapshot { - tasks.removeValue(forKey: id) - } + for completion in snapshot { + _ = try? await completion.terminalValue() } } } package func cancel() { - let snapshot = tasks.withLock { Array($0.values) } + let snapshot = state.withLock { state in + state.isCancelled = true + return state.entries.values.compactMap(\.task) + } for task in snapshot { task.cancel() } } package func retainedTaskCount() -> Int { - tasks.withLock(\.count) + state.withLock(\.entries.count) + } + + private func attach( + _ task: Task, + to id: UUID + ) { + let shouldCancel = state.withLock { state in + guard var entry = state.entries[id] else { return false } + precondition(entry.task == nil, "A task completion registration was attached twice.") + entry.task = task + state.entries[id] = entry + return state.isCancelled + } + if shouldCancel { + task.cancel() + } + } + + private func complete(_ id: UUID) { + let completion = state.withLock { + $0.entries.removeValue(forKey: id)?.completion + } + completion?.succeed(()) } } diff --git a/Tests/StoreTransactionKitTests/TaskCompletionBagTests.swift b/Tests/StoreTransactionKitTests/TaskCompletionBagTests.swift new file mode 100644 index 0000000..e4c5294 --- /dev/null +++ b/Tests/StoreTransactionKitTests/TaskCompletionBagTests.swift @@ -0,0 +1,68 @@ +import Testing +@testable import StoreTransactionKit + +@Suite("Task completion bag", .timeLimit(.minutes(1))) +struct TaskCompletionBagTests { + @Test("completed tasks are released without waiting for shutdown") + func completedTasksAreReleased() async throws { + let bag = TaskCompletionBag() + let gate = TestGate() + let completed = TestSignal() + + for _ in 0..<32 { + let registration = bag.reserve() + let task = Task { + try? await gate.wait() + registration.complete() + await completed.send() + } + registration.attach(task) + } + + #expect(bag.retainedTaskCount() == 32) + await gate.open() + try await completed.wait(for: 32) + #expect(bag.retainedTaskCount() == 0) + } + + @Test("completion before attachment does not retain the task") + func completionBeforeAttachment() async throws { + let bag = TaskCompletionBag() + let completed = TestSignal() + let registration = bag.reserve() + let task = Task { + registration.complete() + await completed.send() + } + + try await completed.wait(for: 1) + registration.attach(task) + + #expect(bag.retainedTaskCount() == 0) + } + + @Test("cancellation before attachment reaches the task") + func cancellationBeforeAttachment() async throws { + let bag = TaskCompletionBag() + let gate = TestGate() + let cancelled = TestSignal() + let registration = bag.reserve() + + bag.cancel() + let task = Task { + defer { registration.complete() } + do { + try await gate.wait() + } catch is CancellationError { + await cancelled.send() + } catch { + Issue.record("Unexpected task failure: \(error)") + } + } + registration.attach(task) + + try await cancelled.wait(for: 1) + await bag.waitForAll() + #expect(bag.retainedTaskCount() == 0) + } +} From 22cab96ce64aa9f3b43d0e6649b51d89f3a4938e Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Thu, 23 Jul 2026 11:28:17 +0900 Subject: [PATCH 15/26] test(runtime): restore transaction lifecycle coverage --- .../RuntimeContractCoverageTests.swift | 794 ++++++++++++++++++ 1 file changed, 794 insertions(+) create mode 100644 Tests/StoreTransactionKitTests/RuntimeContractCoverageTests.swift diff --git a/Tests/StoreTransactionKitTests/RuntimeContractCoverageTests.swift b/Tests/StoreTransactionKitTests/RuntimeContractCoverageTests.swift new file mode 100644 index 0000000..801b2c5 --- /dev/null +++ b/Tests/StoreTransactionKitTests/RuntimeContractCoverageTests.swift @@ -0,0 +1,794 @@ +import Foundation +import StoreKit +import Testing +@testable import StoreTransactionKit + +@Suite("Runtime contract coverage", .timeLimit(.minutes(1))) +struct RuntimeContractCoverageTests { + @MainActor + @Test("subscription status waits for readiness and close drains its publication") + func subscriptionStatusReadinessAndCloseDrain() async throws { + let query = ControlledEntitlementQuery() + let snapshot = makeSubscriptionSnapshot( + id: 301, + productID: .tier1Monthly + ) + let fixture = TestSourceFixture( + currentEntitlements: { try await query.next() } + ) + let store = TransactionStore( + source: fixture.source, + subscriptionCatalog: testSubscriptionCatalog + ) + + try await query.waitForRequest(1) + fixture.subscriptionStatusUpdates.yield() + try await fixture.subscriptionStatusDeliveryCount.wait(for: 1) + #expect(await fixture.entitlementQueryCount.value() == 1) + + await query.succeed([]) + try await store.waitForInitialReadiness() + try await query.waitForRequest(2) + + let closeCompleted = TestSignal() + let close = Task { @MainActor in + try await store.close() + await closeCompleted.send() + } + await store.waitUntilClosing() + #expect(await closeCompleted.value() == 0) + + await query.succeed([snapshot]) + try await close.value + + #expect(await closeCompleted.value() == 1) + #expect(store.entitlements?.transactions == [snapshot]) + #expect(store.activeEntitlements == [.tier1]) + } + + @MainActor + @Test("subscription status handles revocation before publishing removal") + func subscriptionStatusRevocationOrdering() async throws { + let active = makeSnapshot( + id: 302, + productID: TestPlans.ProductID.tier1Monthly.rawValue, + productType: .autoRenewable, + subscriptionGroupID: TestPlans.id.rawValue, + jws: "active-302" + ) + let revoked = makeSnapshot( + id: active.id, + productID: active.productID, + productType: .autoRenewable, + subscriptionGroupID: TestPlans.id.rawValue, + signedDate: Date(timeIntervalSince1970: 400), + jws: "revoked-302", + revocationDate: Date(timeIntervalSince1970: 399) + ) + let current = EntitlementValueSource([active]) + let unfinished = UnfinishedValueSource() + let decisionStarted = TestSignal() + let decisionFinished = TestSignal() + let decisionGate = NonCancellableGate() + let delegate = GatedPolicyDelegate( + started: decisionStarted, + finished: decisionFinished, + gate: decisionGate + ) + let finishes = TestSignal() + let fixture = TestSourceFixture( + currentEntitlements: { await current.read() }, + queryUnfinished: { await unfinished.read() } + ) + let store = TransactionStore( + source: fixture.source, + subscriptionCatalog: testSubscriptionCatalog, + delegate: delegate + ) + try await store.waitForInitialReadiness() + + await current.replace(with: []) + await unfinished.replace(with: [ + .verified( + makeEnvelope(snapshot: revoked, revision: "revoked-302") { + await finishes.send() + await unfinished.replace(with: []) + } + ) + ]) + fixture.subscriptionStatusUpdates.yield() + try await fixture.subscriptionStatusDeliveryCount.wait(for: 1) + try await decisionStarted.wait(for: 1) + + #expect(store.activeEntitlements == [.tier1]) + #expect(await finishes.value() == 0) + + let close = Task { @MainActor in try await store.close() } + await store.waitUntilClosing() + decisionGate.open() + try await close.value + + #expect(await decisionFinished.value() == 1) + #expect(await finishes.value() == 1) + #expect(await delegate.decisionCount() == 1) + #expect(store.entitlements?.transactions.isEmpty == true) + #expect(store.activeEntitlements == []) + } + + @Test("restore reservations coalesce, expose one failure, and retry independently") + func restoreCoordinatorCoalescingAndRetry() async throws { + let synchronization = ControlledSynchronization() + let failures = FailureReporterDispatcher() + let entitlements = EntitlementRefreshCoordinator( + query: { _ in + CurrentEntitlementReconciliation( + snapshots: [], + causalClaims: [], + diagnostics: [] + ) + }, + project: { + (_: StoreEntitlements) throws(AutoRenewableSubscriptionCatalogError) + -> Set in + [] + }, + didComplete: { _ in }, + failures: failures + ) + let coordinator = RestoreCoordinator( + synchronize: { try await synchronization.run() }, + entitlements: entitlements + ) + + let first = await coordinator.reserve() + try await synchronization.waitForAttempt(1) + let second = await coordinator.reserve() + + #expect(first.role == .owner) + #expect(second.role == .observer) + #expect(first.receipt === second.receipt) + #expect(first.reportingAuthority === second.reportingAuthority) + + await synchronization.failNext(TestFailure()) + for receipt in [first.receipt, second.receipt] { + do { + _ = try await receipt.terminalValue() + Issue.record("A failed restore reservation unexpectedly succeeded.") + } catch let failure as RestoreCoordinatorFailure { + #expect(failure.underlyingError is TestFailure) + #expect(!failure.synchronized) + } + } + + let retry = await coordinator.reserve() + try await synchronization.waitForAttempt(2) + await synchronization.succeedNext() + let publication = try await retry.receipt.terminalValue() + + #expect(retry.role == .owner) + #expect(retry.receipt !== first.receipt) + #expect(publication.entitlements.transactions.isEmpty) + #expect(await synchronization.attemptCount() == 2) + await entitlements.sealAndDrain() + await failures.sealAndDrain() + } + + @MainActor + @Test("an abandoned restore reports once and a later restore retries synchronization") + func abandonedRestoreReportsOnceAndRetries() async throws { + let synchronization = ControlledSynchronization() + let delegate = RecordingFailureDelegate() + let fixture = TestSourceFixture( + synchronize: { try await synchronization.run() } + ) + let store = TransactionStore( + source: fixture.source, + subscriptionCatalog: testSubscriptionCatalog, + delegate: delegate + ) + try await store.waitForInitialReadiness() + + let first = Task { @MainActor in try await store.restorePurchases() } + try await synchronization.waitForAttempt(1) + first.cancel() + await #expect(throws: CancellationError.self) { + _ = try await first.value + } + + await synchronization.failNext(TestFailure()) + try await delegate.waitForFailures(1) + + let retry = Task { @MainActor in try await store.restorePurchases() } + try await synchronization.waitForAttempt(2) + await synchronization.succeedNext() + let restored = try await retry.value + + let recorded = await delegate.failures() + #expect(recorded.count == 1) + #expect( + recorded[0].source + == .abandonedDirectOperation(.restorePurchases) + ) + #expect(recorded[0].underlyingError is TestFailure) + #expect(restored.transactions.isEmpty) + #expect(await synchronization.attemptCount() == 2) + try await store.close() + } + + @MainActor + @Test("immediate purchase outcomes perform no transaction work") + func immediatePurchaseOutcomes() async throws { + let delegate = RecordingPolicyDelegate() + let fixture = TestSourceFixture() + let store = TransactionStore( + source: fixture.source, + subscriptionCatalog: testSubscriptionCatalog, + delegate: delegate + ) + try await store.waitForInitialReadiness() + + #expect(try await store.process(.pending) == .pending) + #expect(try await store.process(.userCancelled) == .userCancelled) + #expect(await delegate.decisionCount() == 0) + #expect(await fixture.entitlementQueryCount.value() == 1) + try await store.close() + } + + @MainActor + @Test("cancellation before admission starts no operation-specific work") + func cancellationBeforeAdmission() async throws { + let delegate = RecordingPolicyDelegate() + let historyCalls = TestSignal() + let synchronizationCalls = TestSignal() + let fixture = TestSourceFixture( + history: { _ in + await historyCalls.send() + return [] + }, + synchronize: { await synchronizationCalls.send() } + ) + let store = TransactionStore( + source: fixture.source, + subscriptionCatalog: testSubscriptionCatalog, + delegate: delegate + ) + try await store.waitForInitialReadiness() + + try await expectCancellationBeforeInvocation { + try await store.process(.pending) + } + try await expectCancellationBeforeInvocation { + try await store.refreshEntitlements() + } + try await expectCancellationBeforeInvocation { + try await store.history(for: "cancelled.history") + } + try await expectCancellationBeforeInvocation { + try await store.restorePurchases() + } + + #expect(await delegate.decisionCount() == 0) + #expect(await fixture.entitlementQueryCount.value() == 1) + #expect(await historyCalls.value() == 0) + #expect(await synchronizationCalls.value() == 0) + try await store.close() + } + + @MainActor + @Test("distinct revisions of one transaction are processed independently") + func distinctRevisionsProcessRevocation() async throws { + let active = makeSnapshot( + id: 303, + productID: TestPlans.ProductID.tier2Monthly.rawValue, + productType: .autoRenewable, + subscriptionGroupID: TestPlans.id.rawValue, + jws: "active-303" + ) + let revoked = makeSnapshot( + id: active.id, + productID: active.productID, + productType: .autoRenewable, + subscriptionGroupID: TestPlans.id.rawValue, + signedDate: Date(timeIntervalSince1970: 500), + jws: "revoked-303", + revocationDate: Date(timeIntervalSince1970: 499) + ) + let current = EntitlementValueSource([]) + let delegate = RecordingPolicyDelegate() + let finishes = StringRecorder() + let fixture = TestSourceFixture( + currentEntitlements: { await current.read() } + ) + let store = TransactionStore( + source: fixture.source, + subscriptionCatalog: testSubscriptionCatalog, + delegate: delegate + ) + try await store.waitForInitialReadiness() + + await current.replace(with: [active]) + _ = try await store.process( + .verified( + makeEnvelope(snapshot: active, revision: "active-303") { + await finishes.append("active") + } + ) + ) + await current.replace(with: []) + _ = try await store.process( + .verified( + makeEnvelope(snapshot: revoked, revision: "revoked-303") { + await finishes.append("revoked") + } + ) + ) + + #expect(await delegate.decisionCount() == 2) + #expect(await finishes.snapshot() == ["active", "revoked"]) + #expect(store.entitlements?.transactions.isEmpty == true) + #expect(store.activeEntitlements == []) + try await store.close() + } + + @Test("completed revision cache evicts its oldest bounded entry") + func completedRevisionCacheEviction() { + let first = Data("first".utf8) + let second = Data("second".utf8) + let third = Data("third".utf8) + var cache = CompletedRevisionCache(capacity: 2) + + cache.insert(first, state: .satisfied) + cache.insert(second, state: .needsRefresh) + cache.insert(first, state: .needsRefresh) + cache.insert(third, state: .satisfied) + + #expect(cache.state(for: first) == nil) + guard case .needsRefresh = cache.state(for: second) else { + Issue.record("The second completed revision was unexpectedly evicted.") + return + } + guard case .satisfied = cache.state(for: third) else { + Issue.record("The newest completed revision was not retained.") + return + } + } + + @MainActor + @Test("a successful refresh recovers failed startup state") + func failedStartupRecoversToReady() async throws { + let query = ControlledEntitlementQuery() + let snapshot = makeSubscriptionSnapshot( + id: 304, + productID: .tier1Yearly + ) + let fixture = TestSourceFixture( + currentEntitlements: { try await query.next() } + ) + let store = TransactionStore( + source: fixture.source, + subscriptionCatalog: testSubscriptionCatalog + ) + + try await query.waitForRequest(1) + await query.fail(TestFailure()) + await #expect(throws: TestFailure.self) { + try await store.waitForInitialReadiness() + } + guard case .failed = store.entitlementStatus else { + Issue.record("The failed startup was not observable.") + try await store.close() + return + } + #expect(store.entitlements == nil) + #expect(store.activeEntitlements == nil) + + let refresh = Task { @MainActor in try await store.refreshEntitlements() } + try await query.waitForRequest(2) + await query.succeed([snapshot]) + _ = try await refresh.value + + guard case .ready = store.entitlementStatus else { + Issue.record("A successful refresh did not recover readiness.") + try await store.close() + return + } + #expect(store.entitlements?.transactions == [snapshot]) + #expect(store.activeEntitlements == [.tier1]) + try await store.close() + } + + @MainActor + @Test("close drains failure notification, releases delegate, and admits no later callback") + func closeDrainsFailureNotificationAndDelegate() async throws { + let callbackStarted = TestSignal() + let callbackFinished = TestSignal() + let callbackGate = NonCancellableGate() + let callbackCount = TestSignal() + let delegateDeinitialized = TestSignal() + var token: LifetimeToken? = LifetimeToken(signal: delegateDeinitialized) + weak let weakToken = token + var delegate: GatedFailureDelegate? = GatedFailureDelegate( + token: token!, + started: callbackStarted, + finished: callbackFinished, + calls: callbackCount, + gate: callbackGate + ) + let fixture = TestSourceFixture() + let store = TransactionStore( + source: fixture.source, + subscriptionCatalog: testSubscriptionCatalog, + delegate: delegate + ) + try await store.waitForInitialReadiness() + + fixture.updates.yield( + .unverified( + revision: Data("close-failure".utf8), + error: TestFailure() + ) + ) + try await callbackStarted.wait(for: 1) + delegate = nil + token = nil + + let closeCompleted = TestSignal() + let close = Task { @MainActor in + try await store.close() + await closeCompleted.send() + } + await store.waitUntilClosing() + #expect(await closeCompleted.value() == 0) + #expect(weakToken != nil) + + callbackGate.open() + try await close.value + try await delegateDeinitialized.wait(for: 1) + + #expect(await callbackFinished.value() == 1) + #expect(await closeCompleted.value() == 1) + #expect(await callbackCount.value() == 1) + #expect(weakToken == nil) + + fixture.updates.yield( + .unverified( + revision: Data("late-failure".utf8), + error: TestFailure() + ) + ) + #expect(await callbackCount.value() == 1) + } + + @MainActor + @Test("deinit drains a suspended decision before releasing its delegate") + func deinitDrainsDecision() async throws { + let decisionStarted = TestSignal() + let decisionFinished = TestSignal() + let decisionGate = NonCancellableGate() + let delegateDeinitialized = TestSignal() + var token: LifetimeToken? = LifetimeToken(signal: delegateDeinitialized) + weak let weakToken = token + var delegate: GatedPolicyDelegate? = GatedPolicyDelegate( + token: token!, + started: decisionStarted, + finished: decisionFinished, + gate: decisionGate + ) + let finishes = TestSignal() + let fixture = TestSourceFixture() + var store: TransactionStore? = TransactionStore( + source: fixture.source, + lifecycle: TransactionStoreLifecycle(), + subscriptionCatalog: testSubscriptionCatalog, + delegate: delegate + ) + try await store!.waitForInitialReadiness() + fixture.updates.yield( + .verified( + makeEnvelope( + snapshot: makeSubscriptionSnapshot( + id: 305, + productID: .tier1Monthly + ) + ) { + await finishes.send() + } + ) + ) + try await decisionStarted.wait(for: 1) + + weak let weakStore = store + delegate = nil + token = nil + store = nil + + #expect(weakStore == nil) + #expect(weakToken != nil) + #expect(await finishes.value() == 0) + + decisionGate.open() + try await decisionFinished.wait(for: 1) + try await delegateDeinitialized.wait(for: 1) + + #expect(await finishes.value() == 1) + #expect(weakToken == nil) + } + + @MainActor + @Test("deinit retains runtime while admitted restore synchronization is suspended") + func deinitDrainsRestoreSynchronization() async throws { + let synchronization = ControlledSynchronization() + let delegateDeinitialized = TestSignal() + var token: LifetimeToken? = LifetimeToken(signal: delegateDeinitialized) + weak let weakToken = token + var delegate: TokenHoldingDelegate? = TokenHoldingDelegate(token: token!) + let fixture = TestSourceFixture( + synchronize: { try await synchronization.run() } + ) + var store: TransactionStore? = TransactionStore( + source: fixture.source, + lifecycle: TransactionStoreLifecycle(), + subscriptionCatalog: testSubscriptionCatalog, + delegate: delegate + ) + try await store!.waitForInitialReadiness() + + var restore: Task? = { + let admittedStore = store! + return Task { @MainActor in + try await admittedStore.restorePurchases() + } + }() + try await synchronization.waitForAttempt(1) + restore!.cancel() + await #expect(throws: CancellationError.self) { + _ = try await restore!.value + } + restore = nil + + weak let weakStore = store + delegate = nil + token = nil + store = nil + + #expect(weakStore == nil) + #expect(weakToken != nil) + + await synchronization.succeedNext() + try await delegateDeinitialized.wait(for: 1) + #expect(weakToken == nil) + } + + @MainActor + @Test("deinit drains a suspended failure notification") + func deinitDrainsFailureNotification() async throws { + let callbackStarted = TestSignal() + let callbackFinished = TestSignal() + let callbackGate = NonCancellableGate() + let callbackCount = TestSignal() + let delegateDeinitialized = TestSignal() + var token: LifetimeToken? = LifetimeToken(signal: delegateDeinitialized) + weak let weakToken = token + var delegate: GatedFailureDelegate? = GatedFailureDelegate( + token: token!, + started: callbackStarted, + finished: callbackFinished, + calls: callbackCount, + gate: callbackGate + ) + let fixture = TestSourceFixture() + var store: TransactionStore? = TransactionStore( + source: fixture.source, + lifecycle: TransactionStoreLifecycle(), + subscriptionCatalog: testSubscriptionCatalog, + delegate: delegate + ) + try await store!.waitForInitialReadiness() + fixture.updates.yield( + .unverified( + revision: Data("deinit-failure".utf8), + error: TestFailure() + ) + ) + try await callbackStarted.wait(for: 1) + + weak let weakStore = store + delegate = nil + token = nil + store = nil + + #expect(weakStore == nil) + #expect(weakToken != nil) + + callbackGate.open() + try await callbackFinished.wait(for: 1) + try await delegateDeinitialized.wait(for: 1) + + #expect(await callbackCount.value() == 1) + #expect(weakToken == nil) + } +} + +@MainActor +private func expectCancellationBeforeInvocation( + _ operation: @escaping @MainActor @Sendable () async throws -> Output +) async throws { + let waiting = TestSignal() + let release = ProcessingReceipt() + let task = Task { @MainActor in + await waiting.send() + _ = try? await release.terminalValue() + return try await operation() + } + try await waiting.wait(for: 1) + task.cancel() + release.succeed(()) + do { + _ = try await task.value + Issue.record("A pre-cancelled operation unexpectedly succeeded.") + } catch is CancellationError { + } catch { + Issue.record("A pre-cancelled operation threw an unexpected error: \(error)") + } +} + +private final class NonCancellableGate: Sendable { + private let receipt = ProcessingReceipt() + + func wait() async { + _ = try? await receipt.terminalValue() + } + + func open() { + receipt.succeed(()) + } +} + +private actor ControlledSynchronization { + private var attempts: [ProcessingReceipt] = [] + private let started = TestSignal() + + func run() async throws { + let receipt = ProcessingReceipt() + attempts.append(receipt) + await started.send() + try await receipt.terminalValue() + } + + func waitForAttempt(_ count: Int) async throws { + try await started.wait(for: count) + } + + func succeedNext() { + precondition(!attempts.isEmpty) + attempts.removeFirst().succeed(()) + } + + func failNext(_ error: any Error) { + precondition(!attempts.isEmpty) + attempts.removeFirst().fail(error) + } + + func attemptCount() async -> Int { + await started.value() + } +} + +private actor RecordingPolicyDelegate: TransactionStoreDelegate { + private var decisions = 0 + + func decidePolicy( + for transaction: StoreTransactionSnapshot + ) async throws -> StoreTransactionHandlingPolicy { + decisions += 1 + return .automatic + } + + func decisionCount() -> Int { + decisions + } +} + +private actor GatedPolicyDelegate: TransactionStoreDelegate { + private let token: LifetimeToken? + private let started: TestSignal + private let finished: TestSignal + private let gate: NonCancellableGate + private var decisions = 0 + + init( + token: LifetimeToken? = nil, + started: TestSignal, + finished: TestSignal, + gate: NonCancellableGate + ) { + self.token = token + self.started = started + self.finished = finished + self.gate = gate + } + + func decidePolicy( + for transaction: StoreTransactionSnapshot + ) async throws -> StoreTransactionHandlingPolicy { + _ = token + decisions += 1 + await started.send() + await gate.wait() + await finished.send() + return .automatic + } + + func decisionCount() -> Int { + decisions + } +} + +private actor RecordingFailureDelegate: TransactionStoreDelegate { + private var recorded: [StoreTransactionBackgroundFailure] = [] + private let signal = TestSignal() + + func didFail(with failure: StoreTransactionBackgroundFailure) async { + recorded.append(failure) + await signal.send() + } + + func waitForFailures(_ count: Int) async throws { + try await signal.wait(for: count) + } + + func failures() -> [StoreTransactionBackgroundFailure] { + recorded + } +} + +private actor GatedFailureDelegate: TransactionStoreDelegate { + private let token: LifetimeToken + private let started: TestSignal + private let finished: TestSignal + private let calls: TestSignal + private let gate: NonCancellableGate + + init( + token: LifetimeToken, + started: TestSignal, + finished: TestSignal, + calls: TestSignal, + gate: NonCancellableGate + ) { + self.token = token + self.started = started + self.finished = finished + self.calls = calls + self.gate = gate + } + + func didFail(with failure: StoreTransactionBackgroundFailure) async { + _ = token + await calls.send() + await started.send() + await gate.wait() + await finished.send() + } +} + +private actor TokenHoldingDelegate: TransactionStoreDelegate { + private let token: LifetimeToken + + init(token: LifetimeToken) { + self.token = token + } +} + +private final class LifetimeToken: Sendable { + private let signal: TestSignal + + init(signal: TestSignal) { + self.signal = signal + } + + deinit { + let signal = signal + Task { await signal.send() } + } +} From 5b1b0ac1328f3ec1a1bf9b2ba0d03bf91c915a03 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Thu, 23 Jul 2026 11:31:09 +0900 Subject: [PATCH 16/26] feat(testing): add deterministic subscription harness --- .../SyntheticCurrentEntitlements.swift | 61 +++ .../TransactionStoreTestHarness.swift | 100 ++++ .../TransactionStoreTestHarnessError.swift | 63 +++ .../WithTransactionStoreTestHarness.swift | 42 ++ .../TransactionStoreTestHarnessTests.swift | 501 ++++++++++++++++++ 5 files changed, 767 insertions(+) create mode 100644 Sources/StoreTransactionKitTesting/SyntheticCurrentEntitlements.swift create mode 100644 Sources/StoreTransactionKitTesting/TransactionStoreTestHarness.swift create mode 100644 Sources/StoreTransactionKitTesting/TransactionStoreTestHarnessError.swift create mode 100644 Sources/StoreTransactionKitTesting/WithTransactionStoreTestHarness.swift create mode 100644 Tests/StoreTransactionKitTestingTests/TransactionStoreTestHarnessTests.swift diff --git a/Sources/StoreTransactionKitTesting/SyntheticCurrentEntitlements.swift b/Sources/StoreTransactionKitTesting/SyntheticCurrentEntitlements.swift new file mode 100644 index 0000000..44301ee --- /dev/null +++ b/Sources/StoreTransactionKitTesting/SyntheticCurrentEntitlements.swift @@ -0,0 +1,61 @@ +import Foundation +import StoreKit +import StoreTransactionKit + +@MainActor +final class SyntheticCurrentEntitlements: Sendable { + private var nextTransactionID: UInt64 = 1 + private var activeSnapshot: StoreTransactionSnapshot? + + func snapshots() -> [StoreTransactionSnapshot] { + if let activeSnapshot { + [activeSnapshot] + } else { + [] + } + } + + func makeSnapshot( + productID: String, + subscriptionGroupID: SubscriptionGroupID + ) -> StoreTransactionSnapshot { + precondition( + nextTransactionID < .max, + "The transaction-store test harness exhausted its transaction identifiers." + ) + let transactionID = nextTransactionID + nextTransactionID += 1 + + let date = Date(timeIntervalSince1970: TimeInterval(transactionID)) + return StoreTransactionSnapshot( + id: transactionID, + originalID: transactionID, + productID: productID, + subscriptionGroupID: subscriptionGroupID.rawValue, + productType: .autoRenewable, + environment: .xcode, + offer: nil, + storefrontID: "143441", + storefrontCountryCode: "USA", + price: nil, + currency: nil, + purchaseDate: date, + originalPurchaseDate: date, + expirationDate: nil, + revocationDate: nil, + revocationReason: nil, + purchasedQuantity: 1, + isUpgraded: false, + ownershipType: .purchased, + reason: .purchase, + appAccountToken: nil, + signedDate: date, + jwsRepresentation: + "StoreTransactionKitTesting.synthetic.\(transactionID)" + ) + } + + func replace(with snapshot: StoreTransactionSnapshot) { + activeSnapshot = snapshot + } +} diff --git a/Sources/StoreTransactionKitTesting/TransactionStoreTestHarness.swift b/Sources/StoreTransactionKitTesting/TransactionStoreTestHarness.swift new file mode 100644 index 0000000..ce8294e --- /dev/null +++ b/Sources/StoreTransactionKitTesting/TransactionStoreTestHarness.swift @@ -0,0 +1,100 @@ +import StoreTransactionKit + +/// A StoreKit-free driver for a production ``TransactionStore`` data flow. +@MainActor +public final class TransactionStoreTestHarness +where Entitlement: Hashable & Sendable { + /// The production transaction store supplied to the app component under test. + public let store: TransactionStore + + private let subscriptionCatalog: AutoRenewableSubscriptionCatalog + private let currentEntitlements: SyntheticCurrentEntitlements + + private init( + store: TransactionStore, + subscriptionCatalog: + AutoRenewableSubscriptionCatalog, + currentEntitlements: SyntheticCurrentEntitlements + ) { + self.store = store + self.subscriptionCatalog = subscriptionCatalog + self.currentEntitlements = currentEntitlements + } + + /// Simulates an immediately active purchase of a declared subscription product. + /// + /// The command completes after transaction policy, acknowledgement, + /// entitlement reconciliation, and observable-state publication complete. + @discardableResult + public func purchase( + _ productID: Group.ProductID, + in groupType: Group.Type + ) async throws -> StoreTransactionSnapshot + where Group: AutoRenewableSubscriptionGroup { + guard subscriptionCatalog.subscriptionGroupID == Group.id else { + throw TransactionStoreTestHarnessError.subscriptionGroupMismatch( + expected: subscriptionCatalog.subscriptionGroupID, + actual: Group.id + ) + } + guard subscriptionCatalog.isDeclared(by: groupType) else { + throw TransactionStoreTestHarnessError.subscriptionGroupTypeMismatch( + subscriptionGroupID: Group.id + ) + } + + let rawProductID = productID.rawValue + guard subscriptionCatalog.contains(productID: rawProductID) else { + throw TransactionStoreTestHarnessError.undeclaredProduct( + productID: rawProductID, + subscriptionGroupID: Group.id + ) + } + + try Task.checkCancellation() + let snapshot = currentEntitlements.makeSnapshot( + productID: rawProductID, + subscriptionGroupID: Group.id + ) + return try await store.processSyntheticDelivery( + .synthetic(snapshot: snapshot) { [currentEntitlements] in + await currentEntitlements.replace(with: snapshot) + } + ) + } + + static func make( + subscriptionCatalog: + AutoRenewableSubscriptionCatalog, + delegate: (any TransactionStoreDelegate)? + ) async throws -> TransactionStoreTestHarness { + let currentEntitlements = SyntheticCurrentEntitlements() + let syntheticSource = SyntheticStoreTransactionSource { + await currentEntitlements.snapshots() + } + let store = TransactionStore( + subscriptionCatalog: subscriptionCatalog, + syntheticSource: syntheticSource, + delegate: delegate, + unavailableOperationError: { + TransactionStoreTestHarnessError.operationUnavailable( + operation: $0 + ) + } + ) + let harness = TransactionStoreTestHarness( + store: store, + subscriptionCatalog: subscriptionCatalog, + currentEntitlements: currentEntitlements + ) + + do { + try await store.waitForInitialReadiness() + return harness + } catch { + let initializationError = error + try await store.close() + throw initializationError + } + } +} diff --git a/Sources/StoreTransactionKitTesting/TransactionStoreTestHarnessError.swift b/Sources/StoreTransactionKitTesting/TransactionStoreTestHarnessError.swift new file mode 100644 index 0000000..ab742bf --- /dev/null +++ b/Sources/StoreTransactionKitTesting/TransactionStoreTestHarnessError.swift @@ -0,0 +1,63 @@ +import Foundation +import StoreTransactionKit + +/// An error produced while configuring or operating a transaction-store test harness. +public enum TransactionStoreTestHarnessError: + LocalizedError, + Sendable, + Hashable +{ + /// The supplied group identifier differs from the catalog's group identifier. + case subscriptionGroupMismatch( + expected: SubscriptionGroupID, + actual: SubscriptionGroupID + ) + + /// The supplied group declaration isn't the declaration that created the catalog. + case subscriptionGroupTypeMismatch( + subscriptionGroupID: SubscriptionGroupID + ) + + /// The supplied product isn't declared by the catalog's subscription group. + case undeclaredProduct( + productID: String, + subscriptionGroupID: SubscriptionGroupID + ) + + /// The synthetic store doesn't provide the requested live StoreKit operation. + case operationUnavailable(operation: StoreTransactionOperation) + + /// A localized description of the harness configuration or operation error. + public var errorDescription: String? { + switch self { + case .subscriptionGroupMismatch(let expected, let actual): + "Expected subscription group \(expected.rawValue), but received \(actual.rawValue)." + + case .subscriptionGroupTypeMismatch(let subscriptionGroupID): + "The subscription group declaration for \(subscriptionGroupID.rawValue) did not create this catalog." + + case .undeclaredProduct(let productID, let subscriptionGroupID): + "Product \(productID) is not declared by subscription group \(subscriptionGroupID.rawValue)." + + case .operationUnavailable(let operation): + "The synthetic transaction store does not provide \(operation.description)." + } + } +} + +private extension StoreTransactionOperation { + var description: String { + switch self { + case .processPurchase: + "purchase-result processing" + case .refreshEntitlements: + "entitlement refresh" + case .history: + "transaction history" + case .restorePurchases: + "purchase restoration" + case .close: + "closing" + } + } +} diff --git a/Sources/StoreTransactionKitTesting/WithTransactionStoreTestHarness.swift b/Sources/StoreTransactionKitTesting/WithTransactionStoreTestHarness.swift new file mode 100644 index 0000000..a0454e5 --- /dev/null +++ b/Sources/StoreTransactionKitTesting/WithTransactionStoreTestHarness.swift @@ -0,0 +1,42 @@ +import StoreTransactionKit + +/// Runs a scoped test with a ready-empty synthetic transaction store. +/// +/// The store is closed and drained before this function returns or throws. +@MainActor +public func withTransactionStoreTestHarness( + subscriptionCatalog: AutoRenewableSubscriptionCatalog, + delegate: (any TransactionStoreDelegate)? = nil, + _ operation: + @MainActor ( + TransactionStoreTestHarness + ) async throws -> Result +) async throws -> Result +where Entitlement: Hashable & Sendable { + let harness = try await TransactionStoreTestHarness.make( + subscriptionCatalog: subscriptionCatalog, + delegate: delegate + ) + + let operationResult: Swift.Result + do { + operationResult = .success(try await operation(harness)) + } catch { + operationResult = .failure(error) + } + + let closeResult: Swift.Result + do { + closeResult = .success(try await harness.store.close()) + } catch { + closeResult = .failure(error) + } + + switch operationResult { + case .success(let result): + try closeResult.get() + return result + case .failure(let error): + throw error + } +} diff --git a/Tests/StoreTransactionKitTestingTests/TransactionStoreTestHarnessTests.swift b/Tests/StoreTransactionKitTestingTests/TransactionStoreTestHarnessTests.swift new file mode 100644 index 0000000..f503567 --- /dev/null +++ b/Tests/StoreTransactionKitTestingTests/TransactionStoreTestHarnessTests.swift @@ -0,0 +1,501 @@ +import StoreTransactionKit +import StoreTransactionKitTesting +import StoreKit +import Synchronization +import Testing + +@Suite("TransactionStoreTestHarness", .timeLimit(.minutes(1))) +struct TransactionStoreTestHarnessTests { + @MainActor + @Test("construction publishes a ready empty entitlement set") + func readyEmptyConstruction() async throws { + try await withTransactionStoreTestHarness( + subscriptionCatalog: harnessSubscriptionCatalog + ) { harness in + guard case .ready = harness.store.entitlementStatus else { + Issue.record("The synthetic store did not become ready.") + return + } + #expect(harness.store.entitlements?.transactions.isEmpty == true) + #expect(harness.store.activeEntitlements == []) + #expect(!harness.store.isEntitled(to: .tier1)) + } + } + + @MainActor + @Test("purchase returns after policy, acknowledgement, and publication") + func purchaseCompletion() async throws { + let delegate = ActorRecordingDelegate() + + try await withTransactionStoreTestHarness( + subscriptionCatalog: harnessSubscriptionCatalog, + delegate: delegate + ) { harness in + let snapshot = try await harness.purchase( + .tier1_Monthly, + in: HarnessPlans.self + ) + + #expect(snapshot.id == 1) + #expect(snapshot.originalID == 1) + #expect(snapshot.productID == HarnessPlans.ProductID.tier1_Monthly.rawValue) + #expect(snapshot.subscriptionGroupID == HarnessPlans.id.rawValue) + #expect(snapshot.productType == .autoRenewable) + #expect(snapshot.jwsRepresentation == "StoreTransactionKitTesting.synthetic.1") + #expect(harness.store.entitlements?.transactions == [snapshot]) + #expect(harness.store.activeEntitlements == [.tier1]) + #expect(harness.store.isEntitled(to: .tier1)) + #expect(await delegate.snapshot() == .init(decisions: 1, failures: 0)) + } + } + + @MainActor + @Test("a later product replaces the active synthetic subscription") + func purchaseReplacement() async throws { + try await withTransactionStoreTestHarness( + subscriptionCatalog: harnessSubscriptionCatalog + ) { harness in + let first = try await harness.purchase( + .tier1_Yearly, + in: HarnessPlans.self + ) + let second = try await harness.purchase( + .tier2_Monthly, + in: HarnessPlans.self + ) + + #expect(first.id == 1) + #expect(second.id == 2) + #expect(harness.store.entitlements?.transactions == [second]) + #expect(harness.store.activeEntitlements == [.tier2]) + #expect(!harness.store.isEntitled(to: .tier1)) + #expect(harness.store.isEntitled(to: .tier2)) + + let refreshed = try await harness.store.refreshEntitlements() + #expect(refreshed.transactions == [second]) + } + } + + @MainActor + @Test("group and product validation completes before production admission") + func validationPrecedesAdmission() async throws { + let delegate = ActorRecordingDelegate() + + try await withTransactionStoreTestHarness( + subscriptionCatalog: harnessSubscriptionCatalog, + delegate: delegate + ) { harness in + await expectHarnessError( + .subscriptionGroupMismatch( + expected: HarnessPlans.id, + actual: DifferentIDPlans.id + ) + ) { + try await harness.purchase( + .monthly, + in: DifferentIDPlans.self + ) + } + await expectHarnessError( + .subscriptionGroupTypeMismatch( + subscriptionGroupID: HarnessPlans.id + ) + ) { + try await harness.purchase( + .monthly, + in: SubstitutedPlans.self + ) + } + await expectHarnessError( + .undeclaredProduct( + productID: HarnessPlans.ProductID.undeclared.rawValue, + subscriptionGroupID: HarnessPlans.id + ) + ) { + try await harness.purchase( + .undeclared, + in: HarnessPlans.self + ) + } + + #expect(harness.store.entitlements?.transactions.isEmpty == true) + #expect(harness.store.activeEntitlements == []) + #expect(await delegate.snapshot() == .init(decisions: 0, failures: 0)) + + let firstAdmitted = try await harness.purchase( + .tier1_Monthly, + in: HarnessPlans.self + ) + #expect(firstAdmitted.id == 1) + } + } + + @MainActor + @Test("cancellation already set before admission starts no transaction work") + func preAdmissionCancellation() async throws { + let delegate = ActorRecordingDelegate() + let clock = TransactionStoreTestClock() + + try await withTransactionStoreTestHarness( + subscriptionCatalog: harnessSubscriptionCatalog, + delegate: delegate + ) { harness in + let purchase = Task { @MainActor in + do { + try await clock.sleep(for: .seconds(1)) + } catch is CancellationError { + // Preserve the task's cancellation flag before entering purchase. + } + return try await harness.purchase( + .tier1_Monthly, + in: HarnessPlans.self + ) + } + try await clock.waitUntilPendingSleepCount(reaches: 1) + + purchase.cancel() + await #expect(throws: CancellationError.self) { + _ = try await purchase.value + } + + #expect(harness.store.entitlements?.transactions.isEmpty == true) + #expect(harness.store.activeEntitlements == []) + #expect(await delegate.snapshot() == .init(decisions: 0, failures: 0)) + + let firstAdmitted = try await harness.purchase( + .tier1_Monthly, + in: HarnessPlans.self + ) + #expect(firstAdmitted.id == 1) + } + } + + @MainActor + @Test("decision failure is returned directly without acknowledging the purchase") + func decisionFailure() async throws { + let delegate = ThrowingDelegate() + + try await withTransactionStoreTestHarness( + subscriptionCatalog: harnessSubscriptionCatalog, + delegate: delegate + ) { harness in + await #expect(throws: HarnessTestError.decision) { + try await harness.purchase( + .tier1_Monthly, + in: HarnessPlans.self + ) + } + + #expect(harness.store.entitlements?.transactions.isEmpty == true) + #expect(harness.store.activeEntitlements == []) + #expect(await delegate.snapshot() == .init(decisions: 1, failures: 0)) + } + } + + @MainActor + @Test("unsupported live operations fail before source or delegate work") + func unsupportedOperations() async throws { + let delegate = ActorRecordingDelegate() + + try await withTransactionStoreTestHarness( + subscriptionCatalog: harnessSubscriptionCatalog, + delegate: delegate + ) { harness in + await expectHarnessError( + .operationUnavailable(operation: .processPurchase) + ) { + _ = try await harness.store.process(.pending) + } + await expectHarnessError(.operationUnavailable(operation: .history)) { + _ = try await harness.store.history(for: "test.product") + } + await expectHarnessError( + .operationUnavailable(operation: .restorePurchases) + ) { + _ = try await harness.store.restorePurchases() + } + + #expect(harness.store.entitlements?.transactions.isEmpty == true) + #expect(harness.store.activeEntitlements == []) + #expect(await delegate.snapshot() == .init(decisions: 0, failures: 0)) + } + } + + @MainActor + @Test("multiple synthetic stores coexist without live StoreKit authority") + func multipleSyntheticStores() async throws { + try await withTransactionStoreTestHarness( + subscriptionCatalog: harnessSubscriptionCatalog + ) { first in + try await withTransactionStoreTestHarness( + subscriptionCatalog: harnessSubscriptionCatalog + ) { second in + _ = try await first.purchase( + .tier1_Monthly, + in: HarnessPlans.self + ) + _ = try await second.purchase( + .tier2_Monthly, + in: HarnessPlans.self + ) + + #expect(first.store.activeEntitlements == [.tier1]) + #expect(second.store.activeEntitlements == [.tier2]) + } + } + } + + @MainActor + @Test("a class delegate can delay policy with the deterministic clock") + func classDelegateClockIntegration() async throws { + let clock = TransactionStoreTestClock() + let delegate = DelayedClassDelegate(clock: clock) + + try await withTransactionStoreTestHarness( + subscriptionCatalog: harnessSubscriptionCatalog, + delegate: delegate + ) { harness in + let purchase = Task { @MainActor in + try await harness.purchase( + .tier2_Monthly, + in: HarnessPlans.self + ) + } + + try await clock.waitUntilPendingSleepCount(reaches: 1) + #expect(harness.store.activeEntitlements == []) + #expect(delegate.decisionCount == 1) + + clock.advance(by: .seconds(30)) + let snapshot = try await purchase.value + + #expect(harness.store.entitlements?.transactions == [snapshot]) + #expect(harness.store.activeEntitlements == [.tier2]) + } + } + + @MainActor + @Test("scoped cleanup closes a retained harness after success") + func scopedSuccessCleanup() async throws { + var retained: TransactionStoreTestHarness? + + try await withTransactionStoreTestHarness( + subscriptionCatalog: harnessSubscriptionCatalog + ) { harness in + retained = harness + } + + try await expectClosed(try #require(retained)) + } + + @MainActor + @Test("scoped cleanup closes a retained harness after operation failure") + func scopedFailureCleanup() async throws { + var retained: TransactionStoreTestHarness? + + await #expect(throws: HarnessTestError.operation) { + try await withTransactionStoreTestHarness( + subscriptionCatalog: harnessSubscriptionCatalog + ) { harness -> Void in + retained = harness + throw HarnessTestError.operation + } + } + + try await expectClosed(try #require(retained)) + } + + @MainActor + @Test("scoped cleanup drains after operation cancellation") + func scopedCancellationCleanup() async throws { + let clock = TransactionStoreTestClock() + var retained: TransactionStoreTestHarness? + let scoped = Task { @MainActor in + try await withTransactionStoreTestHarness( + subscriptionCatalog: harnessSubscriptionCatalog + ) { harness in + retained = harness + try await clock.sleep(for: .seconds(1)) + } + } + try await clock.waitUntilPendingSleepCount(reaches: 1) + + scoped.cancel() + await #expect(throws: CancellationError.self) { + try await scoped.value + } + + try await expectClosed(try #require(retained)) + } + + @MainActor + @Test("scoped cleanup is idempotent when the operation closes the store") + func operationClosesStore() async throws { + var retained: TransactionStoreTestHarness? + + try await withTransactionStoreTestHarness( + subscriptionCatalog: harnessSubscriptionCatalog + ) { harness in + retained = harness + try await harness.store.close() + } + + try await expectClosed(try #require(retained)) + } +} + +private enum HarnessEntitlement: Hashable, Sendable { + case tier1 + case tier2 +} + +private enum HarnessPlans: AutoRenewableSubscriptionGroup { + static let id = SubscriptionGroupID( + rawValue: "testing.subscription.group" + ) + + enum ProductID: String, Hashable, Sendable { + case tier1_Monthly = "testing.subscription.tier1.monthly" + case tier1_Yearly = "testing.subscription.tier1.yearly" + case tier2_Monthly = "testing.subscription.tier2.monthly" + case undeclared = "testing.subscription.undeclared" + } + + static var subscriptions: StoreSubscriptions { + StoreSubscription(.tier1_Monthly, entitlement: .tier1) + StoreSubscription(.tier1_Yearly, entitlement: .tier1) + StoreSubscription(.tier2_Monthly, entitlement: .tier2) + } +} + +private enum DifferentIDPlans: + AutoRenewableSubscriptionGroup +{ + static let id = SubscriptionGroupID( + rawValue: "testing.subscription.other-group" + ) + + enum ProductID: String, Hashable, Sendable { + case monthly = "testing.subscription.tier1.monthly" + } + + static var subscriptions: StoreSubscriptions { + StoreSubscription(.monthly, entitlement: .tier1) + } +} + +private enum SubstitutedPlans: + AutoRenewableSubscriptionGroup +{ + static let id = HarnessPlans.id + + enum ProductID: String, Hashable, Sendable { + case monthly = "testing.subscription.tier1.monthly" + } + + static var subscriptions: StoreSubscriptions { + StoreSubscription(.monthly, entitlement: .tier1) + } +} + +private let harnessSubscriptionCatalog = + AutoRenewableSubscriptionCatalog(HarnessPlans.self) + +private enum HarnessTestError: Error, Sendable { + case decision + case operation +} + +private struct DelegateSnapshot: Equatable, Sendable { + let decisions: Int + let failures: Int +} + +private actor ActorRecordingDelegate: TransactionStoreDelegate { + private var decisions = 0 + private var failures = 0 + + func decidePolicy( + for transaction: StoreTransactionSnapshot + ) async throws -> StoreTransactionHandlingPolicy { + decisions += 1 + return .finish + } + + func didFail(with failure: StoreTransactionBackgroundFailure) async { + failures += 1 + } + + func snapshot() -> DelegateSnapshot { + DelegateSnapshot(decisions: decisions, failures: failures) + } +} + +private actor ThrowingDelegate: TransactionStoreDelegate { + private var decisions = 0 + private var failures = 0 + + func decidePolicy( + for transaction: StoreTransactionSnapshot + ) async throws -> StoreTransactionHandlingPolicy { + decisions += 1 + throw HarnessTestError.decision + } + + func didFail(with failure: StoreTransactionBackgroundFailure) async { + failures += 1 + } + + func snapshot() -> DelegateSnapshot { + DelegateSnapshot(decisions: decisions, failures: failures) + } +} + +private final class DelayedClassDelegate: TransactionStoreDelegate { + private let clock: any Clock + private let decisions = Mutex(0) + + var decisionCount: Int { + decisions.withLock { $0 } + } + + init(clock: any Clock) { + self.clock = clock + } + + func decidePolicy( + for transaction: StoreTransactionSnapshot + ) async throws -> StoreTransactionHandlingPolicy { + decisions.withLock { $0 += 1 } + try await clock.sleep(for: .seconds(30)) + return .finish + } +} + +@MainActor +private func expectHarnessError( + _ expected: TransactionStoreTestHarnessError, + performing operation: @MainActor () async throws -> Void +) async { + do { + try await operation() + Issue.record("Expected test harness error: \(expected)") + } catch let error as TransactionStoreTestHarnessError { + #expect(error == expected) + #expect(error.errorDescription != nil) + } catch { + Issue.record("Unexpected error: \(error)") + } +} + +@MainActor +private func expectClosed( + _ harness: TransactionStoreTestHarness +) async throws { + do { + _ = try await harness.store.history(for: "test.product") + Issue.record("The retained synthetic store was not closed.") + } catch StoreTransactionError.closed { + return + } +} From 6cfb874ac61f9874baa4f6af2f8758d9f4405e62 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Thu, 23 Jul 2026 11:40:33 +0900 Subject: [PATCH 17/26] test(catalog): use typed throws in assertions --- ...utoRenewableSubscriptionCatalogTests.swift | 20 +++++-------------- 1 file changed, 5 insertions(+), 15 deletions(-) diff --git a/Tests/StoreTransactionKitTests/AutoRenewableSubscriptionCatalogTests.swift b/Tests/StoreTransactionKitTests/AutoRenewableSubscriptionCatalogTests.swift index b694939..c1da4ac 100644 --- a/Tests/StoreTransactionKitTests/AutoRenewableSubscriptionCatalogTests.swift +++ b/Tests/StoreTransactionKitTests/AutoRenewableSubscriptionCatalogTests.swift @@ -134,7 +134,7 @@ struct AutoRenewableSubscriptionCatalogTests { ) ) Issue.record("Projection unexpectedly accepted a non-consumable product.") - } catch let error as AutoRenewableSubscriptionCatalogError { + } catch let error { guard case let .productTypeMismatch(actualProductID, actual) = error else { Issue.record("Unexpected catalog error: \(error)") return @@ -142,8 +142,6 @@ struct AutoRenewableSubscriptionCatalogTests { #expect(actualProductID == productID) #expect(actual == .nonConsumable) - } catch { - Issue.record("Unexpected error: \(error)") } } @@ -161,7 +159,7 @@ struct AutoRenewableSubscriptionCatalogTests { ) ) Issue.record("Classification unexpectedly accepted the wrong group.") - } catch let error as AutoRenewableSubscriptionCatalogError { + } catch let error { guard case let .subscriptionGroupMismatch( actualProductID, @@ -176,8 +174,6 @@ struct AutoRenewableSubscriptionCatalogTests { #expect(actualProductID == productID) #expect(expected == Plans.id) #expect(actual == "other-group") - } catch { - Issue.record("Unexpected error: \(error)") } } @@ -198,7 +194,7 @@ struct AutoRenewableSubscriptionCatalogTests { ) ) Issue.record("Projection unexpectedly accepted an undeclared product.") - } catch let error as AutoRenewableSubscriptionCatalogError { + } catch let error { guard case let .undeclaredProduct( actualProductID, @@ -211,8 +207,6 @@ struct AutoRenewableSubscriptionCatalogTests { #expect(actualProductID == productID) #expect(subscriptionGroupID == Plans.id) - } catch { - Issue.record("Unexpected error: \(error)") } } @@ -231,7 +225,7 @@ struct AutoRenewableSubscriptionCatalogTests { ) ) Issue.record("Classification unexpectedly accepted the wrong type.") - } catch let error as AutoRenewableSubscriptionCatalogError { + } catch let error { guard case let .productTypeMismatch(actualProductID, actual) = error else { Issue.record("Unexpected catalog error: \(error)") return @@ -239,8 +233,6 @@ struct AutoRenewableSubscriptionCatalogTests { #expect(actualProductID == productID) #expect(actual == .nonRenewable) - } catch { - Issue.record("Unexpected error: \(error)") } } @@ -264,13 +256,11 @@ struct AutoRenewableSubscriptionCatalogTests { ) ) Issue.record("Projection unexpectedly returned a partial set.") - } catch let error as AutoRenewableSubscriptionCatalogError { + } catch let error { guard case .undeclaredProduct = error else { Issue.record("Unexpected catalog error: \(error)") return } - } catch { - Issue.record("Unexpected error: \(error)") } } From 084901e812eef22bd41e13e4c574479346430133 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Thu, 23 Jul 2026 11:44:42 +0900 Subject: [PATCH 18/26] docs(api): align consumer contract --- Docs/AutoRenewableSubscriptionCatalogAPI.md | 2 +- Fixtures/ExternalConsumer/Package.swift | 21 +- .../Sources/Consumer/Consumer.swift | 75 ++++- .../SubscriptionAccessTests.swift | 24 ++ README.md | 33 +- .../Entitlements/EntitlementStatus.swift | 5 +- .../StoreEntitlements.swift | 6 +- .../StorePurchaseOutcome.swift | 2 +- .../DefiningSubscriptionAccess.md | 88 ++++++ .../StoreTransactionKit.md | 84 ++--- .../TestingSubscriptionAccess.md | 66 ++++ .../UnderstandingTransactionHandling.md | 286 ++++++++++-------- .../StoreTransactionSnapshot.swift | 15 +- .../AutoRenewableSubscriptionCatalog.swift | 10 +- ...utoRenewableSubscriptionCatalogError.swift | 2 +- .../AutoRenewableSubscriptionGroup.swift | 6 +- .../Subscriptions/StoreSubscription.swift | 2 + .../TransactionStore.swift | 49 ++- .../TransactionStoreDelegate.swift | 19 +- 19 files changed, 581 insertions(+), 214 deletions(-) create mode 100644 Fixtures/ExternalConsumer/Tests/ConsumerTests/SubscriptionAccessTests.swift create mode 100644 Sources/StoreTransactionKit/StoreTransactionKit.docc/DefiningSubscriptionAccess.md create mode 100644 Sources/StoreTransactionKit/StoreTransactionKit.docc/TestingSubscriptionAccess.md diff --git a/Docs/AutoRenewableSubscriptionCatalogAPI.md b/Docs/AutoRenewableSubscriptionCatalogAPI.md index 7f61f7f..2760d12 100644 --- a/Docs/AutoRenewableSubscriptionCatalogAPI.md +++ b/Docs/AutoRenewableSubscriptionCatalogAPI.md @@ -102,7 +102,7 @@ enum Plans: AutoRenewableSubscriptionGroup { rawValue: "YOUR_SUBSCRIPTION_GROUP_ID" ) - enum ProductID: String { + enum ProductID: String, Hashable, Sendable { case tier1_Monthly = "com.example.subscription.tier1.monthly" case tier1_Yearly = "com.example.subscription.tier1.yearly" case tier2_Monthly = "com.example.subscription.tier2.monthly" diff --git a/Fixtures/ExternalConsumer/Package.swift b/Fixtures/ExternalConsumer/Package.swift index 28fc8da..11fc452 100644 --- a/Fixtures/ExternalConsumer/Package.swift +++ b/Fixtures/ExternalConsumer/Package.swift @@ -29,6 +29,25 @@ let package = Package( .defaultIsolation(nil), .strictMemorySafety(), ] - ) + ), + .testTarget( + name: "ConsumerTests", + dependencies: [ + "Consumer", + .product( + name: "StoreTransactionKit", + package: "StoreTransactionKit" + ), + .product( + name: "StoreTransactionKitTesting", + package: "StoreTransactionKit" + ), + ], + swiftSettings: [ + .swiftLanguageMode(.v6), + .defaultIsolation(nil), + .strictMemorySafety(), + ] + ), ] ) diff --git a/Fixtures/ExternalConsumer/Sources/Consumer/Consumer.swift b/Fixtures/ExternalConsumer/Sources/Consumer/Consumer.swift index 25e993c..1ce4248 100644 --- a/Fixtures/ExternalConsumer/Sources/Consumer/Consumer.swift +++ b/Fixtures/ExternalConsumer/Sources/Consumer/Consumer.swift @@ -1,27 +1,72 @@ import StoreTransactionKit -private enum EntitlementID: String, Hashable, Sendable { - case premium = "com.example.premium" +public enum SubscriptionEntitlement: Hashable, Sendable { + case tier1 + case tier2 +} + +public enum Plans: AutoRenewableSubscriptionGroup { + public static let id = SubscriptionGroupID( + rawValue: "external-consumer.subscription-group" + ) + + public enum ProductID: String, Hashable, Sendable { + case tier1_Monthly = "external-consumer.subscription.tier1.monthly" + case tier1_Yearly = "external-consumer.subscription.tier1.yearly" + case tier2_Monthly = "external-consumer.subscription.tier2.monthly" + case tier2_Yearly = "external-consumer.subscription.tier2.yearly" + } + + public static var subscriptions: StoreSubscriptions { + StoreSubscription(.tier1_Monthly, entitlement: .tier1) + StoreSubscription(.tier1_Yearly, entitlement: .tier1) + StoreSubscription(.tier2_Monthly, entitlement: .tier2) + StoreSubscription(.tier2_Yearly, entitlement: .tier2) + } +} + +public let subscriptionCatalog = AutoRenewableSubscriptionCatalog(Plans.self) + +@MainActor +public final class NotesViewModel { + private let store: TransactionStore + + public var canExportPDF: Bool { + store.isEntitled(to: .tier1) + } + + public init(store: TransactionStore) { + self.store = store + } +} + +public actor AppTransactionDelegate: TransactionStoreDelegate { + public init() {} + + public func decidePolicy( + for transaction: StoreTransactionSnapshot + ) async throws -> StoreTransactionHandlingPolicy { + .automatic + } + + public func didFail( + with failure: StoreTransactionBackgroundFailure + ) async { + print("Background failure from \(failure.source)") + } } @main @MainActor struct Consumer { static func main() async throws { - let store = TransactionStore( - handleTransaction: { transaction in - print("Handle transaction \(transaction.id)") - }, - reportFailure: { failure in - print("Background failure from \(failure.source)") - } + let delegate = AppTransactionDelegate() + let store = TransactionStore( + subscriptionCatalog: subscriptionCatalog, + delegate: delegate ) - - if let activeEntitlements = store.activeEntitlements { - print("Active entitlements: \(activeEntitlements)") - } else { - print("Active entitlements are unresolved") - } + let viewModel = NotesViewModel(store: store) + print("Can export PDF: \(viewModel.canExportPDF)") try await store.close() } } diff --git a/Fixtures/ExternalConsumer/Tests/ConsumerTests/SubscriptionAccessTests.swift b/Fixtures/ExternalConsumer/Tests/ConsumerTests/SubscriptionAccessTests.swift new file mode 100644 index 0000000..b378285 --- /dev/null +++ b/Fixtures/ExternalConsumer/Tests/ConsumerTests/SubscriptionAccessTests.swift @@ -0,0 +1,24 @@ +import Consumer +import StoreTransactionKit +import StoreTransactionKitTesting +import Testing + +@Test +@MainActor +func subscriptionUpdatesViewModel() async throws { + try await withTransactionStoreTestHarness( + subscriptionCatalog: subscriptionCatalog + ) { harness in + let viewModel = NotesViewModel(store: harness.store) + + #expect(!viewModel.canExportPDF) + + let transaction = try await harness.purchase( + .tier1_Monthly, + in: Plans.self + ) + + #expect(transaction.productID == Plans.ProductID.tier1_Monthly.rawValue) + #expect(viewModel.canExportPDF) + } +} diff --git a/README.md b/README.md index 455711a..a2d1211 100644 --- a/README.md +++ b/README.md @@ -22,9 +22,6 @@ In Xcode, choose **File > Add Package Dependencies**, enter ## Quick start -The APIs shown below are proposed for the next beta. The current source does not -implement them yet. - Define the app's entitlements and one App Store Connect auto-renewable subscription group: @@ -41,7 +38,7 @@ enum Plans: AutoRenewableSubscriptionGroup { rawValue: "YOUR_SUBSCRIPTION_GROUP_ID" ) - enum ProductID: String { + enum ProductID: String, Hashable, Sendable { case tier1_Monthly = "com.example.subscription.tier1.monthly" case tier1_Yearly = "com.example.subscription.tier1.yearly" case tier2_Monthly = "com.example.subscription.tier2.monthly" @@ -176,8 +173,28 @@ durable transaction effect, handles a product outside the subscription catalog, or needs background-failure notifications. Without a delegate, automatic handling finishes only catalog-validated auto-renewable subscriptions. -For policy, redelivery, failure routing, and shutdown contracts, see the -[API design](Docs/AutoRenewableSubscriptionCatalogAPI.md). +Return `.finish` only after applying an app-owned effect durably. Throwing leaves +the transaction unfinished so a later StoreKit delivery can retry it. + +```swift +actor AppTransactionDelegate: TransactionStoreDelegate { + func decidePolicy( + for transaction: StoreTransactionSnapshot + ) async throws -> StoreTransactionHandlingPolicy { + try await purchaseLedger.apply(transaction) + return .finish + } + + func didFail( + with failure: StoreTransactionBackgroundFailure + ) async { + diagnostics.record(failure) + } +} +``` + +The store serializes policy decisions and background notifications separately. +Do not call back into the same store from either delegate method. ## Testing @@ -185,6 +202,7 @@ App and ViewModel tests can use `StoreTransactionKitTesting` without a `.storekit` configuration: ```swift +import StoreTransactionKit import StoreTransactionKitTesting import Testing @@ -212,6 +230,9 @@ func subscriptionUpdatesViewModel() async throws { test needs no timing guess. Inject `TransactionStoreTestClock` into the app component that owns a delay or deadline. +Add both `StoreTransactionKit` and `StoreTransactionKitTesting` to the test +target. Keep only `StoreTransactionKit` in the production target. + The app-hosted StoreKit integration suite continues to test the live adapter. See [Tools/TestApp/README.md](Tools/TestApp/README.md) for its scenarios and command. diff --git a/Sources/StoreTransactionKit/Entitlements/EntitlementStatus.swift b/Sources/StoreTransactionKit/Entitlements/EntitlementStatus.swift index 20ec6ba..d12e4ac 100644 --- a/Sources/StoreTransactionKit/Entitlements/EntitlementStatus.swift +++ b/Sources/StoreTransactionKit/Entitlements/EntitlementStatus.swift @@ -1,6 +1,6 @@ /// The availability of the store's typed entitlement projection. public enum EntitlementStatus: Sendable { - /// No entitlement readiness attempt has completed. + /// The initial entitlement reconciliation has not completed. case loading /// No usable complete entitlement snapshot is available. @@ -9,6 +9,9 @@ public enum EntitlementStatus: Sendable { case failed(any Error) /// A complete live entitlement snapshot is available. + /// + /// Both raw and typed entitlement collections are authoritative in this + /// state, including when they are empty. case ready /// App-supplied entitlements are authoritative instead of StoreKit state. diff --git a/Sources/StoreTransactionKit/StoreEntitlements.swift b/Sources/StoreTransactionKit/StoreEntitlements.swift index 7ac4f64..1043c4d 100644 --- a/Sources/StoreTransactionKit/StoreEntitlements.swift +++ b/Sources/StoreTransactionKit/StoreEntitlements.swift @@ -1,10 +1,10 @@ -/// A complete, ordered projection of the current StoreKit entitlements. +/// A complete, ordered projection of current transaction entitlements. public struct StoreEntitlements: Sendable, Equatable { - /// Verified current entitlements in a stable order. + /// Current transaction snapshots in a stable order. /// /// Transactions are ordered by product identifier UTF-8 bytes ascending, /// then purchase date ascending, transaction identifier ascending, and - /// exact JWS UTF-8 bytes ascending. + /// revision-representation UTF-8 bytes ascending. public let transactions: [StoreTransactionSnapshot] package init(transactions: [StoreTransactionSnapshot]) { diff --git a/Sources/StoreTransactionKit/StorePurchaseOutcome.swift b/Sources/StoreTransactionKit/StorePurchaseOutcome.swift index 57bb24f..2b93ee6 100644 --- a/Sources/StoreTransactionKit/StorePurchaseOutcome.swift +++ b/Sources/StoreTransactionKit/StorePurchaseOutcome.swift @@ -1,6 +1,6 @@ /// The semantic result of processing a direct StoreKit purchase result. public enum StorePurchaseOutcome: Sendable, Hashable { - /// StoreTransactionKit verified, durably handled, finished, and refreshed the transaction. + /// StoreTransactionKit verified, applied policy, finished, reconciled, and published the transaction. case completed(StoreTransactionSnapshot) /// The purchase is awaiting an external action and may arrive through transaction updates later. diff --git a/Sources/StoreTransactionKit/StoreTransactionKit.docc/DefiningSubscriptionAccess.md b/Sources/StoreTransactionKit/StoreTransactionKit.docc/DefiningSubscriptionAccess.md new file mode 100644 index 0000000..16541f5 --- /dev/null +++ b/Sources/StoreTransactionKit/StoreTransactionKit.docc/DefiningSubscriptionAccess.md @@ -0,0 +1,88 @@ +# Defining subscription access + +Map the Product IDs in one App Store Connect subscription group to values that +describe access in your app. + +## Declare the catalog + +An App Store Connect subscription group can contain several service levels and +several durations at each level. StoreKit owns group-level ordering, duration, +renewal, and billing state. Your app owns the access meaning. + +```swift +import StoreTransactionKit + +enum SubscriptionEntitlement: Hashable, Sendable { + case tier1 + case tier2 +} + +enum Plans: AutoRenewableSubscriptionGroup { + static let id = SubscriptionGroupID( + rawValue: "YOUR_SUBSCRIPTION_GROUP_ID" + ) + + enum ProductID: String, Hashable, Sendable { + case tier1_Monthly = "com.example.subscription.tier1.monthly" + case tier1_Yearly = "com.example.subscription.tier1.yearly" + case tier2_Monthly = "com.example.subscription.tier2.monthly" + case tier2_Yearly = "com.example.subscription.tier2.yearly" + } + + static var subscriptions: StoreSubscriptions { + StoreSubscription(.tier1_Monthly, entitlement: .tier1) + StoreSubscription(.tier1_Yearly, entitlement: .tier1) + StoreSubscription(.tier2_Monthly, entitlement: .tier2) + StoreSubscription(.tier2_Yearly, entitlement: .tier2) + } +} + +let subscriptionCatalog = AutoRenewableSubscriptionCatalog(Plans.self) +``` + +Use the group ID and Product ID raw values exactly as configured in App Store +Connect. ``AutoRenewableSubscriptionGroup/subscriptions`` is the complete +entitlement-granting declaration; a case that is present only in `ProductID` +doesn't grant typed access. The catalog requires at least one product, nonempty +raw identifiers, and no duplicate raw Product IDs. Several products may grant +the same entitlement. + +The compiler keeps group-specific Product IDs and app entitlements typed. At +runtime, the catalog also validates each matching transaction's auto-renewable +product type and subscription group before publishing access. + +## Read access without blocking the UI + +``TransactionStore/isEntitled(to:)`` performs exact set membership and returns +`false` while access is unavailable. Keep ordinary app content usable and gate +only the paid feature: + +```swift +private var canExportPDF: Bool { + store.isEntitled(to: .tier1) +} +``` + +Use ``TransactionStore/entitlementStatus`` only when the interface needs to +explain why access is unavailable. In `.ready`, an empty +``TransactionStore/activeEntitlements`` set means the query succeeded and no +declared entitlement is active. In `.loading` or `.failed`, the set is `nil`. + +## Override access at composition time + +For a preview, debug build, UI test, or app-defined distribution environment, +create a StoreKit-free store with an exact entitlement set: + +```swift +let store = TransactionStore( + subscriptionCatalog: subscriptionCatalog, + overridingEntitlements: [ + SubscriptionEntitlement.tier1, + .tier2, + ] +) +``` + +The app owns the condition selecting this initializer. An empty sequence is an +authoritative override with no access. Override mode starts no StoreKit work, +keeps raw entitlements `nil`, and rejects StoreKit-backed operations. diff --git a/Sources/StoreTransactionKit/StoreTransactionKit.docc/StoreTransactionKit.md b/Sources/StoreTransactionKit/StoreTransactionKit.docc/StoreTransactionKit.md index 9cd3960..2488885 100644 --- a/Sources/StoreTransactionKit/StoreTransactionKit.docc/StoreTransactionKit.md +++ b/Sources/StoreTransactionKit/StoreTransactionKit.docc/StoreTransactionKit.md @@ -1,62 +1,68 @@ # ``StoreTransactionKit`` -Own StoreKit 2 transaction monitoring at application lifetime and finish each -verified transaction only after its durable business effect succeeds. +Centralize verified transaction handling, `Transaction.finish()` authority, +and observable subscription access in one process-owned store. ## Overview -StoreKit can deliver a purchase as a direct `Product.PurchaseResult`, through -`Transaction.updates`, or as unfinished work on a later launch. -``TransactionStore`` normalizes these paths into one verified, FIFO transaction -processor and publishes observable current-entitlement state. It supports -iOS and Mac Catalyst 18.4 and later, macOS 15.4 and later, tvOS 18.4 and -later, watchOS 11.4 and later, and visionOS 2.4 and later. - -Create one store in the application composition root and retain it for the -process lifetime; call ``TransactionStore/close()`` only from controlled -shutdown and test lifecycles. Supply an idempotent transaction handler that -commits the app's business effect before returning. The app defines a -string-backed entitlement identifier type; -``TransactionStore/activeEntitlements`` then exposes an optional typed set -derived from StoreKit's verified current entitlements. Pass direct results -from custom purchase UI into ``TransactionStore/process(_:)``. - - describes the full model: delivery -paths and deduplication, unfinished-transaction reconciliation before each -entitlement publication, verification-failure reporting, and how the -projection behaves across upgrades, revocations, and grace periods. - -The framework owns StoreKit verification, process-local exact-revision -coalescing, `finish()`, entitlement refresh, history ordering, restore -synchronization, background failure delivery, and explicit shutdown. The app -continues to own persistence, server communication, access presentation, the -concrete purchase scene or window, raw `Product.SubscriptionInfo.Status` -interpretation for renewal UI, and, where the API is available, -`PurchaseIntent.intents` handling for purchases that begin outside the app. - -> Important: StoreTransactionKit exposes an at-least-once handler-delivery -> contract. Make the injected transaction handler durably idempotent using -> transaction identity and the business event it applies. Purchase and -> revocation revisions are distinct business events. Do not call StoreKit -> `finish()` or call back into the same store from the handler. +StoreKit can deliver the same purchase through a direct +`Product.PurchaseResult`, `Transaction.updates`, and unfinished-transaction +reconciliation. ``TransactionStore`` joins those paths by exact transaction +revision, applies one handling policy, and publishes raw and app-defined +entitlement state together on the main actor. + +Define one ``AutoRenewableSubscriptionCatalog`` from the Product IDs in an App +Store Connect subscription group. Several products, such as monthly and yearly +durations, may grant the same app entitlement. Create one live store at the +application composition root and inject that instance into feature code. + +Use ``TransactionStore/isEntitled(to:)`` for feature gating. When UI needs to +explain unavailable access, inspect ``TransactionStore/entitlementStatus`` to +distinguish the initial load, failure, a ready empty set, and an app-supplied +override. + +The optional ``TransactionStoreDelegate`` owns only app-specific durable +effects and background-failure reactions. Without a delegate, the default +policy finishes catalog-managed auto-renewable subscriptions. StoreTransactionKit +still verifies deliveries, reconciles unfinished work, validates catalog +metadata, orders history, restores purchases, reports background failures, and +drains admitted work during explicit shutdown. + +Start with , then read + before adding an app-owned transaction +effect. shows StoreKit-free ViewModel tests +that use the production store data flow. ## Topics ### Essentials +- - +- -### Creating a store +### Declaring subscriptions + +- ``AutoRenewableSubscriptionCatalog`` +- ``AutoRenewableSubscriptionGroup`` +- ``SubscriptionGroupID`` +- ``StoreSubscription`` +- ``StoreSubscriptionsBuilder`` + +### Reading entitlement state - ``TransactionStore`` +- ``EntitlementStatus`` - ``StoreEntitlements`` -### Processing purchases +### Processing transactions +- ``TransactionStoreDelegate`` +- ``StoreTransactionHandlingPolicy`` - ``StorePurchaseOutcome`` - ``StoreTransactionSnapshot`` -### Diagnosing lifecycle and background work +### Failures and lifecycle - ``StoreTransactionError`` - ``StoreTransactionVerificationError`` diff --git a/Sources/StoreTransactionKit/StoreTransactionKit.docc/TestingSubscriptionAccess.md b/Sources/StoreTransactionKit/StoreTransactionKit.docc/TestingSubscriptionAccess.md new file mode 100644 index 0000000..70fc2ee --- /dev/null +++ b/Sources/StoreTransactionKit/StoreTransactionKit.docc/TestingSubscriptionAccess.md @@ -0,0 +1,66 @@ +# Testing subscription access + +Drive the production transaction and entitlement pipeline without a `.storekit` +configuration. + +## Add the testing product + +Keep the production target dependent only on `StoreTransactionKit`. Add both +`StoreTransactionKit` and `StoreTransactionKitTesting` to the test target, then +import both modules explicitly: + +```swift +import StoreTransactionKit +import StoreTransactionKitTesting +import Testing +``` + +The testing product depends on the production module internally, but does not +re-export it. Explicit imports keep production and test-only dependencies clear. + +## Drive a typed purchase + +```swift +@Test +@MainActor +func subscriptionUpdatesViewModel() async throws { + try await withTransactionStoreTestHarness( + subscriptionCatalog: subscriptionCatalog + ) { harness in + let viewModel = NotesViewModel(store: harness.store) + + #expect(!viewModel.canExportPDF) + + try await harness.purchase( + .tier1_Monthly, + in: Plans.self + ) + + #expect(viewModel.canExportPDF) + } +} +``` + +The scoped harness starts with a ready empty entitlement set and closes its +store before returning or throwing. `purchase(_:,in:)` validates the supplied +group declaration and Product ID before admission, then returns after policy, +synthetic acknowledgement, reconciliation, and observable-state publication. +No fixed delay or global “idle” wait is needed. + +A later purchase in the same group replaces the active synthetic product. The +harness models immediate current access; it does not simulate renewal timing, +billing retry, upgrade scheduling, expiration, or revocation. + +The returned ``StoreTransactionSnapshot`` is synthetic. Its +``StoreTransactionSnapshot/jwsRepresentation`` is a deterministic sentinel, not +a signed JWS, and its transaction identifier is local to that harness. Use +app-hosted StoreKit tests for verification, live adapter behavior, StoreKit Test +sessions, renewals, restore UI, history, and revocation. + +## Control consumer-owned time + +The harness transaction pipeline has no delay or retry timer. Inject +`TransactionStoreTestClock` into the app component that owns time, such as a +ViewModel or delegate, and advance it explicitly. A harness purchase receipt +still marks entitlement publication; clock advancement alone does not imply +that transaction work is complete. diff --git a/Sources/StoreTransactionKit/StoreTransactionKit.docc/UnderstandingTransactionHandling.md b/Sources/StoreTransactionKit/StoreTransactionKit.docc/UnderstandingTransactionHandling.md index 14cf719..c01cdc1 100644 --- a/Sources/StoreTransactionKit/StoreTransactionKit.docc/UnderstandingTransactionHandling.md +++ b/Sources/StoreTransactionKit/StoreTransactionKit.docc/UnderstandingTransactionHandling.md @@ -1,131 +1,159 @@ # Understanding transaction handling -How StoreTransactionKit turns StoreKit 2 deliveries into durable, observable -entitlement state — and what that model asks of your handler and your UI. - -## Delivery paths and deduplication - -Every purchase converges on one FIFO transaction processor, whichever path it -arrives by: a direct `Product.PurchaseResult` passed to -``TransactionStore/process(_:)``, a delivery from `Transaction.updates`, or an -unfinished transaction reconciled at startup and on entitlement refreshes. - -Each delivery is identified by its exact JWS revision. When the same revision -arrives through several paths — StoreKit delivers launch-time unfinished -transactions through `Transaction.updates` as well — concurrent deliveries -join the in-flight attempt and completed revisions are suppressed. The -suppression cache is process-local and bounded, which is why the handler -contract is at-least-once rather than exactly-once: the handler must stay -idempotent across process launches and cache eviction. - -## Reconciliation before publication - -Startup and every entitlement refresh query `Transaction.unfinished` and -durably handle each verified delivery — including consumables — before the -entitlement projection is published, so published entitlement state never -runs ahead of the durable ledger for transactions this device still reports -as unfinished. A transaction that was already finished elsewhere — on another -device, or by a previous process — can appear in the projection without a -local handler invocation; while the app is running, purchases completed on -other devices reach the handler through `Transaction.updates`. - -When the handler throws, the transaction is not finished and that refresh (or -startup readiness) fails with the handler's error. The failed work is not -retried in a loop; the next refresh, or the next arriving transaction, opens a -new attempt and retries it. Because public operations other than -``TransactionStore/close()`` wait for the startup attempt, a handler that -hangs blocks the store — return or throw promptly and let a later refresh -retry. - -## Verification - -Snapshots exist only for transactions that StoreKit verified; the handler and -the projection never observe unverified data. An unverified purchase result -passed to ``TransactionStore/process(_:)`` throws a -``StoreTransactionVerificationError`` to that caller and is not reported to -the failure callback. Unverified elements accepted by background monitoring, -reconciliation, or projection-query paths are reported through the failure -callback instead: - -- From `Transaction.updates`, with source - ``StoreTransactionBackgroundFailure/Source/updates``. -- From `Transaction.unfinished` reconciliation, with source - ``StoreTransactionBackgroundFailure/Source/unfinished``. -- From a current-entitlement query, with source - ``StoreTransactionBackgroundFailure/Source/currentEntitlementVerification``; - the element is omitted and the verified remainder still publishes. - -## The entitlement projection - -``TransactionStore/entitlements`` is `nil` until the first query resolves and -non-`nil` empty when nothing is currently entitled. Its transactions follow -the stable order documented on ``StoreEntitlements/transactions``. StoreKit -itself excludes refunded and revoked transactions from current entitlements; -the store additionally excludes transactions superseded by a subscription -upgrade from ``TransactionStore/activeEntitlements`` while keeping them in the -complete snapshot. - -The projection refreshes at startup, after each processed transaction, on -subscription status changes, and on explicit -``TransactionStore/refreshEntitlements()`` or -``TransactionStore/restorePurchases()`` calls. - -Two subscription nuances live outside this projection. A subscription in a -billing grace period stays entitled while its snapshot's `expirationDate` is -already past, so render renewal and billing state from -`Product.SubscriptionInfo.Status` rather than from dates. And entitlement -identifiers map 1:1 to product identifiers, so gate access on the tier set or -on `StoreTransactionSnapshot.subscriptionGroupID` when any tier of a group -grants the same access. - -## Failure reporting and readiness - -Failure delivery and readiness state are related but separate contracts. -Public operations deliver terminal errors to their attached callers by -throwing. Background-owned physical work reports a failure once through the -failure callback as a ``StoreTransactionBackgroundFailure``. This includes -background deliveries, unfinished and current-entitlement verification, and -direct operations whose every waiting caller cancelled. - -Some public operations contain child work whose physical attempt may already -be owned by another producer. A failed `Transaction.unfinished` -reconciliation attempt both fails the enclosing startup or refresh and is -reported once by that attempt's reporting owner. Its source is -``StoreTransactionBackgroundFailure/Source/unfinished`` when reconciliation -owns the attempt, or the existing owner's source — such as -``StoreTransactionBackgroundFailure/Source/updates`` — when reconciliation -joins in-flight work. The enclosing operation propagates the underlying error -but doesn't report that physical failure a second time. - -The initial readiness attempt has no throwing public caller. -``TransactionStore/startupError`` reflects its error for observable UI state. -A failed startup unfinished reconciliation therefore appears both as one -owner-sourced report and as `startupError`; that report isn't necessarily -`.unfinished` when the attempt was already in flight. An entitlement query -failure with no separate reporting owner appears only as `startupError`. A -later successful entitlement refresh — which also retries failed unfinished -work — clears the property; call -``TransactionStore/refreshEntitlements()`` from a retry affordance in the UI. - -The failure callback receives admitted failures serially and losslessly with -backpressure. Every reporting path waits for the callback to return, and -``TransactionStore/close()`` drains admitted callbacks. Record or enqueue each -failure promptly instead of waiting indefinitely. - -## Lifecycle - -Create one store per process. A second store would run its own listeners and -hold independent `finish()` authority over the same transactions, so one -store's handler failure could be masked by the other store finishing first. - -``TransactionStore/close()`` stops the producers and drains every accepted -operation and callback; dropping the last reference is not an awaitable -shutdown. Production apps normally retain the store for the process lifetime -and never call it. - -The injected callbacks must not call back into the same store, directly or -through an awaited child or detached task: the callback runs on the same -worker the re-entrant operation would wait for, so the call becomes a -dependency cycle. Propagated-context reentrancy is rejected with -``StoreTransactionError/reentrantOperation(operation:)``; a detached task -escapes that guard but still forms the cycle. +Follow a transaction from admission through policy, finishing, entitlement +publication, failure ownership, and shutdown. + +## Exact-revision processing + +A direct `Product.PurchaseResult`, `Transaction.updates`, and +`Transaction.unfinished` can expose the same transaction. StoreTransactionKit +identifies a live delivery by the exact verified JWS revision. Concurrent +deliveries of that revision join one physical attempt instead of repeating +policy or `finish()`. + +After finishing, the revision enters a bounded process-local cache that +suppresses nearby redelivery. The cache is neither durable nor an app business +ledger: process launch or eviction can present the revision again. If a +``TransactionStoreDelegate`` applies an app-owned effect, make that effect +durably idempotent for the business event. Transaction ID alone is insufficient +because a later signed revision can describe a revocation or another change. + +## Policy and finishing + +The catalog validates and classifies every verified transaction before asking +the delegate for ``TransactionStoreDelegate/decidePolicy(for:)``: + +- A declared auto-renewable subscription with matching group metadata is + managed. ``StoreTransactionHandlingPolicy/automatic`` and + ``StoreTransactionHandlingPolicy/finish`` both allow finishing it. +- A product outside the catalog's group is unmanaged. `.automatic` throws + ``StoreTransactionError/unhandledTransaction(productID:productType:)``; + `.finish` allows finishing only after the app has durably handled it. +- Contradictory metadata or an undeclared current product inside the managed + group fails catalog validation before the delegate runs and is never + finished. + +If the delegate throws, the framework does not finish the transaction or start +its causal entitlement refresh. A later independent StoreKit delivery opens a +new attempt; the framework adds no timer or retry loop. + +For a successful purchase, +``TransactionStore/process(_:)`` returns +``StorePurchaseOutcome/completed(_:)`` only after policy, `finish()`, unfinished +reconciliation, catalog projection, and main-actor publication complete. +If the refresh fails after `finish()`, the method instead throws +``StoreTransactionError/entitlementRefreshFailed(after:underlyingError:)`` with +``StoreTransactionError/CompletedOperation/finishedTransaction(_:)``. Retry +``TransactionStore/refreshEntitlements()``; do not repeat the completed action. + +The same boundary applies to restore. A synchronization failure is returned +directly. If `AppStore.sync()` succeeds and its following refresh fails, +``TransactionStore/restorePurchases()`` throws the completed-operation error +with ``StoreTransactionError/CompletedOperation/synchronizedPurchases``. + +## Reconciliation and publication + +The initial load and every entitlement refresh handle all verified unfinished +transactions before publishing `Transaction.currentEntitlements`. This keeps +the observable projection from running ahead of unfinished durable work. +Current entitlements that were finished by another process or device may still +appear without a local policy invocation. + +``TransactionStore/entitlements``, +``TransactionStore/activeEntitlements``, and +``TransactionStore/entitlementStatus`` derive from one availability value: + +| Status | Raw entitlements | Typed entitlements | +| --- | --- | --- | +| ``EntitlementStatus/loading`` | `nil` | `nil` | +| ``EntitlementStatus/failed(_:)`` | `nil` | `nil` | +| ``EntitlementStatus/ready`` | authoritative collection | authoritative set | +| ``EntitlementStatus/overridden`` | `nil` | authoritative app-supplied set | + +An empty ready or overridden set means no entitlement; it is not unresolved. +A transient query or transaction-handling failure preserves an existing ready +snapshot. A catalog contradiction clears raw and typed projections and moves +to `.failed`, because stale typed access may be unsafe. A later successful +refresh publishes a new ready snapshot. + +The catalog maps declared Product IDs to app entitlement values. It does not +copy StoreKit group levels or subscription periods into the entitlement type, +and multiple Product IDs may map to the same value. Upgraded transactions stay +in the raw projection but grant no typed access. Products outside the managed +group stay raw and do not enter the typed set. + +StoreKit excludes revoked or refunded transactions from current entitlements. +For billing retry, grace period, and renewal presentation, use +`Product.SubscriptionInfo.Status`; do not infer subscription status only from +snapshot dates. + +## Failure ownership + +An admitted physical failure has one delivery owner. While a direct caller +remains attached, the operation throws to that caller and does not also send the +same failure through the background callback. If every attached caller cancels +after admission, the physical work continues and the last abandonment transfers +failure ownership to the background path. + +Background-owned failures are logged once and, when supplied, delivered once to +``TransactionStoreDelegate/didFail(with:)``. This includes update processing, +unfinished reconciliation, current-entitlement verification failures, and +abandoned direct operations. The callback is a notification after the failure; +it cannot change policy or request retry. + +Failures that affect observable entitlement state commit that state before +notification begins. Notifications are serialized with backpressure and +``TransactionStore/close()`` drains every admitted notification. Policy +decisions are also serialized, but policy and notification execution may +overlap. + +An unverified direct purchase throws ``StoreTransactionVerificationError`` to +its caller. An unverified background element is reported instead. Unverified +current-entitlement elements are omitted while the verified remainder can still +publish. + +## Admission and cancellation + +`process(_:)`, `refreshEntitlements()`, `history(for:)`, and +`restorePurchases()` check cancellation before crossing their admission +boundary. Pre-admission cancellation starts no physical work. After admission, +caller cancellation abandons only that caller's wait: policy, finishing, +refresh, publication, and any required background reporting continue. + +When closing has sealed admission, a new operation throws +``StoreTransactionError/closing``. After shutdown completes, it throws +``StoreTransactionError/closed``. The override initializer has no StoreKit +backend and its StoreKit operations throw +``StoreTransactionError/operationUnavailableInOverride(operation:)``. + +Delegate methods must not call an admission-bearing operation on the same +store. Such a call can wait behind the callback that is making it. Reentry with +inherited callback context throws +``StoreTransactionError/reentrantOperation(operation:)``. A detached task does +not inherit that detection context, but awaiting one from the callback still +creates the same dependency cycle and must also be avoided. + +## Close and deinitialization + +Create one live ``TransactionStore`` per process. A second live initializer is +a programmer error while the first store owns transaction monitoring and +finishing authority. Override stores and StoreTransactionKitTesting harnesses +do not consume that live-store lease. + +The first ``TransactionStore/close()`` call atomically seals public and producer +admission and starts one terminal shutdown. Concurrent close calls join the same +completion, and caller cancellation does not abandon it. Shutdown stops StoreKit +producers, drains admitted producer elements and public operations, drains +transaction, refresh, restore, and failure workers, releases the delegate, and +then releases the live-store lease. The last entitlement state remains readable. + +Calling `close()` from a callback owned by the same store throws a reentrancy +error. Production apps normally retain the store for process lifetime; explicit +close is primarily for controlled shutdown and tests. + +An isolated deinitializer is only a synchronous containment backstop. It seals +new admission and signals cancellation to framework-owned work, but cannot await +callbacks or claim a successful drain. Admitted work retains lifecycle authority +until it terminates, so another live store may not be constructible immediately +after an unclosed store is released. `close()` is the only awaitable replacement +boundary. diff --git a/Sources/StoreTransactionKit/StoreTransactionSnapshot.swift b/Sources/StoreTransactionKit/StoreTransactionSnapshot.swift index fcfca65..31d634f 100644 --- a/Sources/StoreTransactionKit/StoreTransactionSnapshot.swift +++ b/Sources/StoreTransactionKit/StoreTransactionSnapshot.swift @@ -1,11 +1,12 @@ import Foundation import StoreKit -/// An immutable projection of a transaction that StoreKit verified. +/// An immutable projection of a verified or test-harness transaction. /// -/// StoreTransactionKit creates snapshots only after StoreKit verification -/// succeeds. A snapshot never owns the underlying `Transaction` and -/// exposes no authority to finish it. +/// StoreTransactionKit creates live snapshots only after StoreKit verification +/// succeeds. StoreTransactionKitTesting creates deterministic synthetic +/// snapshots for its transaction-store harness. A snapshot never owns the +/// underlying `Transaction` and exposes no authority to finish it. public struct StoreTransactionSnapshot: Sendable, Hashable { /// The identifier of this transaction revision's transaction. public let id: UInt64 @@ -75,11 +76,13 @@ public struct StoreTransactionSnapshot: Sendable, Hashable { /// The date the App Store signed this transaction revision. public let signedDate: Date - /// The exact JWS Compact Serialization that StoreKit verified. + /// The exact verified JWS, or a deterministic testing revision sentinel. /// /// StoreTransactionKit uses the UTF-8 bytes of this value as its /// process-local delivery revision. Consumers must not treat it as a secret - /// or persist it as the sole business idempotency key. + /// or persist it as the sole business idempotency key. Synthetic snapshots + /// use `StoreTransactionKitTesting.synthetic.` instead of a + /// signed JWS; their transaction identifiers restart for each harness. public let jwsRepresentation: String package init( diff --git a/Sources/StoreTransactionKit/Subscriptions/AutoRenewableSubscriptionCatalog.swift b/Sources/StoreTransactionKit/Subscriptions/AutoRenewableSubscriptionCatalog.swift index 9e349ec..b40438d 100644 --- a/Sources/StoreTransactionKit/Subscriptions/AutoRenewableSubscriptionCatalog.swift +++ b/Sources/StoreTransactionKit/Subscriptions/AutoRenewableSubscriptionCatalog.swift @@ -1,6 +1,10 @@ import StoreKit -/// A validated mapping from StoreKit products to app-defined entitlements. +/// A validated mapping from one auto-renewable subscription group to app entitlements. +/// +/// The catalog treats ``AutoRenewableSubscriptionGroup/subscriptions`` as the +/// complete declaration of product identifiers that can grant typed access and +/// validates StoreKit product type and group metadata before publication. public struct AutoRenewableSubscriptionCatalog: Sendable where Entitlement: Hashable & Sendable { package let subscriptionGroupID: SubscriptionGroupID @@ -9,6 +13,10 @@ where Entitlement: Hashable & Sendable { private let entitlementsByProductID: [Product.ID: Entitlement] /// Creates and validates a catalog from one auto-renewable subscription group. + /// + /// An empty declaration, empty product identifier, or duplicate raw product + /// identifier is a programmer error. Multiple products may grant the same + /// entitlement. public init(_ groupType: Group.Type) where Group: AutoRenewableSubscriptionGroup { let subscriptions = Group.subscriptions diff --git a/Sources/StoreTransactionKit/Subscriptions/AutoRenewableSubscriptionCatalogError.swift b/Sources/StoreTransactionKit/Subscriptions/AutoRenewableSubscriptionCatalogError.swift index 4babcd9..3ce1fd4 100644 --- a/Sources/StoreTransactionKit/Subscriptions/AutoRenewableSubscriptionCatalogError.swift +++ b/Sources/StoreTransactionKit/Subscriptions/AutoRenewableSubscriptionCatalogError.swift @@ -1,7 +1,7 @@ import Foundation import StoreKit -/// An inconsistency between a verified StoreKit transaction and a subscription catalog. +/// An inconsistency between a transaction snapshot and a subscription catalog. public enum AutoRenewableSubscriptionCatalogError: LocalizedError, Sendable { /// A current product in the managed group has no catalog declaration. case undeclaredProduct( diff --git a/Sources/StoreTransactionKit/Subscriptions/AutoRenewableSubscriptionGroup.swift b/Sources/StoreTransactionKit/Subscriptions/AutoRenewableSubscriptionGroup.swift index 03c9d7b..4b1a63a 100644 --- a/Sources/StoreTransactionKit/Subscriptions/AutoRenewableSubscriptionGroup.swift +++ b/Sources/StoreTransactionKit/Subscriptions/AutoRenewableSubscriptionGroup.swift @@ -1,4 +1,8 @@ /// A typed declaration of one App Store auto-renewable subscription group. +/// +/// The group binds typed product identifiers to app-domain entitlement values. +/// StoreKit continues to own subscription duration and upgrade or downgrade +/// ordering; the entitlement type describes access in the app. public protocol AutoRenewableSubscriptionGroup { /// The app-defined access value granted by the group's products. associatedtype Entitlement: Hashable & Sendable @@ -11,7 +15,7 @@ public protocol AutoRenewableSubscriptionGroup { /// The group's identifier in App Store Connect. static var id: SubscriptionGroupID { get } - /// The products managed by the group and the entitlement each one grants. + /// The complete set of declared products and the entitlement each one grants. @StoreSubscriptionsBuilder< Self.ProductID, Self.Entitlement diff --git a/Sources/StoreTransactionKit/Subscriptions/StoreSubscription.swift b/Sources/StoreTransactionKit/Subscriptions/StoreSubscription.swift index 560d582..9763654 100644 --- a/Sources/StoreTransactionKit/Subscriptions/StoreSubscription.swift +++ b/Sources/StoreTransactionKit/Subscriptions/StoreSubscription.swift @@ -1,4 +1,6 @@ /// A product declaration and the app entitlement it grants. +/// +/// Monthly and yearly products may intentionally grant the same entitlement. public struct StoreSubscription: Sendable where ProductID: RawRepresentable & Hashable & Sendable, diff --git a/Sources/StoreTransactionKit/TransactionStore.swift b/Sources/StoreTransactionKit/TransactionStore.swift index 146855f..14d1d56 100644 --- a/Sources/StoreTransactionKit/TransactionStore.swift +++ b/Sources/StoreTransactionKit/TransactionStore.swift @@ -2,6 +2,11 @@ import Observation import StoreKit /// An observable, process-owned StoreKit transaction and entitlement store. +/// +/// A live store monitors StoreKit deliveries, owns `Transaction.finish()` +/// authority, and publishes raw and app-defined entitlement state together on +/// the main actor. Create one live instance at the application composition root +/// and retain it for the process lifetime. @MainActor @Observable public final class TransactionStore @@ -35,6 +40,9 @@ where Entitlement: Hashable & Sendable { } /// The availability of the typed entitlement projection. + /// + /// Inspect this value when the UI needs to distinguish loading or failure + /// from a ready empty entitlement set. public var entitlementStatus: EntitlementStatus { switch availability { case .loading: @@ -49,6 +57,9 @@ where Entitlement: Hashable & Sendable { } /// The latest complete raw StoreKit entitlement projection. + /// + /// This value is non-`nil` only in ``EntitlementStatus/ready``. Override + /// mode has no synthetic raw StoreKit projection. public var entitlements: StoreEntitlements? { guard case .ready(let entitlements, _) = availability else { return nil @@ -57,6 +68,10 @@ where Entitlement: Hashable & Sendable { } /// The app-defined entitlements granted by the current catalog projection. + /// + /// A non-`nil` empty set authoritatively means that no declared entitlement + /// is active. The value is `nil` while loading or when no usable projection + /// is available after failure. public var activeEntitlements: Set? { switch availability { case .ready(_, let activeEntitlements), @@ -71,7 +86,12 @@ where Entitlement: Hashable & Sendable { @ObservationIgnored private let backend: Backend private var availability: EntitlementAvailability - /// Creates one live StoreKit store for an auto-renewable subscription catalog. + /// Creates the process's live StoreKit store for an auto-renewable subscription catalog. + /// + /// Initialization starts transaction monitoring and the first entitlement + /// reconciliation. The store strongly retains `delegate` until terminal + /// shutdown. Creating a second live store in the same process before the + /// first store finishes ``close()`` is a programmer error. public convenience init( subscriptionCatalog: AutoRenewableSubscriptionCatalog, delegate: (any TransactionStoreDelegate)? = nil @@ -88,6 +108,10 @@ where Entitlement: Hashable & Sendable { } /// Creates a StoreKit-free store with one authoritative entitlement set. + /// + /// The sequence is normalized to a set and published immediately with + /// ``EntitlementStatus/overridden``. This store performs no StoreKit work, + /// has no raw ``entitlements``, and rejects StoreKit-backed operations. public convenience init( subscriptionCatalog: AutoRenewableSubscriptionCatalog, overridingEntitlements: some Sequence @@ -201,11 +225,18 @@ where Entitlement: Hashable & Sendable { } /// Returns whether the exact app-defined entitlement is active. + /// + /// This method returns `false` while entitlement state is unavailable. public func isEntitled(to entitlement: Entitlement) -> Bool { activeEntitlements?.contains(entitlement) == true } /// Processes a direct result from custom purchase UI. + /// + /// A successful verified purchase completes only after policy selection, + /// finishing, causal entitlement reconciliation, and main-actor publication. + /// Pending and user-cancelled results return their corresponding semantic + /// outcome without transaction processing. public func process( _ result: Product.PurchaseResult ) async throws -> StorePurchaseOutcome { @@ -246,7 +277,9 @@ where Entitlement: Hashable & Sendable { return snapshot } - /// Refreshes and publishes the current StoreKit entitlement projection. + /// Reconciles unfinished transactions and publishes current entitlements. + /// + /// Raw and typed entitlement values are validated and committed atomically. @discardableResult public func refreshEntitlements() async throws -> StoreEntitlements { let admission = try admit(operation: .refreshEntitlements) @@ -256,6 +289,8 @@ where Entitlement: Hashable & Sendable { } /// Returns verified transaction history for one product. + /// + /// Results are all-or-nothing and ordered newest first. public func history( for productID: Product.ID ) async throws -> [StoreTransactionSnapshot] { @@ -267,6 +302,10 @@ where Entitlement: Hashable & Sendable { } /// Synchronizes App Store purchases and refreshes entitlements. + /// + /// If synchronization succeeds but refresh fails, this method throws + /// ``StoreTransactionError/entitlementRefreshFailed(after:underlyingError:)`` + /// with ``StoreTransactionError/CompletedOperation/synchronizedPurchases``. @discardableResult public func restorePurchases() async throws -> StoreEntitlements { let admission = try admit(operation: .restorePurchases) @@ -276,6 +315,12 @@ where Entitlement: Hashable & Sendable { } /// Stops producers and drains every operation accepted before closing. + /// + /// The first call seals admission and starts one shared terminal shutdown. + /// Concurrent calls join it, and later calls return successfully. Caller + /// cancellation does not abandon shutdown. Calling this method from a + /// callback owned by this store throws + /// ``StoreTransactionError/reentrantOperation(operation:)``. public func close() async throws { try rejectReentrancy(operation: .close) switch backend { diff --git a/Sources/StoreTransactionKit/TransactionStoreDelegate.swift b/Sources/StoreTransactionKit/TransactionStoreDelegate.swift index f41a589..d63a05a 100644 --- a/Sources/StoreTransactionKit/TransactionStoreDelegate.swift +++ b/Sources/StoreTransactionKit/TransactionStoreDelegate.swift @@ -7,20 +7,24 @@ public enum StoreTransactionHandlingPolicy: Sendable, Hashable { case automatic /// Finishes a transaction after the app has durably applied its business effect. + /// + /// Use this policy for an unmanaged product only after the app has committed + /// its idempotent business effect. case finish } /// Receives transaction decisions and background failure notifications. /// /// The delegate is optional because both requirements have default -/// implementations. Conforming types may use an actor or provide their own -/// synchronization; the protocol does not prescribe an actor. +/// implementations. Policy decisions and failure notifications are each +/// serialized, but the two streams may overlap. public protocol TransactionStoreDelegate: AnyObject, Sendable { /// Chooses how to handle a verified transaction. /// - /// StoreTransactionKit invokes decisions serially. Throwing prevents the - /// transaction from being finished and prevents its causal entitlement - /// refresh. + /// StoreTransactionKit invokes decisions serially after catalog + /// classification. Throwing prevents the transaction from being finished + /// and prevents its causal entitlement refresh. A later independent + /// StoreKit delivery may retry the exact revision. func decidePolicy( for transaction: StoreTransactionSnapshot ) async throws -> StoreTransactionHandlingPolicy @@ -28,8 +32,9 @@ public protocol TransactionStoreDelegate: AnyObject, Sendable { /// Notifies the delegate of a failure owned by background work. /// /// This notification cannot alter the completed operation or request a - /// retry. When a failure changes observable entitlement state, that state - /// is committed before this method begins. + /// retry. Delivery is serialized and applies backpressure. When a failure + /// changes observable entitlement state, that state is committed before + /// this method begins. func didFail( with failure: StoreTransactionBackgroundFailure ) async From d17bd5c2579acefb18da658092c907cee4f0d2eb Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Thu, 23 Jul 2026 11:47:42 +0900 Subject: [PATCH 19/26] ci(test): run external consumer tests --- .github/workflows/ci.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d00e767..37bff82 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -92,7 +92,7 @@ jobs: -destination 'generic/platform=macOS' \ -derivedDataPath .build/docc-ci - - name: Build external consumer fixture + - name: Build and test external consumer fixture working-directory: Fixtures/ExternalConsumer run: | swift package resolve @@ -101,7 +101,7 @@ jobs: echo "External consumer resolved an app-only dependency." >&2 exit 1 fi - swift build --build-system swiftbuild + swift test --build-system swiftbuild supported-platform-builds: name: Test Build (${{ matrix.name }}) From f7db25c5452771098b3fc893b99a64108be0ab38 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Thu, 23 Jul 2026 11:49:22 +0900 Subject: [PATCH 20/26] docs(api): remove implemented design proposal --- Docs/AutoRenewableSubscriptionCatalogAPI.md | 1287 ------------------- 1 file changed, 1287 deletions(-) delete mode 100644 Docs/AutoRenewableSubscriptionCatalogAPI.md diff --git a/Docs/AutoRenewableSubscriptionCatalogAPI.md b/Docs/AutoRenewableSubscriptionCatalogAPI.md deleted file mode 100644 index 2760d12..0000000 --- a/Docs/AutoRenewableSubscriptionCatalogAPI.md +++ /dev/null @@ -1,1287 +0,0 @@ -# Auto-renewable subscription API design - -Status: Proposed for the next beta API. - -This document is the source of truth for the proposal. The README presents its -consumer-facing shape and labels it as proposed; the public source and symbol -documentation continue to describe the currently released API until the -implementation transaction is complete. After implementation, the public -contracts move to symbol DocC and a consumer article, and this temporary design -document is removed. - -## Purpose - -StoreTransactionKit needs to translate StoreKit Product IDs into the app's -feature-access vocabulary without making Product IDs themselves the public -entitlement type. The first consumer is an app with one App Store Connect -auto-renewable subscription group containing multiple access levels and -multiple durations at each level. - -The same entitlement domain must support an app-selected StoreKit bypass and -deterministic app or ViewModel tests. Those paths use the production state and -transaction pipeline without inventing StoreKit transactions in app code. - -## Goals - -- Scope the Product ID type to one auto-renewable subscription group. -- Map multiple billing durations at one access level to one app entitlement. -- Make the subscription declaration the single source of catalog membership and - app entitlement mapping. -- Keep StoreKit group levels and renewal periods in StoreKit rather than copying - them into the catalog. -- Validate every piece of verified transaction metadata that the static catalog - can know. -- Publish raw and typed entitlement state as one atomic snapshot. -- Distinguish unavailable entitlement state from an available empty set. -- Keep normal app UI usable while entitlement state is unavailable. -- Finish automatically only for a catalog-validated auto-renewable transaction. -- Give other product types an explicit app-owned handling decision. -- Make every background-owned failure observable without requiring a delegate. -- Allow fixed entitlement overrides without framework-owned environment checks. -- Let tests drive the production pipeline without a `.storekit` file or timing - guesses. -- Make terminal shutdown and the single-live-store invariant enforceable. - -## Non-goals - -- The initial catalog does not compose multiple subscription groups. Supporting - that requires per-group availability and failure isolation rather than one - all-or-nothing entitlement projection. -- The catalog does not describe consumables, non-consumables, or non-renewing - subscriptions. -- The catalog does not own prices, localized merchandising, purchase UI, - renewal UI, or `Product.SubscriptionInfo.Status`. -- The framework does not infer app access from StoreKit `groupLevel`. -- The framework does not infer an entitlement for an undeclared Product ID. -- The framework does not detect TestFlight, previews, debug builds, receipts, - or other environments to select override mode. -- The no-configuration test harness does not validate StoreKit verification, - JWS, App Store Connect metadata, system purchase UI, or StoreKit renewal - scheduling. -- Advancing a test clock does not mean that the transaction pipeline is idle or - that an entitlement publication has completed. -- Source compatibility with the current Product-ID-as-entitlement API is not a - goal while the package is beta. - -## StoreKit model - -An App Store Connect subscription group contains auto-renewable subscriptions -with different access levels and durations. A customer holds one subscription -in a group at a time. Subscriptions at one level may have monthly and yearly -variants. - -StoreKit owns these facts: - -- `Product.SubscriptionInfo.subscriptionGroupID` identifies the group. -- `Product.SubscriptionInfo.groupLevel` orders upgrade and downgrade paths; - level `1` is the highest service level. -- `Product.SubscriptionInfo.subscriptionPeriod` describes the renewal period. -- `Transaction.currentEntitlements` includes current non-consumables, - qualifying auto-renewable subscriptions, and non-renewing subscriptions. It - excludes consumables. - -The app owns access meaning. `SubscriptionEntitlement.tier1` is an app-domain -value, not a copy of `groupLevel == 1`. The explicit Product ID mapping is the -boundary between those models. - -## Consumer story - -Define the app entitlements and the Product IDs belonging to one subscription -group: - -```swift -import StoreTransactionKit - -enum SubscriptionEntitlement: Hashable, Sendable { - case tier1 - case tier2 -} - -enum Plans: AutoRenewableSubscriptionGroup { - static let id = SubscriptionGroupID( - rawValue: "YOUR_SUBSCRIPTION_GROUP_ID" - ) - - enum ProductID: String, Hashable, Sendable { - case tier1_Monthly = "com.example.subscription.tier1.monthly" - case tier1_Yearly = "com.example.subscription.tier1.yearly" - case tier2_Monthly = "com.example.subscription.tier2.monthly" - case tier2_Yearly = "com.example.subscription.tier2.yearly" - } - - static var subscriptions: StoreSubscriptions { - StoreSubscription(.tier1_Monthly, entitlement: .tier1) - StoreSubscription(.tier1_Yearly, entitlement: .tier1) - StoreSubscription(.tier2_Monthly, entitlement: .tier2) - StoreSubscription(.tier2_Yearly, entitlement: .tier2) - } -} - -let subscriptionCatalog = AutoRenewableSubscriptionCatalog(Plans.self) -``` - -Create one live store at the process composition root and inject that same -instance into SwiftUI: - -```swift -import StoreTransactionKit -import SwiftUI - -@main -struct ExampleApp: App { - @State private var store: TransactionStore - - init() { - _store = State( - initialValue: TransactionStore( - subscriptionCatalog: subscriptionCatalog - ) - ) - } - - var body: some Scene { - WindowGroup { - NavigationStack { - ContentView() - } - .environment(store) - } - } -} -``` - -Gate only the paid feature. Loading or a failed entitlement query does not -replace the rest of the view: - -```swift -import StoreKit -import StoreTransactionKit -import SwiftUI - -struct ContentView: View { - @Environment(TransactionStore.self) private var store - @State private var isShowingPaywall = false - - private var canExportPDF: Bool { - store.isEntitled(to: .tier1) - } - - var body: some View { - List { - Section { - NavigationLink("All notes") { - NotesView() - } - } - - Section { - Button("Export as PDF") { - exportPDF() - } - .disabled(!canExportPDF) - - Button("Plans and subscriptions") { - isShowingPaywall = true - } - } header: { - Text("Premium") - } - } - .sheet(isPresented: $isShowingPaywall) { - SubscriptionStoreView(groupID: Plans.id.rawValue) - } - } -} -``` - -Monthly and yearly subscriptions granting the same access map to the same -entitlement. StoreKit owns upgrade and downgrade ordering. If several plan -identities grant one feature, the app checks the accepted entitlement values; -the catalog does not infer tier inclusion. - -No active subscription is represented by `.ready` with an empty -`activeEntitlements` set. It is distinct from `.loading` or `.failed`, where -`activeEntitlements` is `nil`. - -For an app-defined environment that bypasses StoreKit, provide the exact set to -activate: - -```swift -let store = TransactionStore( - subscriptionCatalog: subscriptionCatalog, - overridingEntitlements: [ - SubscriptionEntitlement.tier1, - .tier2, - ] -) -``` - -The app owns the condition selecting this initializer. An empty sequence means -override mode with no active entitlement. - -## Proposed public interface - -```swift -public struct SubscriptionGroupID: - RawRepresentable, - Hashable, - Sendable -{ - public let rawValue: String - - public init(rawValue: String) -} - -public struct StoreSubscription: - Sendable -where - ProductID: RawRepresentable & Hashable & Sendable, - Entitlement: Hashable & Sendable -{ - public let id: ProductID - public let entitlement: Entitlement - - public init( - _ id: ProductID, - entitlement: Entitlement - ) -} - -@resultBuilder -public struct StoreSubscriptionsBuilder -where - ProductID: RawRepresentable & Hashable & Sendable, - Entitlement: Hashable & Sendable -{ - public typealias Element = - StoreSubscription - - public static func buildExpression( - _ expression: Element - ) -> Element - - public static func buildBlock( - _ first: Element, - _ rest: Element... - ) -> [Element] -} - -public protocol AutoRenewableSubscriptionGroup { - associatedtype Entitlement: Hashable & Sendable - associatedtype ProductID: - RawRepresentable & Hashable & Sendable - - static var id: SubscriptionGroupID { get } - - @StoreSubscriptionsBuilder< - Self.ProductID, - Self.Entitlement - > - static var subscriptions: Self.StoreSubscriptions { get } -} - -public extension AutoRenewableSubscriptionGroup { - typealias StoreSubscriptions = - [StoreSubscription] -} - -public struct AutoRenewableSubscriptionCatalog: Sendable -where Entitlement: Hashable & Sendable { - public init(_ groupType: Group.Type) - where Group: AutoRenewableSubscriptionGroup -} - -public enum AutoRenewableSubscriptionCatalogError: LocalizedError, Sendable { - case undeclaredProduct( - productID: Product.ID, - subscriptionGroupID: SubscriptionGroupID - ) - case productTypeMismatch( - productID: Product.ID, - actual: Product.ProductType - ) - case subscriptionGroupMismatch( - productID: Product.ID, - expected: SubscriptionGroupID, - actual: String? - ) - - public var errorDescription: String? { get } -} - -public enum EntitlementStatus: Sendable { - case loading - case failed(any Error) - case ready - case overridden -} - -public enum StoreTransactionOperation: Sendable, Hashable { - case processPurchase - case refreshEntitlements - case history - case restorePurchases - case close -} - -public enum StoreTransactionError: Error, Sendable { - public enum CompletedOperation: Sendable, Hashable { - case finishedTransaction(StoreTransactionSnapshot) - case synchronizedPurchases - } - - case closing - case closed - case unknownPurchaseResult - case unhandledTransaction( - productID: Product.ID, - productType: Product.ProductType - ) - case reentrantOperation(operation: StoreTransactionOperation) - case operationUnavailableInOverride( - operation: StoreTransactionOperation - ) - case entitlementRefreshFailed( - after: CompletedOperation, - underlyingError: any Error - ) -} - -public enum StoreTransactionHandlingPolicy: Sendable, Hashable { - case automatic - case finish -} - -public protocol TransactionStoreDelegate: AnyObject, Sendable { - func decidePolicy( - for transaction: StoreTransactionSnapshot - ) async throws -> StoreTransactionHandlingPolicy - - func didFail( - with failure: StoreTransactionBackgroundFailure - ) async -} - -public extension TransactionStoreDelegate { - func decidePolicy( - for transaction: StoreTransactionSnapshot - ) async throws -> StoreTransactionHandlingPolicy { - .automatic - } - - func didFail( - with failure: StoreTransactionBackgroundFailure - ) async {} -} - -public enum StorePurchaseOutcome: Sendable, Hashable { - case completed(StoreTransactionSnapshot) - case pending - case userCancelled -} - -@MainActor -@Observable -public final class TransactionStore -where Entitlement: Hashable & Sendable { - public var entitlementStatus: EntitlementStatus { get } - public var entitlements: StoreEntitlements? { get } - public var activeEntitlements: Set? { get } - - public func isEntitled(to entitlement: Entitlement) -> Bool - - public init( - subscriptionCatalog: AutoRenewableSubscriptionCatalog, - delegate: (any TransactionStoreDelegate)? = nil - ) - - public init( - subscriptionCatalog: AutoRenewableSubscriptionCatalog, - overridingEntitlements: some Sequence - ) - - public func process( - _ result: Product.PurchaseResult - ) async throws -> StorePurchaseOutcome - - @discardableResult - public func refreshEntitlements() async throws -> StoreEntitlements - - public func history( - for productID: Product.ID - ) async throws -> [StoreTransactionSnapshot] - - @discardableResult - public func restorePurchases() async throws -> StoreEntitlements - - public func close() async throws -} -``` - -`StoreTransactionError` is not `Hashable`: the post-completion failure preserves -an arbitrary underlying error, and no consumer requires errors as collection -keys. - -## Catalog contract - -### Type-safety boundary - -The nested Product ID type prevents a Product ID declared for another group -from being passed to a group-specific API. The primary associated type in -`AutoRenewableSubscriptionGroup` fixes the app -entitlement type at the conformance, and the builder then accepts only that -group's `ProductID` and entitlement values. `SubscriptionGroupID` prevents group -IDs from being confused with Product IDs. Feature code sees the app's -`Entitlement`, not raw identifiers. - -`subscriptions` is the single source of catalog membership and mapping. -Declaring a case or static member on `ProductID` does not by itself make that -identifier a managed subscription; a `StoreSubscription` entry does. -The catalog therefore does not require `CaseIterable` or reconcile a second -list of identifiers with the builder output. - -The compiler cannot validate App Store Connect. Runtime validation is therefore -part of the catalog contract. The name `SubscriptionGroupID` mirrors StoreKit's -`subscriptionGroupID`; the auto-renewable qualifier belongs on the group and -catalog types that define subscription scope. - -The client-conformance protocol stays intentionally small. The catalog consumes -`subscriptions` synchronously and stores normalized strings, entitlement values, -and the `ObjectIdentifier` of the declaring group type. It retains no group -instance, group metatype, or typed Product ID. The declaration identity is used -only to prevent a testing command from substituting another conformance with -the same raw identifiers but a different mapping. Future optional metadata -belongs in a configuration value or catalog initializer rather than a new -protocol requirement. - -### Apple API analog - -This declaration shape follows the Xcode 27 `Evaluations` framework in three -places: `Evaluation.Evaluators` resolves a nested collection alias from a -conformance's associated types, `EvaluatorsBuilder` makes that collection -declarative, and `Evaluator` provides an inline concrete element. Here, -`StoreSubscriptions` resolves to an array of inline `StoreSubscription` values. - -This is an API-shape analog only. StoreTransactionKit does not import -`Evaluations`, and adopting the shape does not raise the package's deployment -targets to OS 27. - -The collection property is named `subscriptions`, matching -`SubscriptionStoreView.init(subscriptions:)`. The unqualified `Subscription` -and `Subscriptions` names are not used: Combine already exports a protocol and -namespace with those exact names. `StoreSubscription` and -`StoreSubscriptions` retain the subscription vocabulary while avoiding that -collision. The enclosing group and catalog types retain the `AutoRenewable` -qualifier because they define the StoreKit product scope. - -The analogy stops at the storage boundary. Evaluations needs -`any EvaluatorProtocol` because one list can contain different -evaluator implementations. Every subscription entry has the same -`ProductID`-plus-`Entitlement` shape, so StoreTransactionKit uses one generic -value and introduces no per-subscription protocol, existential, closure-backed -mapping, or type erasure. - -The group remains a static schema and the catalog initializer continues to take -its metatype. `buildExpression` gives each `StoreSubscription` initializer the -group's concrete `ProductID` and `Entitlement` context. The builder DSL exposes -only those element expressions and flat `buildBlock` composition; it does not -add optional, either, or array syntax. A witness getter can bypass the builder -transformation with an explicit `return`, so the API does not claim to make -runtime-dependent declarations unrepresentable. The catalog evaluates the -getter once during construction, snapshots that returned declaration, and does -not observe later getter results. - -### Construction - -`SubscriptionGroupID.init(rawValue:)` preconditions that its value is not empty. -Catalog construction evaluates the static subscription declaration once, -performs no StoreKit request, and preconditions that: - -- At least one subscription is declared. -- Every Product ID raw value is nonempty. -- No raw Product ID is repeated within the group. - -These are static programmer errors, so the initializer remains nonthrowing. -Duplicate entitlement values are valid and expected for monthly and yearly -subscriptions at one access level. Declaration order has no semantic meaning; -the catalog normalizes entries into its lookup. - -### Runtime validation and projection - -A Product ID is declared only when it appears in a `subscriptions` entry. For -each verified transaction in a candidate `StoreEntitlements` snapshot, the -catalog applies these rules before publication: - -1. A declared Product ID must have `productType == .autoRenewable`. -2. A declared Product ID must have the catalog's subscription group ID. -3. A declared, non-upgraded transaction maps to its typed entitlement. -4. A declared transaction with `isUpgraded == true` remains raw but grants no - typed access. -5. An undeclared, non-upgraded Product ID in the catalog's group fails with - `undeclaredProduct`; the framework cannot infer its access meaning. -6. An undeclared upgraded transaction in the catalog's group is accepted only - when its type is `.autoRenewable`; any other type fails with - `productTypeMismatch`. A valid upgraded transaction remains raw, can be - finished by `.automatic`, and grants no typed access. This permits retiring - a Product ID after no supported customer can hold it as current. -7. A product outside the catalog's group remains raw and is ignored by the - typed projection. - -Every applicable transaction is validated before anything is published. -Successful mappings form a `Set`, so multiple durations can produce one -entitlement value. - -The catalog is a closed declaration of the group it manages. Adding a product -in App Store Connect can therefore make an older binary report -`undeclaredProduct` after a customer moves to it. Product rollout must account -for supported older app versions; guessing a tier could grant the wrong access. - -## Transaction handling policy - -The catalog classifies each verified transaction before the delegate runs: - -- A **managed** transaction is a catalog-declared, metadata-valid - auto-renewable transaction. An undeclared upgraded transaction in the managed - group is also managed for finishing after its type and group are validated, - but it cannot grant typed access. -- An **invalid** transaction is declared with the wrong type or group, is an - undeclared non-upgraded product inside the managed group, or is an undeclared - upgraded product in that group whose type is not `.autoRenewable`. It fails - before the delegate runs and is never finished. -- An **unmanaged** transaction belongs outside the catalog, such as a - consumable, non-consumable, non-renewing subscription, or an auto-renewable - subscription in another group. - -The delegate decision is requested for managed and unmanaged transactions: - -- `.automatic` finishes a managed transaction and throws - `unhandledTransaction` for an unmanaged one. It is not unconditional finish. -- `.finish` means the app has durably applied this business event, or its - idempotency ledger proves that the event was already applied. The framework - then calls `finish()`. -- If `decidePolicy(for:)` throws, the framework does not call `finish()` and - does not run the causal entitlement refresh. A direct operation throws the - error; background-owned work reports it. A later independent StoreKit - delivery can present the exact revision again. The framework starts no timer - or backoff retry. - -There is no normal “keep unfinished” policy. StoreKit may still include an -unfinished auto-renewable transaction in `Transaction.currentEntitlements`, so -not calling `finish()` does not guarantee that access is withheld. A future -deferral feature would need transaction suppression and a corresponding public -availability state, not only another policy case. StoreKit purchase deferral is -already represented by `StorePurchaseOutcome.pending`. - -The decision/notification split follows the same structure as -`WKNavigationDelegate`: one method returns policy before a consequential action; -the other reports a failure that has already occurred and returns no policy. -Both requirements have defaults, so the delegate is optional and may implement -only the behavior it owns. - -The protocol is class-bound and `Sendable`, but not actor-bound. Its public -contract describes isolation requirements rather than prescribing whether a -consumer uses an actor or a synchronized class. - -### Exact-revision ownership - -Direct purchase results, `Transaction.updates`, and `Transaction.unfinished` -reconciliation attach to one causal decision receipt for an exact transaction -revision. Coalesced deliveries share that receipt instead of repeating delegate -work. The receipt stays active through policy, `finish()`, causal refresh, and -ordered MainActor publication. - -After `finish()` succeeds, the exact revision enters a bounded process-local -completed cache. The cache suppresses nearby duplicate deliveries but is not a -durable business ledger. Eviction may allow the revision to be presented again, -so every app-owned effect remains idempotent. Revision identity includes changes -such as revocation; transaction ID alone is not sufficient. - -### Failure after a completed action - -If `process(_:)` finishes a transaction and its causal entitlement refresh then -fails, it throws: - -```swift -StoreTransactionError.entitlementRefreshFailed( - after: .finishedTransaction(transaction), - underlyingError: error -) -``` - -The exact revision is already recorded as completed. The consumer must not -reapply its business effect, repeat a purchase, or rerun `process(_:)` only to -recover. Its next operation is `refreshEntitlements()`. - -`StorePurchaseOutcome.completed` is returned only after policy, finish, refresh, -catalog projection, and atomic MainActor publication all complete. A -post-finish refresh failure returns no outcome and throws the typed error above. - -If `AppStore.sync()` itself fails, `restorePurchases()` throws the original -error. If synchronization succeeds and the following refresh fails, it throws -`entitlementRefreshFailed(after: .synchronizedPurchases, underlyingError:)`. -The consumer retries `refreshEntitlements()` rather than immediately presenting -restore authentication again. - -The physical refresh coordinator completes with the root refresh or catalog -error only. Each attached direct receipt adds its own completed-action context: -a process receipt whose finish succeeded creates `.finishedTransaction`, a -restore receipt whose sync succeeded creates `.synchronizedPurchases`, and a -plain refresh receipt returns the root error unchanged. This remains correct -when those callers coalesce into one physical batch. - -If the physical failure is background-owned, the entitlement-refresh background -failure also stores the root error rather than one caller's completed-action -wrapper. Observable `EntitlementStatus` stores that same root error because it -explains readiness. Completed revisions and restore completion remain recorded -by their respective operation owners. - -## Fixed entitlement override - -`overridingEntitlements` is a composition-root choice, not mutable runtime -state. The initializer consumes a finite sequence, normalizes it once to a -`Set`, and publishes `.overridden` immediately. An empty sequence is an -authoritative empty set; it does not select live StoreKit behavior. - -An override store: - -- Starts no StoreKit source, monitor, query, or transaction processing. -- Does not retain or invoke a delegate. -- Publishes the normalized typed set and answers exact membership queries. -- Keeps raw `entitlements == nil`; it invents no StoreKit snapshots. -- Throws `operationUnavailableInOverride(operation:)` from every StoreKit - operation before starting work. -- Makes `close()` successful and idempotent. - -The app owns whether a preview, internal build, TestFlight build, UI test, or -another environment uses this initializer. There is no “unlock everything” -Boolean because the framework does not know the app's complete entitlement -universe. - -The catalog remains an initializer argument so live and override composition -share the same entitlement domain. No entitlement is reverse-mapped to a -Product ID because several products can intentionally grant the same value. - -## Atomic entitlement publication - -Raw and typed entitlement values describe one StoreKit query and commit -together. Projection and validation run inside the entitlement refresh -coordinator after unfinished processing and before any snapshot is exposed or -any receipt completes. - -```swift -private struct EntitlementPublication: Sendable -where Entitlement: Hashable & Sendable { - let entitlements: StoreEntitlements - let activeEntitlements: Set -} - -private enum EntitlementRefreshOutcome: Sendable -where Entitlement: Hashable & Sendable { - case success(EntitlementPublication) - case transientFailure(any Error) - case catalogFailure(AutoRenewableSubscriptionCatalogError) -} -``` - -One reducer owns the public state: - -```swift -private enum EntitlementAvailability { - case loading - case failed(any Error) - case ready( - entitlements: StoreEntitlements, - activeEntitlements: Set - ) - case overridden(activeEntitlements: Set) -} -``` - -`entitlementStatus`, `entitlements`, and `activeEntitlements` are computed from -that value. There are no independently mutated mirror properties. - -| Status | `entitlements` | `activeEntitlements` | Meaning | -| --- | --- | --- | --- | -| `.loading` | `nil` | `nil` | No readiness attempt has completed. | -| `.failed(error)` | `nil` | `nil` | No usable complete snapshot exists. | -| `.ready` | non-`nil` | non-`nil` | A complete live snapshot is available; empty means no entitlement. | -| `.overridden` | `nil` | non-`nil` | The app-supplied set is authoritative; empty means no entitlement. | - -State transitions are: - -| Event | Result | -| --- | --- | -| Live initialization | `.loading`. | -| Override initialization | `.overridden` with the normalized set. | -| Successful candidate | `.ready` with one new atomic snapshot. | -| Query or transaction-handling failure with no prior snapshot | `.failed(error)`. | -| Query or transaction-handling failure after `.ready` | Preserve the last `.ready` snapshot. | -| Catalog failure | `.failed(error)` and clear both projections, even after `.ready`. | -| Successful empty query | `.ready` with two empty collections. | -| Unverified current-entitlement element | Omit it, report it, and publish the verified remainder. | -| Close | Preserve the last entitlement state. | - -A catalog contradiction fails closed because preserving an older typed set could -continue granting a higher tier after a move to an undeclared lower-tier -product. A transient query failure preserves a known-good ready snapshot. - -`isEntitled(to:)` performs exact membership in `.ready` and `.overridden`. It -returns `false` while loading, after a readiness failure, or when the available -set does not contain the value. Consumers inspect `entitlementStatus` only when -they need to explain the reason. - -## Failure routing and observability - -Failure delivery follows ownership of the physical work: - -| Failure | Observable state | Direct caller | Background owner | -| --- | --- | --- | --- | -| Invalid static catalog | Store is not created | None | `precondition` failure | -| StoreKit operation in override mode | Preserve `.overridden` | Throw `operationUnavailableInOverride` | None | -| Explicit query or handling failure | Fail or preserve according to the state table | Throw | No duplicate report while attached | -| Startup or background query/handling failure | Fail or preserve according to the state table | None | Record and optionally notify once | -| Explicit catalog failure | Invalidate projections | Throw | No duplicate report while attached | -| Startup or background catalog failure | Invalidate projections | None | Record and optionally notify once | -| Current-entitlement verification failure | Publish verified remainder | Attached operation may succeed | Record and optionally notify once | -| Post-finish or post-sync refresh failure | Apply the underlying refresh transition | Throw typed completed-action error | Record and optionally notify once when background-owned | - -Every physical batch has one reporting authority. Direct participation is bound -at admission, before work can fail. If any attached direct caller receives the -error, it is not also a background failure. If all direct callers abandon the -work, ownership transfers to the background authority and the error is reported -once. - -Every background-owned failure is first recorded through a package-owned -unified `Logger`, whether or not a delegate exists. A supplied delegate then -receives the same failure through `didFail(with:)`. Internal logging is -best-effort observability: it cannot change policy or completion, has no public -injection surface, and never records JWS data. Direct errors returned to an -attached caller are not logged as background failures. - -For a failure that changes observable entitlement state, the reducer commit -completes before logging and before `didFail(with:)` begins. A delegate may -therefore inspect the corresponding state, but it cannot alter that state or -request retry by returning a value. - -Background notifications are serialized with backpressure. Decisions are also -serialized, but decision and notification delivery are independent and may -overlap. `close()` drains both. - -## Product-type boundary - -`AutoRenewableSubscriptionCatalog` maps only auto-renewable subscriptions: - -- Non-consumables and non-renewing subscriptions may appear in raw - `StoreEntitlements` but do not produce typed catalog entitlements. -- Consumables never appear in `Transaction.currentEntitlements`; they still - reach transaction handling. An app that owns a consumable balance supplies a - delegate and returns `.finish` only after applying that balance durably. -- `.automatic` rejects every unmanaged product, so the default path never - finishes a product with no business-effect owner. - -`TransactionStore` remains the process-wide transaction monitor and finish -authority across product types. A future typed non-consumable catalog is a -separate design, not another member of `AutoRenewableSubscriptionGroup`. - -## Ownership map - -| Responsibility | Owner | -| --- | --- | -| Group ID, typed Product IDs, catalog membership, and entitlement mapping | App-defined `AutoRenewableSubscriptionGroup.subscriptions` | -| Catalog lookup and verified metadata validation | `AutoRenewableSubscriptionCatalog` | -| Choosing live or override mode | App composition root | -| Fixed override normalization and publication | `TransactionStore` availability owner | -| StoreKit query and unfinished reconciliation | `CurrentEntitlementReconciler` | -| Exact-revision admission, decision receipt, and completed cache | Transaction processing coordinator | -| Projection, refresh coalescing, ordered completion, and atomic publication | Entitlement refresh coordinator | -| Observable availability | `TransactionStore` reducer | -| Process-wide live-monitoring lease, admission, and shared close completion | Non-generic internal lifecycle authority | -| Exactly-once direct/background failure selection | Runtime reporting authority | -| Unified background logging | Runtime reporting authority and package logger | -| App-specific policy, durable effect, and failure reaction | App `TransactionStoreDelegate` | -| Product merchandising and subscription-status presentation | App using StoreKit directly | -| Synthetic source, command receipt, and test lifecycle | `StoreTransactionKitTesting` harness | -| Timed app behavior | The app component with an injected `Clock` | -| Virtual time and sleep-registration barriers | `TransactionStoreTestClock` | - -No UI type owns semantic entitlement state, and no second Product ID mapping is -performed outside the catalog. - -## Lifecycle and concurrency - -### Live-store lease - -The live initializer synchronously acquires one process-wide exclusive lease -before retaining a delegate or creating a StoreKit producer. A second live -initializer while that lease is active is a precondition failure. The lease is -shared across every generic specialization of `TransactionStore`. - -The lease is held by a non-generic internal lifetime authority, not only by the -observable facade. `close()` releases it after terminal shutdown. Override -stores and synthetic stores created by `StoreTransactionKitTesting` do not -acquire it because they neither monitor live StoreKit sequences nor own live -`finish()` authority. - -Dropping a store is not an awaitable replacement protocol. Code that needs a -different live store first awaits `close()`. - -### Admission and cancellation - -Each admission-bearing operation — `process(_:)`, `refreshEntitlements()`, -`history(for:)`, and `restorePurchases()` — checks cancellation immediately -before acquiring its operation lease. Successful lease acquisition is the -admission boundary. - -- Cancellation before admission throws `CancellationError` and starts no - operation-specific StoreKit work. -- Cancellation after admission abandons only that caller's wait. The physical - decision, finish, refresh, publication, and failure routing continue to - terminal completion. -- If the cancelled caller was the last direct observer of a later failure, that - failure becomes background-owned and is reported once. -- `.pending` and `.userCancelled` results create no durable transaction work and - check cancellation before returning. -- `close()` is the exception: it begins or joins terminal shutdown even when the - caller is already cancelled, and every caller waits for the shared completion. - -Admission-bearing operations accepted while running complete. New -admission-bearing operations after shutdown has been sealed throw `.closing`; -after terminal completion they throw `.closed`. Repeated `close()` calls still -succeed, and the last observable entitlement state remains readable. - -### Delegate reentrancy - -The store strongly retains its delegate until terminal shutdown. A delegate -that references the store must hold that reference weakly. - -A delegate must not start an admission-bearing operation on the same store from -either callback. Inherited callback context lets the runtime reject direct calls -and `Task {}` child calls with -`reentrantOperation(operation:)`. `Task.detached` intentionally drops task-local -context, so Swift's actor isolation, `@isolated(any)`, `sending`, -`SendableMetatype`, and actor-context inheritance cannot prove that detached -call's ancestry. Starting a detached operation remains unsupported whether or -not the callback awaits it: awaiting can create a dependency cycle, while -fire-and-forget work escapes callback ownership. The contract does not claim -detached provenance is detectable. - -A process-wide “callback active” gate is not used because it would reject -unrelated operations that merely overlap a suspended callback. - -### Shared close - -The first accepted `close()` publishes one shared, noncancellable completion -before suspending. Concurrent callers join it; calls after closure return -successfully without effect. - -Before awaiting `AsyncSequence.next()`, each live producer acquires an iteration -lease. Sealing producer admission prevents a new `next()` call but does not -invalidate a lease already waiting for or handling an element. If an element is -returned while close races with that wait, the producer hands it to a processing -coordinator-owned, noncancellable terminal receipt before observing cancellation -and waits for that receipt without propagating producer cancellation into it. -Producer task cancellation interrupts the sequence wait, not physical work -admitted from a returned element. - -Terminal shutdown executes in this order: - -1. Transition to `closing`; seal public-operation admission and new producer - iteration admission. -2. Cancel the startup waiter and StoreKit producer tasks. -3. Await producer termination; callbacks admitted before sealing remain - admitted. -4. Await every admitted direct operation, decision, `finish()`, entitlement - refresh, ordered publication, and causal receipt. -5. Seal and drain background-failure delivery. -6. Release the strongly retained delegate. -7. Release the live-store lease and enter `closed`. - -After `close()` returns, no framework-owned task, StoreKit producer, delegate -invocation, or publication from that store remains active. The last entitlement -state remains readable. - -Calling `close()` from a callback owned by the same store throws -`reentrantOperation(operation: .close)` because waiting for that callback is -part of close completion. - -### Deinitialization backstop - -`TransactionStore` uses `isolated deinit` only for synchronous containment. It -must synchronously seal public and producer admission, then signal cancellation -to startup, producers, and finite framework tasks. It does not start an -unstructured cleanup task, await callbacks, claim shutdown completion, or -release the live lease directly. - -Runtime-owned work retains the lifetime token until every task admitted before -that seal terminates. Deinitialization does not promise a successful drain; -admitted work may instead reach terminal cancellation and background-failure -routing. Constructing another live store immediately after dropping an unclosed -one may therefore still fail the lease precondition. Explicit `close()` is the -only awaitable replacement boundary. - -## Deterministic consumer testing - -The package adds a second public SwiftPM product with a one-way dependency: - -```text -StoreTransactionKitTesting - ↓ -StoreTransactionKit -``` - -Production targets import `StoreTransactionKit`. Test targets may import -`StoreTransactionKitTesting`, which creates a real `TransactionStore` around a -package-scoped synthetic source. It does not reimplement transaction handling, -catalog projection, or observable state. - -```swift -public final class TransactionStoreTestClock: Clock, Sendable { - public typealias Duration = Swift.Duration - - public struct Instant: InstantProtocol, Sendable { - public typealias Duration = Swift.Duration - - public static let zero: Instant - - public func advanced(by duration: Duration) -> Instant - public func duration(to other: Instant) -> Duration - - public static func < (lhs: Instant, rhs: Instant) -> Bool - } - - public var now: Instant { get } - public var minimumResolution: Duration { get } - - public init(now: Instant = .zero) - - public func sleep( - until deadline: Instant, - tolerance: Duration? - ) async throws - - public func advance(by duration: Duration) - - public func waitUntilPendingSleepCount( - reaches count: Int - ) async throws -} - -@MainActor -public final class TransactionStoreTestHarness -where Entitlement: Hashable & Sendable { - public let store: TransactionStore - - @discardableResult - public func purchase( - _ productID: Group.ProductID, - in groupType: Group.Type - ) async throws -> StoreTransactionSnapshot - where Group: AutoRenewableSubscriptionGroup -} - -public enum TransactionStoreTestHarnessError: - LocalizedError, - Sendable, - Hashable -{ - case subscriptionGroupMismatch( - expected: SubscriptionGroupID, - actual: SubscriptionGroupID - ) - case subscriptionGroupTypeMismatch( - subscriptionGroupID: SubscriptionGroupID - ) - case undeclaredProduct( - productID: String, - subscriptionGroupID: SubscriptionGroupID - ) - case operationUnavailable(operation: StoreTransactionOperation) - - public var errorDescription: String? { get } -} - -@MainActor -public func withTransactionStoreTestHarness( - subscriptionCatalog: AutoRenewableSubscriptionCatalog, - delegate: (any TransactionStoreDelegate)? = nil, - _ operation: @MainActor ( - TransactionStoreTestHarness - ) async throws -> Result -) async throws -> Result -where Entitlement: Hashable & Sendable -``` - -The scoped function owns construction and cleanup. It closes and drains the -store before returning on success, failure, or cancellation, then rethrows the -operation error. A retained harness value is already closed after the scope. - -Construction completes an empty synthetic entitlement query before invoking the -closure, so the initial state is `.ready` with empty raw and typed collections. -The harness validates that the supplied group and Product ID belong to the -catalog. It accepts a Product ID rather than an entitlement because an -entitlement cannot be reverse-mapped to one monthly or yearly product. - -A group whose ID differs from the catalog throws -`subscriptionGroupMismatch(expected:actual:)`. A different group declaration -that reuses the same ID throws -`subscriptionGroupTypeMismatch(subscriptionGroupID:)`; the catalog's declaring -group and `subscriptions` mapping remain authoritative. A raw Product ID absent -from that declaration throws `undeclaredProduct(productID:subscriptionGroupID:)`. -All checks complete before synthetic transaction admission, invoke no delegate -method, and leave state unchanged. - -`purchase(_:,in:)` returns only after: - -1. The synthetic transaction is admitted through the production direct path. -2. The delegate or `.automatic` resolver returns a policy. -3. The synthetic transaction is acknowledged as finished. -4. Current synthetic entitlements are reconciled and projected. -5. `TransactionStore` publishes the resulting state on `@MainActor`. - -It returns the completed `StoreTransactionSnapshot` only after that receipt -completes. Pre-admission harness validation and delegate decision failures throw -directly and are not duplicated as background failures. A refresh or projection -failure after synthetic acknowledgement follows the production -`entitlementRefreshFailed(after: .finishedTransaction(...))` contract. -Supplying a delegate tests the app's real policy decision; the harness does not -add a second public failure-capture state. - -A later `purchase` of another Product ID in the same group removes the prior -snapshot from the synthetic current-entitlement set before the new causal -refresh. It neither retains that snapshot nor marks it `isUpgraded`. This -supports a deterministic tier1-to-tier2 ViewModel test while explicitly -modeling only an immediately effective active product, not App Store scheduling -or metadata for upgrades, downgrades, renewals, or billing retry. Expiration, -revocation, and superseded-transaction projection remain app-hosted or -package-level StoreKit scenarios until they have independent public command -contracts. - -The harness exposes no “wait until globally idle”: monitoring tasks are -long-lived, so global quiescence is not meaningful. Each mutating command is its -own completion receipt and does not promise a SwiftUI render pass or completion -of consumer-owned unstructured tasks. - -### Synthetic store operation matrix - -The harness exposes its `TransactionStore` so production ViewModels use their -real dependency. That does not give the lease-exempt synthetic store live -StoreKit authority: - -| Store surface | Synthetic behavior | -| --- | --- | -| Entitlement properties and `isEntitled(to:)` | Read the production availability reducer. | -| `refreshEntitlements()` | Reconcile and publish the synthetic current-entitlement set. | -| `close()` | Drain the synthetic runtime; repeated calls succeed. | -| `process(_:)` | Throw `operationUnavailable(operation: .processPurchase)` before inspecting or finishing a live transaction. | -| `history(for:)` | Throw `operationUnavailable(operation: .history)` before source work. | -| `restorePurchases()` | Throw `operationUnavailable(operation: .restorePurchases)` without calling `AppStore.sync()`. | - -`purchase(_:,in:)` is the only public synthetic mutation command in the initial -surface. Unsupported store operations leave state unchanged and do not invoke -the delegate. A synthetic purchase exercises the production finish-decision -boundary against a synthetic acknowledgement; it never calls -`Transaction.finish()` on a live StoreKit value. - -### Clock contract - -The harness itself accepts no Clock because its production transaction work has -no delay, timeout, or retry policy. `TransactionStoreTestClock` is injected into -the app component that owns time, such as a delegate or ViewModel. Clock -advancement releases sleepers; the purchase receipt still proves entitlement -publication. - -```swift -final class DelayedTransactionDelegate: TransactionStoreDelegate { - private let clock: any Clock - - init(clock: any Clock) { - self.clock = clock - } - - func decidePolicy( - for transaction: StoreTransactionSnapshot - ) async throws -> StoreTransactionHandlingPolicy { - try await clock.sleep(for: .seconds(30)) - return .finish - } -} - -let clock = TransactionStoreTestClock() -let delegate = DelayedTransactionDelegate(clock: clock) - -try await withTransactionStoreTestHarness( - subscriptionCatalog: subscriptionCatalog, - delegate: delegate -) { harness in - let viewModel = NotesViewModel(store: harness.store) - - let purchase = Task { @MainActor in - try await harness.purchase( - .tier1_Monthly, - in: Plans.self - ) - } - - try await clock.waitUntilPendingSleepCount(reaches: 1) - #expect(!viewModel.canExportPDF) - - clock.advance(by: .seconds(30)) - try await purchase.value - - #expect(viewModel.canExportPDF) -} -``` - -The registration barrier is continuation-backed; tests do not use fixed sleeps -or guessed `Task.yield()` counts. Negative clock advances and negative sleeper -counts are programmer errors. Cancelling a sleeper removes it and throws -`CancellationError` according to the `Clock` contract. - -The public harness initially exposes only `purchase(_:,in:)` as a synthetic -mutation command. App-hosted `.storekit` tests remain responsible for the live -StoreKit adapter, verification, StoreKit Test session behavior, renewal -scheduling, restore UI, history, expiration, and revocation. - -## Required contract tests - -### Catalog and state - -- Every monthly and yearly `StoreSubscription` maps to its expected - entitlement. -- Empty group IDs, subscription declarations, and Product ID raw values fail at - construction. -- Duplicate raw Product IDs fail; duplicate entitlement values remain valid. -- A typed Product ID that has no `subscriptions` entry is not a catalog member - and is rejected by typed testing commands before admission. -- Known Product IDs with a wrong type or group fail projection. -- An undeclared non-upgraded Product ID in the managed group fails projection. -- An undeclared upgraded Product ID in the managed group with a non-auto- - renewable type fails with `productTypeMismatch` before delegate policy. -- An external-group Product ID remains raw and does not enter the typed set. -- An upgraded managed-group transaction remains raw, grants no typed access, - and can be handled without retaining a retired Product ID declaration. -- Initial live state, ready-empty state, failed state, and override-empty state - preserve the state table's `nil` distinctions. -- Every publication changes raw state, typed state, and status atomically. -- Catalog contradictions clear stale access; transient refresh failures preserve - an earlier ready snapshot. -- `isEntitled(to:)` is observed through the single availability owner and uses - exact membership. - -### Processing, failure routing, and lifecycle - -- `.automatic` finishes only a validated managed auto-renewable transaction. -- `.finish` is the only app-selected path to finish an unmanaged transaction. -- A thrown decision performs no finish or causal refresh and is redeliverable. -- A post-finish refresh failure records the revision as completed, throws - `entitlementRefreshFailed(after: .finishedTransaction(...))`, and recovers via - `refreshEntitlements()` without repeating policy or finish. -- A sync failure throws its original error; a post-sync refresh failure throws - the completed-operation error and recovers without repeating sync. -- When process, restore, and plain refresh receipts coalesce on one failed - physical refresh, each direct caller receives its own wrapper or root error; - an abandoned batch produces one background report containing the root error. -- `StorePurchaseOutcome.completed` is returned only after MainActor publication. -- Completed-revision suppression is bounded and never substitutes for an app - ledger. -- Coalesced direct, update, and unfinished deliveries decide one exact revision - once per active receipt. -- Direct errors are not duplicated as background logs or notifications; an - abandoned direct failure transfers to the background owner once. -- Every background failure reaches the internal diagnostic sink once even with - no delegate. A supplied delegate receives it after the related state commit. -- The store retains its delegate until close drains decisions and notifications. -- Direct and inherited child-task callback reentry is rejected. Detached reentry - is documented as unsupported without claiming provenance detection. -- A second live initializer fails across different `Entitlement` types. -- Override and multiple synthetic stores do not consume the live lease. -- Close seals admission before producer shutdown, joins concurrent callers, - ignores waiter cancellation, and releases the lease only after complete drain. -- A producer holding an iteration lease before `next()` processes an element - returned concurrently with close; no later iteration begins after the seal, - and close waits for its policy, finish, refresh, and publication. -- Close completion guarantees no later framework task, callback, or publication. -- Cancellation before admission starts no work; cancellation after admission - detaches the caller and lets the operation complete under background ownership. -- Deinit cancellation retains the live lease until runtime termination. - -### Testing and distribution - -- After implementation, the external production fixture builds the README - examples against public API. -- A testing fixture starts ready-empty, purchases a typed Product ID, and reads - its ViewModel change immediately after the command returns without `.storekit`. -- Wrong group ID, substituted group declaration, and undeclared Product ID - commands throw their testing errors before admission and leave state and - delegate calls unchanged. -- A second purchase in the same group replaces the active synthetic product and - publishes the newly mapped entitlement without retaining a synthetic upgraded - snapshot. -- Harness validation and decision errors reach the command caller directly; - failures after acknowledgement use the production completed-action wrapper. -- A synthetic store refreshes its synthetic set and closes normally; process, - history, and restore throw `operationUnavailable` before live StoreKit work. -- Passing a real purchase result to a synthetic store cannot call live - `Transaction.finish()` or bypass the process-wide live lease. -- Scoped cleanup drains after success, failure, and cancellation and remains - idempotent if the operation called `store.close()`. -- A timed app dependency reaches a registered sleeper, exposes intermediate - state, advances virtual time, and still awaits the purchase publication - receipt without fixed sleeps. -- The test clock releases only due sleepers, removes cancelled sleepers, and - resumes a cancelled sleep with `CancellationError`. -- The sleep-registration barrier handles multiple waiters and cancellation - without polling; invalid negative advances or counts fail in subprocess - precondition tests. -- App-hosted StoreKit tests cover monthly/yearly products, real upgrade and - downgrade behavior, restore, renewal, expiration, revocation, and recovery. -- The testing product is not imported transitively by production consumers. -- Swift 6 strict-concurrency builds cover the specialized primary-associated- - type conformance, nested `StoreSubscriptions` alias, generic - `StoreSubscriptionsBuilder`, Clock existential, actor/class delegate, and - Sendable surfaces. -- Symbol DocC and the consumer article build without warnings. - -## Implementation transaction - -The redesign is complete only when one change updates all of the following: - -- Public source and symbol documentation. -- Unit and app-hosted StoreKit tests. -- The `StoreTransactionKitTesting` product and its one-way target dependency. -- Package-scoped production seams used by the synthetic source. -- Production and testing external consumer fixtures. -- The README examples, removal of their proposal label, and hosted DocC - examples. -- Every dependent app and its resolved package revision. - -Until that transaction lands, the README labels the consumer sketch as proposed -and the current symbol documentation remains authoritative for compilable API. -Once the contract is implemented, the proposal label is removed and the -contract moves into symbol DocC and a consumer article; this document is then -deleted. No compatibility alias or deprecated initializer is planned while the -package is beta. - -## References - -- [Offer auto-renewable subscriptions](https://developer.apple.com/help/app-store-connect/manage-subscriptions/offer-auto-renewable-subscriptions/) -- [`Product.SubscriptionInfo.subscriptionGroupID`](https://developer.apple.com/documentation/storekit/product/subscriptioninfo/subscriptiongroupid) -- [`Product.SubscriptionInfo.groupLevel`](https://developer.apple.com/documentation/storekit/product/subscriptioninfo/grouplevel) -- [`Product.SubscriptionInfo.subscriptionPeriod`](https://developer.apple.com/documentation/storekit/product/subscriptioninfo/subscriptionperiod) -- [`Transaction.isUpgraded`](https://developer.apple.com/documentation/storekit/transaction/isupgraded) -- [`Transaction.currentEntitlements`](https://developer.apple.com/documentation/storekit/transaction/currententitlements) -- [`SubscriptionStoreView.init(subscriptions:)`](https://developer.apple.com/documentation/storekit/subscriptionstoreview/init(subscriptions:)) -- [`Combine.Subscription`](https://developer.apple.com/documentation/combine/subscription) -- [`Combine.Subscriptions`](https://developer.apple.com/documentation/combine/subscriptions) -- [`Evaluation`](https://developer.apple.com/documentation/evaluations/evaluation) -- [`Evaluation.Evaluators`](https://developer.apple.com/documentation/evaluations/evaluation/evaluators-swift.typealias) -- [`EvaluatorsBuilder`](https://developer.apple.com/documentation/evaluations/evaluatorsbuilder) -- [`EvaluatorProtocol`](https://developer.apple.com/documentation/evaluations/evaluatorprotocol) -- [`Evaluator`](https://developer.apple.com/documentation/evaluations/evaluator) -- [`WKNavigationDelegate`](https://developer.apple.com/documentation/webkit/wknavigationdelegate) -- [`TaskLocal`](https://developer.apple.com/documentation/swift/tasklocal) -- [`Task.detached(priority:operation:)`](https://developer.apple.com/documentation/swift/task/detached(priority:operation:)) -- [`Clock`](https://developer.apple.com/documentation/swift/clock) -- [SE-0329: Clock, Instant, and Duration](https://github.com/swiftlang/swift-evolution/blob/main/proposals/0329-clock-instant-duration.md) -- [Using Continuations and Clock for deterministic Swift concurrency tests](https://zenn.dev/kntk/articles/2e8d1925b0bb6b) -- [StoreKit 2 subscription implementation walkthrough](https://www.revenuecat.com/blog/engineering/ios-in-app-subscription-tutorial-with-storekit-2-and-swift-jp/) From 2f7a063b2942a360b073bca8f36dc792ce7ad228 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Thu, 23 Jul 2026 11:52:38 +0900 Subject: [PATCH 21/26] docs(testing): show consumer-owned clock injection --- .../TestingSubscriptionAccess.md | 45 +++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/Sources/StoreTransactionKit/StoreTransactionKit.docc/TestingSubscriptionAccess.md b/Sources/StoreTransactionKit/StoreTransactionKit.docc/TestingSubscriptionAccess.md index 70fc2ee..171e48c 100644 --- a/Sources/StoreTransactionKit/StoreTransactionKit.docc/TestingSubscriptionAccess.md +++ b/Sources/StoreTransactionKit/StoreTransactionKit.docc/TestingSubscriptionAccess.md @@ -64,3 +64,48 @@ The harness transaction pipeline has no delay or retry timer. Inject ViewModel or delegate, and advance it explicitly. A harness purchase receipt still marks entitlement publication; clock advancement alone does not imply that transaction work is complete. + +```swift +final class DelayedTransactionDelegate: TransactionStoreDelegate { + private let clock: any Clock + + init(clock: any Clock) { + self.clock = clock + } + + func decidePolicy( + for transaction: StoreTransactionSnapshot + ) async throws -> StoreTransactionHandlingPolicy { + try await clock.sleep(for: .seconds(30)) + return .finish + } +} + +let clock = TransactionStoreTestClock() +let delegate = DelayedTransactionDelegate(clock: clock) + +try await withTransactionStoreTestHarness( + subscriptionCatalog: subscriptionCatalog, + delegate: delegate +) { harness in + let viewModel = NotesViewModel(store: harness.store) + let purchase = Task { @MainActor in + try await harness.purchase( + .tier1_Monthly, + in: Plans.self + ) + } + + try await clock.waitUntilPendingSleepCount(reaches: 1) + #expect(!viewModel.canExportPDF) + + clock.advance(by: .seconds(30)) + try await purchase.value + + #expect(viewModel.canExportPDF) +} +``` + +`waitUntilPendingSleepCount(reaches:)` is a continuation-backed registration +barrier, so the test needs neither a fixed delay nor guessed `Task.yield()` +counts. From 9ac9ad6166d5cbd3337a31863410ea5e124e36ff Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Thu, 23 Jul 2026 11:58:54 +0900 Subject: [PATCH 22/26] test(runtime): cover coalesced refresh failure ownership --- .../EntitlementRefreshCoordinator.swift | 6 +- .../Runtime/StoreTransactionRuntime.swift | 6 +- .../RuntimeContractCoverageTests.swift | 196 ++++++++++++++++++ .../TestSupport.swift | 51 +++++ 4 files changed, 256 insertions(+), 3 deletions(-) diff --git a/Sources/StoreTransactionKit/Entitlements/EntitlementRefreshCoordinator.swift b/Sources/StoreTransactionKit/Entitlements/EntitlementRefreshCoordinator.swift index 84232e5..ce5edc3 100644 --- a/Sources/StoreTransactionKit/Entitlements/EntitlementRefreshCoordinator.swift +++ b/Sources/StoreTransactionKit/Entitlements/EntitlementRefreshCoordinator.swift @@ -51,6 +51,7 @@ where Entitlement: Hashable & Sendable { private let didComplete: @Sendable (EntitlementRefreshOutcome) async -> Void private let failures: FailureReporterDispatcher private let lifetime: TransactionStoreLifecycle? + private let reservationDidEnqueue: (@Sendable () -> Void)? private var current: StoreEntitlements? private var pending: [PendingWork] = [] private var worker: Task? @@ -68,13 +69,15 @@ where Entitlement: Hashable & Sendable { @escaping @Sendable (EntitlementRefreshOutcome) async -> Void, failures: FailureReporterDispatcher, - lifetime: TransactionStoreLifecycle? = nil + lifetime: TransactionStoreLifecycle? = nil, + reservationDidEnqueue: (@Sendable () -> Void)? = nil ) { self.query = query self.project = project self.didComplete = didComplete self.failures = failures self.lifetime = lifetime + self.reservationDidEnqueue = reservationDidEnqueue } package func reserve( @@ -119,6 +122,7 @@ where Entitlement: Hashable & Sendable { ) ) ) + reservationDidEnqueue?() startWorkerIfNeeded() return EntitlementRefreshReservation( receipt: receipt, diff --git a/Sources/StoreTransactionKit/Runtime/StoreTransactionRuntime.swift b/Sources/StoreTransactionKit/Runtime/StoreTransactionRuntime.swift index 7ffe691..61ded61 100644 --- a/Sources/StoreTransactionKit/Runtime/StoreTransactionRuntime.swift +++ b/Sources/StoreTransactionKit/Runtime/StoreTransactionRuntime.swift @@ -35,7 +35,8 @@ where Entitlement: Hashable & Sendable { delegate: (any TransactionStoreDelegate)?, entitlementOutcome: @escaping @Sendable (EntitlementRefreshOutcome) async - -> Void + -> Void, + entitlementReservationDidEnqueue: (@Sendable () -> Void)? = nil ) { self.source = source self.lifecycle = lifecycle @@ -102,7 +103,8 @@ where Entitlement: Hashable & Sendable { }, didComplete: entitlementOutcome, failures: failures, - lifetime: lifecycle + lifetime: lifecycle, + reservationDidEnqueue: entitlementReservationDidEnqueue ) self.entitlements = entitlements diff --git a/Tests/StoreTransactionKitTests/RuntimeContractCoverageTests.swift b/Tests/StoreTransactionKitTests/RuntimeContractCoverageTests.swift index 801b2c5..45fc585 100644 --- a/Tests/StoreTransactionKitTests/RuntimeContractCoverageTests.swift +++ b/Tests/StoreTransactionKitTests/RuntimeContractCoverageTests.swift @@ -215,6 +215,198 @@ struct RuntimeContractCoverageTests { try await store.close() } + @Test( + "coalesced direct operations preserve caller errors and one abandoned report" + ) + func coalescedDirectOperationFailureOwnership() async throws { + let query = ControlledEntitlementQuery() + let reservations = TestCounterSignal() + let synchronizations = TestCounterSignal() + let finishes = TestCounterSignal() + let delegate = RecordingFailureDelegate() + let fixture = TestSourceFixture( + currentEntitlements: { try await query.next() }, + synchronize: { synchronizations.send() } + ) + let lifecycle = TransactionStoreLifecycle() + let runtime = StoreTransactionRuntime( + sessionID: UUID(), + source: fixture.source, + lifecycle: lifecycle, + subscriptionCatalog: testSubscriptionCatalog, + delegate: delegate, + entitlementOutcome: { _ in }, + entitlementReservationDidEnqueue: { + reservations.send() + } + ) + runtime.start() + + try await query.waitForRequest(1) + #expect(reservations.value() == 1) + + let firstSnapshot = makeSubscriptionSnapshot( + id: 306, + productID: .tier1Monthly, + revision: "coalesced-first" + ) + let firstProcess = Task { + try await runtime.process( + .verified( + makeEnvelope( + snapshot: firstSnapshot, + revision: "coalesced-first" + ) { + finishes.send() + } + ), + leases: try lifecycle.beginOperation() + ) + } + let firstRestore = Task { + try await runtime.restorePurchases( + leases: try lifecycle.beginOperation() + ) + } + let firstRefresh = Task { + try await runtime.currentEntitlements( + leases: try lifecycle.beginOperation() + ) + } + + try await reservations.wait(for: 4) + #expect(finishes.value() == 1) + #expect(synchronizations.value() == 1) + + await query.succeed([]) + try await runtime.waitForInitialReadiness() + try await query.waitForRequest(2) + #expect(await fixture.entitlementQueryCount.value() == 2) + await query.fail(CoalescedRefreshFailure(batch: 1)) + + do { + _ = try await firstProcess.value + Issue.record("The process caller unexpectedly succeeded.") + } catch StoreTransactionError.entitlementRefreshFailed( + after: .finishedTransaction(let transaction), + underlyingError: let error + ) { + #expect(transaction == firstSnapshot) + #expect((error as? CoalescedRefreshFailure)?.batch == 1) + } catch { + Issue.record("The process caller received an unexpected error: \(error)") + } + + do { + _ = try await firstRestore.value + Issue.record("The restore caller unexpectedly succeeded.") + } catch StoreTransactionError.entitlementRefreshFailed( + after: .synchronizedPurchases, + underlyingError: let error + ) { + #expect((error as? CoalescedRefreshFailure)?.batch == 1) + } catch { + Issue.record("The restore caller received an unexpected error: \(error)") + } + + do { + _ = try await firstRefresh.value + Issue.record("The refresh caller unexpectedly succeeded.") + } catch let error as CoalescedRefreshFailure { + #expect(error.batch == 1) + } catch { + Issue.record("The refresh caller received an unexpected error: \(error)") + } + #expect((await delegate.failures()).isEmpty) + + let separatingRefresh = Task { + try await runtime.currentEntitlements( + leases: try lifecycle.beginOperation() + ) + } + try await reservations.wait(for: 5) + try await query.waitForRequest(3) + + let secondSnapshot = makeSubscriptionSnapshot( + id: 307, + productID: .tier2Monthly, + revision: "coalesced-abandoned" + ) + let secondProcess = Task { + try await runtime.process( + .verified( + makeEnvelope( + snapshot: secondSnapshot, + revision: "coalesced-abandoned" + ) { + finishes.send() + } + ), + leases: try lifecycle.beginOperation() + ) + } + let secondRestore = Task { + try await runtime.restorePurchases( + leases: try lifecycle.beginOperation() + ) + } + let secondRefresh = Task { + try await runtime.currentEntitlements( + leases: try lifecycle.beginOperation() + ) + } + + try await reservations.wait(for: 8) + #expect(finishes.value() == 2) + #expect(synchronizations.value() == 2) + + secondProcess.cancel() + secondRestore.cancel() + secondRefresh.cancel() + await #expect(throws: CancellationError.self) { + _ = try await secondProcess.value + } + await #expect(throws: CancellationError.self) { + _ = try await secondRestore.value + } + await #expect(throws: CancellationError.self) { + _ = try await secondRefresh.value + } + + await query.succeed([]) + _ = try await separatingRefresh.value + try await query.waitForRequest(4) + #expect(await fixture.entitlementQueryCount.value() == 4) + await query.fail(CoalescedRefreshFailure(batch: 2)) + try await delegate.waitForFailures(1) + + await lifecycle.close { + await runtime.shutdown() + } + + let failures = await delegate.failures() + #expect(failures.count == 1) + #expect( + (failures.first?.underlyingError as? CoalescedRefreshFailure)? + .batch == 2 + ) + if let source = failures.first?.source { + guard case .abandonedDirectOperation(let operation) = source else { + Issue.record("The failed batch had no abandoned direct owner.") + return + } + #expect( + [ + StoreTransactionOperation.processPurchase, + .refreshEntitlements, + .restorePurchases, + ].contains(operation) + ) + } + #expect(reservations.value() == 8) + #expect(await fixture.entitlementQueryCount.value() == 4) + } + @MainActor @Test("immediate purchase outcomes perform no transaction work") func immediatePurchaseOutcomes() async throws { @@ -644,6 +836,10 @@ private final class NonCancellableGate: Sendable { } } +private struct CoalescedRefreshFailure: Error, Sendable { + let batch: Int +} + private actor ControlledSynchronization { private var attempts: [ProcessingReceipt] = [] private let started = TestSignal() diff --git a/Tests/StoreTransactionKitTests/TestSupport.swift b/Tests/StoreTransactionKitTests/TestSupport.swift index 9005826..90238e6 100644 --- a/Tests/StoreTransactionKitTests/TestSupport.swift +++ b/Tests/StoreTransactionKitTests/TestSupport.swift @@ -54,6 +54,57 @@ actor TestSignal { } } +final class TestCounterSignal: Sendable { + private struct Waiter: Sendable { + let target: Int + let receipt: ProcessingReceipt + } + + private struct State: Sendable { + var count = 0 + var waiters: [Waiter] = [] + } + + private let state = Mutex(State()) + + func send() { + let ready = state.withLock { state -> [ProcessingReceipt] in + state.count += 1 + var ready: [ProcessingReceipt] = [] + state.waiters.removeAll { waiter in + guard waiter.target <= state.count else { return false } + ready.append(waiter.receipt) + return true + } + return ready + } + for receipt in ready { + receipt.succeed(()) + } + } + + func wait(for target: Int) async throws { + precondition(target > 0) + let receipt = state.withLock { + state -> ProcessingReceipt? in + guard state.count < target else { return nil } + let receipt = ProcessingReceipt() + state.waiters.append(Waiter(target: target, receipt: receipt)) + return receipt + } + guard let receipt else { return } + do { + try await receipt.value() + } catch is ProcessingReceiptWaiterCancellation { + throw CancellationError() + } + } + + func value() -> Int { + state.withLock(\.count) + } +} + actor TestGate { private var isOpen = false private var waiters: [UUID: CheckedContinuation] = [:] From 12d603a8348ad716b3eca725efc6274063a84fb5 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Thu, 23 Jul 2026 12:19:56 +0900 Subject: [PATCH 23/26] test(runtime): cover restore synchronization retry --- .../RuntimeContractCoverageTests.swift | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/Tests/StoreTransactionKitTests/RuntimeContractCoverageTests.swift b/Tests/StoreTransactionKitTests/RuntimeContractCoverageTests.swift index 45fc585..3d00d27 100644 --- a/Tests/StoreTransactionKitTests/RuntimeContractCoverageTests.swift +++ b/Tests/StoreTransactionKitTests/RuntimeContractCoverageTests.swift @@ -173,6 +173,39 @@ struct RuntimeContractCoverageTests { await failures.sealAndDrain() } + @MainActor + @Test("an attached restore synchronization failure returns directly and can retry") + func attachedRestoreSynchronizationFailureCanRetry() async throws { + let synchronization = ControlledSynchronization() + let delegate = RecordingFailureDelegate() + let fixture = TestSourceFixture( + synchronize: { try await synchronization.run() } + ) + let store = TransactionStore( + source: fixture.source, + subscriptionCatalog: testSubscriptionCatalog, + delegate: delegate + ) + try await store.waitForInitialReadiness() + + let first = Task { @MainActor in try await store.restorePurchases() } + try await synchronization.waitForAttempt(1) + await synchronization.failNext(TestFailure()) + await #expect(throws: TestFailure.self) { + _ = try await first.value + } + + let retry = Task { @MainActor in try await store.restorePurchases() } + try await synchronization.waitForAttempt(2) + await synchronization.succeedNext() + let restored = try await retry.value + + #expect(restored.transactions.isEmpty) + #expect(await synchronization.attemptCount() == 2) + #expect((await delegate.failures()).isEmpty) + try await store.close() + } + @MainActor @Test("an abandoned restore reports once and a later restore retries synchronization") func abandonedRestoreReportsOnceAndRetries() async throws { From 700d50c597e184f4e2a5eb885b01fa7e7cb57ba8 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Thu, 23 Jul 2026 12:27:22 +0900 Subject: [PATCH 24/26] docs(api): align raw projection with StoreKit --- .../UnderstandingTransactionHandling.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/Sources/StoreTransactionKit/StoreTransactionKit.docc/UnderstandingTransactionHandling.md b/Sources/StoreTransactionKit/StoreTransactionKit.docc/UnderstandingTransactionHandling.md index c01cdc1..2f9c153 100644 --- a/Sources/StoreTransactionKit/StoreTransactionKit.docc/UnderstandingTransactionHandling.md +++ b/Sources/StoreTransactionKit/StoreTransactionKit.docc/UnderstandingTransactionHandling.md @@ -78,9 +78,10 @@ refresh publishes a new ready snapshot. The catalog maps declared Product IDs to app entitlement values. It does not copy StoreKit group levels or subscription periods into the entitlement type, -and multiple Product IDs may map to the same value. Upgraded transactions stay -in the raw projection but grant no typed access. Products outside the managed -group stay raw and do not enter the typed set. +and multiple Product IDs may map to the same value. A transaction that StoreKit +marks as upgraded grants no typed access. The raw projection otherwise mirrors +the verified current-entitlement items StoreKit returns, including products +outside the managed group. StoreKit excludes revoked or refunded transactions from current entitlements. For billing retry, grace period, and renewal presentation, use From d77b455b8e67349c3e3a5f556ea23859f4cb3d1d Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Thu, 23 Jul 2026 12:45:19 +0900 Subject: [PATCH 25/26] test(storekit): migrate app-hosted subscription scenarios --- Tools/TestApp/README.md | 34 +- Tools/TestApp/StoreKitTest.storekit | 129 ++++- .../StoreTransactionKitIntegrationTests.swift | 527 ++++++++++++------ 3 files changed, 479 insertions(+), 211 deletions(-) diff --git a/Tools/TestApp/README.md b/Tools/TestApp/README.md index 35e5ae6..902cae5 100644 --- a/Tools/TestApp/README.md +++ b/Tools/TestApp/README.md @@ -2,24 +2,25 @@ The app host gives StoreKit Test a real application process while testing the public `TransactionStore` API from an external target. `StoreKitTest.storekit` -defines Plus and Pro as two service levels in the same subscription group and -Lifetime as a non-consumable entitlement. +defines Tier 1 and Tier 2 as two service levels in the same subscription group, +with monthly and yearly products for each level. A test-only subscription in a +different group exercises delegate handling outside that catalog. The serialized suite covers: - direct purchases and external `Transaction.updates` handling through finish -- launch reconciliation for an existing purchase +- launch reconciliation for every monthly and yearly catalog product - launch reconciliation for cancelled-active and already-expired subscriptions - unfinished purchase replay until durable handling succeeds and finishes it - interrupted purchases that resume through `Transaction.updates` - verification failures that are reported and remain unfinished -- immediate Plus-to-Pro upgrades -- Pro-to-Plus downgrades at renewal +- immediate Tier 1-to-Tier 2 upgrades +- Tier 2-to-Tier 1 downgrades at renewal - cancellation remaining entitled until explicit expiration - renewal transaction lineage and durable handling before status publication - refunds -- empty, existing-entitlement, and failed-then-retried restores -- Ask to Buy approval and decline +- empty, existing-entitlement, and failed restores +- Ask to Buy decline on pre-27 runtimes and approval on OS 27 and later The host app and test bundle support iOS, tvOS, watchOS, and visionOS. Run the StoreKit runtime suite on an installed iOS Simulator. StoreKit Test owns one @@ -55,6 +56,25 @@ signals. They don't use fixed sleeps or accelerated wall-clock time. A suite time limit exists only to surface a missing event as a failed test instead of hanging the test process. +The external-update test covers an unmanaged delegate `.finish` on every +runtime with the test-only subscription group. The raw projection retains that +transaction while the typed catalog projection remains empty. On iOS 18.6, +StoreKit Test's automatic causal and startup queries can resolve before its +current-entitlement view updates. That lane verifies initial handling before an +explicit refresh exposes the raw transaction, then uses a new store to prove +there is no unfinished replay before refreshing its raw projection. + +Ask to Buy decline isn't automated on iOS 27 because StoreKit Test produces a +purchased transaction after `declineAskToBuyTransaction(identifier:)`. The +suite validates decline on pre-27 runtimes and pending-to-approved delivery on +OS 27 and later. Validate the iOS 27 decline path separately in Sandbox. + +Two other iOS 27 StoreKit Test differences have explicit test boundaries. +Expiring a subscription doesn't publish a subscription-status update, so that +lane performs a restore before asserting removal. An injected `AppStore.sync` +network failure arrives as `StoreKitError.systemError` with an unknown internal +error instead of the typed network error delivered on pre-27 runtimes. + CI runs this suite with Xcode 26.5 on the iOS 26.2 simulator available in the GitHub macOS 26 image. CI also cross-builds the app host and test bundle for tvOS, watchOS, and visionOS simulators. The same runtime suite is validated diff --git a/Tools/TestApp/StoreKitTest.storekit b/Tools/TestApp/StoreKitTest.storekit index c7bfdcf..d93099f 100644 --- a/Tools/TestApp/StoreKitTest.storekit +++ b/Tools/TestApp/StoreKitTest.storekit @@ -14,21 +14,7 @@ ], "products" : [ - { - "displayPrice" : "9.99", - "familyShareable" : false, - "internalID" : "StoreTransactionKitLifetime", - "localizations" : [ - { - "description" : "The lifetime entitlement used by integration tests.", - "displayName" : "Lifetime", - "locale" : "en_US" - } - ], - "productID" : "com.example.StoreTransactionKit.lifetime", - "referenceName" : "Lifetime", - "type" : "NonConsumable" - } + ], "settings" : { "_askToBuyEnabled" : false, @@ -97,18 +83,46 @@ "displayPrice" : "1.99", "familyShareable" : false, "groupNumber" : 2, - "internalID" : "StoreTransactionKitPlus", + "internalID" : "StoreTransactionKitTier1Monthly", "introductoryOffer" : null, "localizations" : [ { - "description" : "The Plus plan used by integration tests.", - "displayName" : "Plus", + "description" : "The monthly Tier 1 plan used by integration tests.", + "displayName" : "Tier 1 Monthly", "locale" : "en_US" } ], - "productID" : "com.example.StoreTransactionKit.plus", + "productID" : "com.example.StoreTransactionKit.tier1.monthly", "recurringSubscriptionPeriod" : "P1M", - "referenceName" : "Plus", + "referenceName" : "Tier 1 Monthly", + "subscriptionGroupID" : "StoreTransactionKitSubscriptionGroup", + "type" : "RecurringSubscription", + "winbackOffers" : [ + + ] + }, + { + "adHocOffers" : [ + + ], + "codeOffers" : [ + + ], + "displayPrice" : "19.99", + "familyShareable" : false, + "groupNumber" : 2, + "internalID" : "StoreTransactionKitTier1Yearly", + "introductoryOffer" : null, + "localizations" : [ + { + "description" : "The yearly Tier 1 plan used by integration tests.", + "displayName" : "Tier 1 Yearly", + "locale" : "en_US" + } + ], + "productID" : "com.example.StoreTransactionKit.tier1.yearly", + "recurringSubscriptionPeriod" : "P1Y", + "referenceName" : "Tier 1 Yearly", "subscriptionGroupID" : "StoreTransactionKitSubscriptionGroup", "type" : "RecurringSubscription", "winbackOffers" : [ @@ -125,22 +139,87 @@ "displayPrice" : "3.99", "familyShareable" : false, "groupNumber" : 1, - "internalID" : "StoreTransactionKitPro", + "internalID" : "StoreTransactionKitTier2Monthly", "introductoryOffer" : null, "localizations" : [ { - "description" : "The Pro plan used by integration tests.", - "displayName" : "Pro", + "description" : "The monthly Tier 2 plan used by integration tests.", + "displayName" : "Tier 2 Monthly", "locale" : "en_US" } ], - "productID" : "com.example.StoreTransactionKit.pro", + "productID" : "com.example.StoreTransactionKit.tier2.monthly", "recurringSubscriptionPeriod" : "P1M", - "referenceName" : "Pro", + "referenceName" : "Tier 2 Monthly", + "subscriptionGroupID" : "StoreTransactionKitSubscriptionGroup", + "type" : "RecurringSubscription", + "winbackOffers" : [ + + ] + }, + { + "adHocOffers" : [ + + ], + "codeOffers" : [ + + ], + "displayPrice" : "39.99", + "familyShareable" : false, + "groupNumber" : 1, + "internalID" : "StoreTransactionKitTier2Yearly", + "introductoryOffer" : null, + "localizations" : [ + { + "description" : "The yearly Tier 2 plan used by integration tests.", + "displayName" : "Tier 2 Yearly", + "locale" : "en_US" + } + ], + "productID" : "com.example.StoreTransactionKit.tier2.yearly", + "recurringSubscriptionPeriod" : "P1Y", + "referenceName" : "Tier 2 Yearly", "subscriptionGroupID" : "StoreTransactionKitSubscriptionGroup", "type" : "RecurringSubscription", "winbackOffers" : [ + ] + } + ] + }, + { + "id" : "StoreTransactionKitExternalSubscriptionGroup", + "localizations" : [ + + ], + "name" : "External", + "subscriptions" : [ + { + "adHocOffers" : [ + + ], + "codeOffers" : [ + + ], + "displayPrice" : "0.99", + "familyShareable" : false, + "groupNumber" : 1, + "internalID" : "StoreTransactionKitExternalMonthly", + "introductoryOffer" : null, + "localizations" : [ + { + "description" : "A catalog-external subscription used by integration tests.", + "displayName" : "External Monthly", + "locale" : "en_US" + } + ], + "productID" : "com.example.StoreTransactionKit.external.monthly", + "recurringSubscriptionPeriod" : "P1M", + "referenceName" : "External Monthly", + "subscriptionGroupID" : "StoreTransactionKitExternalSubscriptionGroup", + "type" : "RecurringSubscription", + "winbackOffers" : [ + ] } ] diff --git a/Tools/TestApp/StoreTransactionKitIntegrationTests/StoreTransactionKitIntegrationTests.swift b/Tools/TestApp/StoreTransactionKitIntegrationTests/StoreTransactionKitIntegrationTests.swift index 38ec2be..71ce59a 100644 --- a/Tools/TestApp/StoreTransactionKitIntegrationTests/StoreTransactionKitIntegrationTests.swift +++ b/Tools/TestApp/StoreTransactionKitIntegrationTests/StoreTransactionKitIntegrationTests.swift @@ -8,12 +8,35 @@ import Testing import UIKit #endif -private enum Entitlement: String, Hashable, Sendable { - case lifetime = "com.example.StoreTransactionKit.lifetime" - case plus = "com.example.StoreTransactionKit.plus" - case pro = "com.example.StoreTransactionKit.pro" +private enum SubscriptionEntitlement: Hashable, Sendable { + case tier1 + case tier2 } +private enum Plans: AutoRenewableSubscriptionGroup { + static let id = SubscriptionGroupID( + rawValue: "StoreTransactionKitSubscriptionGroup" + ) + + enum ProductID: String, CaseIterable, Sendable { + case tier1_Monthly = "com.example.StoreTransactionKit.tier1.monthly" + case tier1_Yearly = "com.example.StoreTransactionKit.tier1.yearly" + case tier2_Monthly = "com.example.StoreTransactionKit.tier2.monthly" + case tier2_Yearly = "com.example.StoreTransactionKit.tier2.yearly" + } + + static var subscriptions: StoreSubscriptions { + StoreSubscription(.tier1_Monthly, entitlement: .tier1) + StoreSubscription(.tier1_Yearly, entitlement: .tier1) + StoreSubscription(.tier2_Monthly, entitlement: .tier2) + StoreSubscription(.tier2_Yearly, entitlement: .tier2) + } +} + +private let subscriptionCatalog = AutoRenewableSubscriptionCatalog(Plans.self) +private let externalSubscriptionProductID = + "com.example.StoreTransactionKit.external.monthly" + @Suite(.serialized, .timeLimit(.minutes(1))) @MainActor struct StoreTransactionKitIntegrationTests { @@ -29,28 +52,51 @@ struct StoreTransactionKitIntegrationTests { let handlerCalls = TestSignal() let observedStore = ObservedStore( - handleTransaction: { _ in + decidePolicy: { _ in await handlerCalls.send() await handlerStarted.send() try await handlerRelease.wait(for: 1) + return .finish }, - reportFailure: { failure in + didFail: { failure in Issue.record("Unexpected purchase failure: \(failure)") } ) + var purchaseTask: Task? do { try await observedStore.waitForEntitlements([]) - _ = try await session.buyProduct( - identifier: Entitlement.lifetime.rawValue - ) + let task = Task { @MainActor in + try await session.buyProduct( + identifier: externalSubscriptionProductID + ) + } + purchaseTask = task try await handlerStarted.wait(for: 1) #expect(observedStore.store.activeEntitlements == []) await handlerRelease.send() - try await observedStore.waitForEntitlements([.lifetime]) + _ = try await task.value #expect(await handlerCalls.value() == 1) + if #available(iOS 27.0, + tvOS 27.0, + watchOS 27.0, + visionOS 27.0, + *) { + _ = try await observedStore.waitForTransaction( + productID: externalSubscriptionProductID, + excluding: 0 + ) + } else { + _ = try await observedStore.store.refreshEntitlements() + } + #expect( + observedStore.store.entitlements?.transactions.map(\.productID) + == [externalSubscriptionProductID] + ) + #expect(observedStore.store.activeEntitlements == []) } catch { + purchaseTask?.cancel() await handlerRelease.send() do { try await observedStore.close() @@ -63,22 +109,45 @@ struct StoreTransactionKitIntegrationTests { let replayedHandlerCalls = TestSignal() try await withObservedStore( - handleTransaction: { _ in + decidePolicy: { _ in await replayedHandlerCalls.send() + return .finish }, - reportFailure: { failure in + didFail: { failure in Issue.record("Unexpected post-finish failure: \(failure)") } ) { observedStore in - try await observedStore.waitForEntitlements([.lifetime]) + try await observedStore.waitForEntitlements([]) + guard case .ready = observedStore.store.entitlementStatus else { + Issue.record("The replay store did not reach ready state.") + return + } + #expect(await replayedHandlerCalls.value() == 0) + if #available(iOS 27.0, + tvOS 27.0, + watchOS 27.0, + visionOS 27.0, + *) { + _ = try await observedStore.waitForTransaction( + productID: externalSubscriptionProductID, + excluding: 0 + ) + } else { + _ = try await observedStore.store.refreshEntitlements() + } + #expect( + observedStore.store.entitlements?.transactions.map(\.productID) + == [externalSubscriptionProductID] + ) + #expect(observedStore.store.activeEntitlements == []) #expect(await replayedHandlerCalls.value() == 0) } } @Test - func directPurchaseIsProcessedAndPublishesPlus() async throws { + func directPurchaseIsProcessedAndPublishesTier1() async throws { try await withTestContext { context in - let product = try await context.product(.plus) + let product = try await context.product(.tier1_Monthly) let appAccountToken = UUID() let result = try await purchase( product, @@ -91,7 +160,7 @@ struct StoreTransactionKitIntegrationTests { Issue.record("Expected a completed direct purchase.") return } - #expect(transaction.productID == Entitlement.plus.rawValue) + #expect(transaction.productID == Plans.ProductID.tier1_Monthly.rawValue) #expect( transaction.subscriptionGroupID == "StoreTransactionKitSubscriptionGroup" @@ -113,26 +182,33 @@ struct StoreTransactionKitIntegrationTests { #expect(transaction.reason == .purchase) #expect(transaction.appAccountToken == appAccountToken) #expect(!transaction.jwsRepresentation.isEmpty) - #expect(context.store.activeEntitlements == [.plus]) + #expect(context.store.activeEntitlements == [.tier1]) } } @Test - func launchReconcilesAnExistingPurchase() async throws { - try await withTestContext(preexistingSubscription: .plus) { context in - #expect(context.store.activeEntitlements == [.plus]) + func launchMapsEverySubscriptionProduct() async throws { + for productID in Plans.ProductID.allCases { + try await withTestContext(preexistingSubscription: productID) { context in + switch productID { + case .tier1_Monthly, .tier1_Yearly: + #expect(context.store.activeEntitlements == [.tier1]) + case .tier2_Monthly, .tier2_Yearly: + #expect(context.store.activeEntitlements == [.tier2]) + } + } } } @Test func launchKeepsACancelledSubscriptionUntilItsExpiration() async throws { try await withTestContext( - preexistingSubscription: .plus, + preexistingSubscription: .tier1_Monthly, preexistingPurchaseOptions: [ .purchaseDate(.now, renewalBehavior: .cancelImmediately) ] ) { context in - #expect(context.store.activeEntitlements == [.plus]) + #expect(context.store.activeEntitlements == [.tier1]) } } @@ -147,7 +223,7 @@ struct StoreTransactionKitIntegrationTests { ) try await withTestContext( - preexistingSubscription: .plus, + preexistingSubscription: .tier1_Monthly, preexistingPurchaseOptions: [ .purchaseDate( purchaseDate, @@ -167,16 +243,18 @@ struct StoreTransactionKitIntegrationTests { session.resetToDefaultState() session.clearTransactions() } - _ = try await session.buyProduct(identifier: Entitlement.plus.rawValue) + _ = try await session.buyProduct( + identifier: Plans.ProductID.tier1_Monthly.rawValue + ) let failedHandlerCalls = TestSignal() let launchDeliveryFailures = TestSignal() try await withObservedStore( - handleTransaction: { _ in + decidePolicy: { _ in await failedHandlerCalls.send() throw DurableHandlingFailure() }, - reportFailure: { failure in + didFail: { failure in switch failure.source { case .updates, .unfinished: await launchDeliveryFailures.send() @@ -195,27 +273,29 @@ struct StoreTransactionKitIntegrationTests { let successfulHandlerCalls = TestSignal() try await withObservedStore( - handleTransaction: { _ in + decidePolicy: { _ in await successfulHandlerCalls.send() + return .finish }, - reportFailure: { failure in + didFail: { failure in Issue.record("Unexpected retry failure: \(failure)") } ) { observedStore in try await successfulHandlerCalls.wait(for: 1) - try await observedStore.waitForEntitlements([.plus]) + try await observedStore.waitForEntitlements([.tier1]) } let postFinishHandlerCalls = TestSignal() try await withObservedStore( - handleTransaction: { _ in + decidePolicy: { _ in await postFinishHandlerCalls.send() + return .finish }, - reportFailure: { failure in + didFail: { failure in Issue.record("Unexpected post-finish failure: \(failure)") } ) { observedStore in - try await observedStore.waitForEntitlements([.plus]) + try await observedStore.waitForEntitlements([.tier1]) #expect(await postFinishHandlerCalls.value() == 0) } } @@ -224,24 +304,45 @@ struct StoreTransactionKitIntegrationTests { func interruptedPurchaseCompletesAfterTheIssueIsResolved() async throws { try await withTestContext { context in context.session.interruptedPurchasesEnabled = true - let product = try await context.product(.plus) - let result = try await purchase(product) - guard case .pending = result else { - Issue.record("Expected the interrupted purchase to remain pending.") - return - } - let interrupted = try #require( - context.session.allTransactions().first { - $0.productIdentifier == Entitlement.plus.rawValue - && $0.hasPurchaseIssue + let interrupted: SKTestTransaction + if #available(iOS 27.0, + tvOS 27.0, + watchOS 27.0, + visionOS 27.0, + *) { + let transaction = try await context.session.buyProduct( + identifier: Plans.ProductID.tier1_Monthly.rawValue + ) + interrupted = try #require( + context.session.allTransactions().first { + $0.identifier == Int(transaction.id) + } + ) + } else { + let product = try await context.product(.tier1_Monthly) + let result = try await purchase(product) + let outcome = try await context.store.process(result) + guard case .pending = outcome else { + Issue.record( + "Expected the interrupted purchase to remain pending." + ) + return } - ) + interrupted = try #require( + context.session.allTransactions().first { + $0.productIdentifier + == Plans.ProductID.tier1_Monthly.rawValue + && $0.hasPurchaseIssue + } + ) + } + #expect(interrupted.hasPurchaseIssue) try context.session.resolveIssueForTransaction( identifier: interrupted.identifier ) - try await context.waitForEntitlements([.plus]) + try await context.waitForEntitlements([.tier1]) } } @@ -256,10 +357,11 @@ struct StoreTransactionKitIntegrationTests { let updateFailures = TestSignal() try await withObservedStore( - handleTransaction: { _ in + decidePolicy: { _ in await rejectedHandlerCalls.send() + return .finish }, - reportFailure: { failure in + didFail: { failure in if case .updates = failure.source { await updateFailures.send() } @@ -271,7 +373,9 @@ struct StoreTransactionKitIntegrationTests { forAPI: .verification ) - _ = try await session.buyProduct(identifier: Entitlement.plus.rawValue) + _ = try await session.buyProduct( + identifier: Plans.ProductID.tier1_Monthly.rawValue + ) try await updateFailures.wait(for: 1) #expect(await rejectedHandlerCalls.value() == 0) @@ -280,10 +384,11 @@ struct StoreTransactionKitIntegrationTests { let unfinishedFailures = TestSignal() try await withObservedStore( - handleTransaction: { _ in + decidePolicy: { _ in await rejectedHandlerCalls.send() + return .finish }, - reportFailure: { failure in + didFail: { failure in if case .unfinished = failure.source { await unfinishedFailures.send() } @@ -292,53 +397,58 @@ struct StoreTransactionKitIntegrationTests { try await unfinishedFailures.wait(for: 1) #expect(await rejectedHandlerCalls.value() == 0) } + + try await session.setSimulatedError(nil, forAPI: .verification) } @Test - func upgradeFromPlusToProPublishesOnlyPro() async throws { + func upgradeFromTier1ToTier2PublishesOnlyTier2() async throws { try await withTestContext { context in - _ = try await context.session.buyProduct(identifier: Entitlement.plus.rawValue) - try await context.waitForEntitlements([.plus]) - - _ = try await context.session.buyProduct(identifier: Entitlement.pro.rawValue) + _ = try await context.session.buyProduct( + identifier: Plans.ProductID.tier1_Monthly.rawValue + ) + try await context.waitForEntitlements([.tier1]) - try await context.waitForEntitlements([.pro]) - #expect( - context.store.entitlements?.transactions.contains { - $0.productID == Entitlement.plus.rawValue && $0.isUpgraded - } == true + _ = try await context.session.buyProduct( + identifier: Plans.ProductID.tier2_Monthly.rawValue ) + + try await context.waitForEntitlements([.tier2]) } } @Test - func downgradeFromProToPlusChangesAtRenewal() async throws { + func downgradeFromTier2ToTier1ChangesAtRenewal() async throws { try await withTestContext { context in - _ = try await context.session.buyProduct(identifier: Entitlement.pro.rawValue) - try await context.waitForEntitlements([.pro]) + _ = try await context.session.buyProduct( + identifier: Plans.ProductID.tier2_Monthly.rawValue + ) + try await context.waitForEntitlements([.tier2]) - _ = try await context.session.buyProduct(identifier: Entitlement.plus.rawValue) + _ = try await context.session.buyProduct( + identifier: Plans.ProductID.tier1_Monthly.rawValue + ) let preRenewalEntitlements = try await context.store.refreshEntitlements() #expect( preRenewalEntitlements.transactions.map(\.productID) - == [Entitlement.pro.rawValue] + == [Plans.ProductID.tier2_Monthly.rawValue] ) - #expect(context.store.activeEntitlements == [.pro]) + #expect(context.store.activeEntitlements == [.tier2]) try context.session.forceRenewalOfSubscription( - productIdentifier: Entitlement.pro.rawValue + productIdentifier: Plans.ProductID.tier2_Monthly.rawValue ) - try await context.waitForEntitlements([.plus]) + try await context.waitForEntitlements([.tier1]) } } @Test - func cancellationKeepsPlusUntilExpirationThenRemovesIt() async throws { + func cancellationKeepsTier1UntilExpirationThenRemovesIt() async throws { try await withTestContext { context in let transaction = try await context.session.buyProduct( - identifier: Entitlement.plus.rawValue + identifier: Plans.ProductID.tier1_Monthly.rawValue ) - try await context.waitForEntitlements([.plus]) + try await context.waitForEntitlements([.tier1]) try context.session.disableAutoRenewForTransaction( identifier: UInt(transaction.id) @@ -353,15 +463,26 @@ struct StoreTransactionKitIntegrationTests { let cancelledEntitlements = try await context.store.refreshEntitlements() #expect( cancelledEntitlements.transactions.map(\.productID) - == [Entitlement.plus.rawValue] + == [Plans.ProductID.tier1_Monthly.rawValue] ) - #expect(context.store.activeEntitlements == [.plus]) + #expect(context.store.activeEntitlements == [.tier1]) try context.session.expireSubscription( - productIdentifier: Entitlement.plus.rawValue + productIdentifier: Plans.ProductID.tier1_Monthly.rawValue ) - try await context.waitForEntitlements([]) + if #available(iOS 27.0, + tvOS 27.0, + watchOS 27.0, + visionOS 27.0, + *) { + let expiredEntitlements = + try await context.store.restorePurchases() + #expect(expiredEntitlements.transactions.isEmpty) + #expect(context.store.activeEntitlements == []) + } else { + try await context.waitForEntitlements([]) + } } } @@ -369,21 +490,21 @@ struct StoreTransactionKitIntegrationTests { func renewalPublishesTheNewTransactionLineage() async throws { try await withTestContext { context in let initial = try await context.session.buyProduct( - identifier: Entitlement.plus.rawValue + identifier: Plans.ProductID.tier1_Monthly.rawValue ) - try await context.waitForEntitlements([.plus]) + try await context.waitForEntitlements([.tier1]) try context.session.forceRenewalOfSubscription( - productIdentifier: Entitlement.plus.rawValue + productIdentifier: Plans.ProductID.tier1_Monthly.rawValue ) let renewal = try await context.waitForTransaction( - productID: Entitlement.plus.rawValue, + productID: Plans.ProductID.tier1_Monthly.rawValue, excluding: initial.id ) #expect(renewal.originalID == initial.originalID) #expect(renewal.reason == .renewal) - #expect(context.store.activeEntitlements == [.plus]) + #expect(context.store.activeEntitlements == [.tier1]) } } @@ -397,12 +518,14 @@ struct StoreTransactionKitIntegrationTests { let renewalHandlerStarted = TestSignal() let renewalHandlerRelease = TestSignal() let observedStore = ObservedStore( - handleTransaction: { transaction in - guard transaction.reason == .renewal else { return } - await renewalHandlerStarted.send() - try await renewalHandlerRelease.wait(for: 1) + decidePolicy: { transaction in + if transaction.reason == .renewal { + await renewalHandlerStarted.send() + try await renewalHandlerRelease.wait(for: 1) + } + return .finish }, - reportFailure: { failure in + didFail: { failure in Issue.record("Unexpected renewal failure: \(failure)") } ) @@ -410,12 +533,12 @@ struct StoreTransactionKitIntegrationTests { do { try await observedStore.waitForEntitlements([]) let initial = try await session.buyProduct( - identifier: Entitlement.plus.rawValue + identifier: Plans.ProductID.tier1_Monthly.rawValue ) - try await observedStore.waitForEntitlements([.plus]) + try await observedStore.waitForEntitlements([.tier1]) try session.forceRenewalOfSubscription( - productIdentifier: Entitlement.plus.rawValue + productIdentifier: Plans.ProductID.tier1_Monthly.rawValue ) try await renewalHandlerStarted.wait(for: 1) @@ -425,7 +548,7 @@ struct StoreTransactionKitIntegrationTests { ) await renewalHandlerRelease.send() let renewal = try await observedStore.waitForTransaction( - productID: Entitlement.plus.rawValue, + productID: Plans.ProductID.tier1_Monthly.rawValue, excluding: initial.id ) #expect(renewal.reason == .renewal) @@ -443,14 +566,15 @@ struct StoreTransactionKitIntegrationTests { let replayedHandlerCalls = TestSignal() try await withObservedStore( - handleTransaction: { _ in + decidePolicy: { _ in await replayedHandlerCalls.send() + return .finish }, - reportFailure: { failure in + didFail: { failure in Issue.record("Unexpected post-renewal failure: \(failure)") } ) { observedStore in - try await observedStore.waitForEntitlements([.plus]) + try await observedStore.waitForEntitlements([.tier1]) #expect(await replayedHandlerCalls.value() == 0) } } @@ -459,9 +583,9 @@ struct StoreTransactionKitIntegrationTests { func refundRemovesTheEntitlement() async throws { try await withTestContext { context in let transaction = try await context.session.buyProduct( - identifier: Entitlement.plus.rawValue + identifier: Plans.ProductID.tier1_Monthly.rawValue ) - try await context.waitForEntitlements([.plus]) + try await context.waitForEntitlements([.tier1]) try context.session.refundTransaction(identifier: UInt(transaction.id)) @@ -481,19 +605,19 @@ struct StoreTransactionKitIntegrationTests { @Test func restoreReturnsAnExistingEntitlement() async throws { - try await withTestContext(preexistingSubscription: .plus) { context in + try await withTestContext(preexistingSubscription: .tier1_Yearly) { context in let entitlements = try await context.store.restorePurchases() #expect( entitlements.transactions.map(\.productID) - == [Entitlement.plus.rawValue] + == [Plans.ProductID.tier1_Yearly.rawValue] ) - #expect(context.store.activeEntitlements == [.plus]) + #expect(context.store.activeEntitlements == [.tier1]) } } @Test - func restorePropagatesAppStoreSyncFailureAndCanRetry() async throws { + func restorePropagatesAppStoreSyncFailure() async throws { try await withTestContext { context in let injectedFailure = SKTestFailures.AppStoreSync.generic( .networkError(URLError(.notConnectedToInternet)) @@ -507,34 +631,40 @@ struct StoreTransactionKitIntegrationTests { == injectedFailure ) + var capturedRestoreError: (any Error)? do { _ = try await context.store.restorePurchases() - Issue.record("Expected restorePurchases() to fail.") - } catch StoreKitError.networkError(let error) { - #expect(error.code == .notConnectedToInternet) } catch { - Issue.record("Unexpected restore error: \(error)") + capturedRestoreError = error } - - try await context.session.setSimulatedError( - nil, - forAPI: .appStoreSync - ) - #expect( - await context.session.simulatedError(forAPI: .appStoreSync) - == nil + let restoreError = try #require( + capturedRestoreError, + "Expected restorePurchases() to fail." ) - let entitlements = try await context.store.restorePurchases() - #expect(entitlements.transactions.isEmpty) + if #unavailable(iOS 27.0, + tvOS 27.0, + watchOS 27.0, + visionOS 27.0) { + guard case StoreKitError.networkError(let error) = restoreError else { + Issue.record("Unexpected restore error: \(restoreError)") + return + } + #expect(error.code == .notConnectedToInternet) + } else { + guard case StoreKitError.systemError = restoreError else { + Issue.record("Unexpected restore error: \(restoreError)") + return + } + } #expect(context.store.activeEntitlements == []) } } @Test - func askToBuyApprovalArrivesThroughUpdates() async throws { + func askToBuyResolutionMatchesTheStoreKitTestRuntime() async throws { try await withTestContext { context in context.session.askToBuyEnabled = true - let product = try await context.product(.plus) + let product = try await context.product(.tier1_Monthly) let result = try await purchase(product) let outcome = try await context.store.process(result) @@ -548,43 +678,30 @@ struct StoreTransactionKitIntegrationTests { $0.pendingAskToBuyConfirmation } ) - try context.session.approveAskToBuyTransaction( - identifier: pending.identifier - ) - try await context.waitForEntitlements([.plus]) - } - } - - @Test - func askToBuyDeclineDoesNotGrantAnEntitlement() async throws { - try await withTestContext { context in - context.session.askToBuyEnabled = true - let product = try await context.product(.plus) - let result = try await purchase(product) - - let outcome = try await context.store.process(result) + if #available(iOS 27.0, + tvOS 27.0, + watchOS 27.0, + visionOS 27.0, + *) { + try context.session.approveAskToBuyTransaction( + identifier: pending.identifier + ) + try await context.waitForEntitlements([.tier1]) + } else { + try context.session.declineAskToBuyTransaction( + identifier: pending.identifier + ) - guard case .pending = outcome else { - Issue.record("Expected an Ask to Buy purchase to remain pending.") - return + let entitlements = + try await context.store.restorePurchases() + #expect(entitlements.transactions.isEmpty) + #expect(context.store.activeEntitlements == []) + #expect( + context.session.allTransactions().allSatisfy { + !$0.pendingAskToBuyConfirmation + } + ) } - let pending = try #require( - context.session.allTransactions().first { - $0.pendingAskToBuyConfirmation - } - ) - try context.session.declineAskToBuyTransaction( - identifier: pending.identifier - ) - - let entitlements = try await context.store.restorePurchases() - #expect(entitlements.transactions.isEmpty) - #expect(context.store.activeEntitlements == []) - #expect( - context.session.allTransactions().allSatisfy { - !$0.pendingAskToBuyConfirmation - } - ) } } @@ -606,9 +723,9 @@ struct StoreTransactionKitIntegrationTests { } private func withTestContext( - preexistingSubscription: Entitlement? = nil, + preexistingSubscription: Plans.ProductID? = nil, preexistingPurchaseOptions: Set = [], - expectedEntitlements: Set? = nil, + expectedEntitlements: Set? = nil, _ body: (TestContext) async throws -> Void ) async throws { let context = try await TestContext( @@ -635,14 +752,14 @@ private final class TestContext { let session: SKTestSession private let observedStore: ObservedStore - var store: TransactionStore { + var store: TransactionStore { observedStore.store } init( - preexistingSubscription: Entitlement? = nil, + preexistingSubscription: Plans.ProductID? = nil, preexistingPurchaseOptions: Set = [], - expectedEntitlements: Set? = nil + expectedEntitlements: Set? = nil ) async throws { let session = try await makeTestSession() self.session = session @@ -654,28 +771,38 @@ private final class TestContext { } let observedStore = ObservedStore( - handleTransaction: { _ in }, - reportFailure: { error in + decidePolicy: { _ in .automatic }, + didFail: { error in Issue.record("Background transaction failure: \(error)") } ) self.observedStore = observedStore let expected = expectedEntitlements - ?? preexistingSubscription.map { [$0] } + ?? preexistingSubscription.map { + switch $0 { + case .tier1_Monthly, .tier1_Yearly: + [.tier1] + case .tier2_Monthly, .tier2_Yearly: + [.tier2] + } + } ?? [] try await observedStore.waitForEntitlements(expected) - #expect(store.startupError == nil) + guard case .ready = store.entitlementStatus else { + Issue.record("The store did not reach ready entitlement state.") + return + } } - func product(_ entitlement: Entitlement) async throws -> Product { + func product(_ productID: Plans.ProductID) async throws -> Product { try #require( - try await Product.products(for: [entitlement.rawValue]).first + try await Product.products(for: [productID.rawValue]).first ) } func waitForEntitlements( - _ expected: Set + _ expected: Set ) async throws { try await observedStore.waitForEntitlements(expected) } @@ -701,24 +828,31 @@ private final class TestContext { @MainActor private final class ObservedStore { - let store: TransactionStore + let store: TransactionStore let observation: TransactionStoreObservation init( - handleTransaction: - @escaping @Sendable (StoreTransactionSnapshot) async throws -> Void, - reportFailure: + decidePolicy: + @escaping @Sendable (StoreTransactionSnapshot) async throws + -> StoreTransactionHandlingPolicy, + didFail: @escaping @Sendable (StoreTransactionBackgroundFailure) async -> Void ) { - let store = TransactionStore( - handleTransaction: handleTransaction, - reportFailure: reportFailure + let delegate = ClosureTransactionStoreDelegate( + decidePolicy: decidePolicy, + didFail: didFail + ) + let store = TransactionStore( + subscriptionCatalog: subscriptionCatalog, + delegate: delegate ) self.store = store self.observation = TransactionStoreObservation(store: store) } - func waitForEntitlements(_ expected: Set) async throws { + func waitForEntitlements( + _ expected: Set + ) async throws { while true { let generation = observation.generation guard store.activeEntitlements != expected else { return } @@ -729,8 +863,11 @@ private final class ObservedStore { func waitForStartupFailure() async throws { while true { let generation = observation.generation - guard store.startupError == nil else { return } - try await observation.waitForChange(after: generation) + guard case .failed = store.entitlementStatus else { + try await observation.waitForChange(after: generation) + continue + } + return } } @@ -755,17 +892,48 @@ private final class ObservedStore { } } +private final class ClosureTransactionStoreDelegate: TransactionStoreDelegate { + private let policy: + @Sendable (StoreTransactionSnapshot) async throws + -> StoreTransactionHandlingPolicy + private let failure: @Sendable (StoreTransactionBackgroundFailure) async -> Void + + init( + decidePolicy: + @escaping @Sendable (StoreTransactionSnapshot) async throws + -> StoreTransactionHandlingPolicy, + didFail: + @escaping @Sendable (StoreTransactionBackgroundFailure) async -> Void + ) { + self.policy = decidePolicy + self.failure = didFail + } + + func decidePolicy( + for transaction: StoreTransactionSnapshot + ) async throws -> StoreTransactionHandlingPolicy { + try await policy(transaction) + } + + func didFail( + with failure: StoreTransactionBackgroundFailure + ) async { + await self.failure(failure) + } +} + @MainActor private func withObservedStore( - handleTransaction: - @escaping @Sendable (StoreTransactionSnapshot) async throws -> Void, - reportFailure: + decidePolicy: + @escaping @Sendable (StoreTransactionSnapshot) async throws + -> StoreTransactionHandlingPolicy, + didFail: @escaping @Sendable (StoreTransactionBackgroundFailure) async -> Void, _ body: (ObservedStore) async throws -> Void ) async throws { let observedStore = ObservedStore( - handleTransaction: handleTransaction, - reportFailure: reportFailure + decidePolicy: decidePolicy, + didFail: didFail ) do { try await body(observedStore) @@ -782,12 +950,12 @@ private func withObservedStore( @MainActor private final class TransactionStoreObservation { - private weak var store: TransactionStore? + private weak var store: TransactionStore? private let changes: AsyncStream private let continuation: AsyncStream.Continuation private(set) var generation: UInt64 = 0 - init(store: TransactionStore) { + init(store: TransactionStore) { let pair = AsyncStream.makeStream( bufferingPolicy: .bufferingNewest(1) ) @@ -817,7 +985,8 @@ private final class TransactionStoreObservation { guard let store else { return } withObservationTracking { _ = store.entitlements - _ = store.startupError + _ = store.activeEntitlements + _ = store.entitlementStatus } onChange: { [weak self] in Task { @MainActor in self?.didChange() @@ -912,7 +1081,7 @@ private enum StoreKitTestEnvironment { } _ = try #require( try await Product.products( - for: [Entitlement.lifetime.rawValue] + for: [Plans.ProductID.tier1_Monthly.rawValue] ).first, "The StoreKit test configuration is not active." ) From 8100a72651050e2b60270c6d8069529b8a2e100f Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Thu, 23 Jul 2026 14:14:12 +0900 Subject: [PATCH 26/26] ci(test): fix iOS package test lane --- .github/workflows/ci.yml | 5 +- .../TransactionStoreTestClockTests.swift | 24 +++++----- ...utoRenewableSubscriptionCatalogTests.swift | 42 +++++++++-------- .../RuntimeOwnerTests.swift | 46 ++++++++++--------- 4 files changed, 62 insertions(+), 55 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 37bff82..34fffcd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -154,7 +154,7 @@ jobs: CODE_SIGNING_REQUIRED=NO ios-tests: - name: Package Tests (iOS Simulator) + name: Package and StoreKit Tests (iOS Simulator) runs-on: macos-26 timeout-minutes: 10 env: @@ -195,8 +195,9 @@ jobs: - name: Run iOS Simulator tests run: | xcodebuild test \ - -scheme StoreTransactionKit \ + -scheme StoreTransactionKit-Package \ -destination "platform=iOS Simulator,id=${SIMULATOR_ID}" \ + -parallel-testing-enabled NO \ CODE_SIGNING_ALLOWED=NO \ CODE_SIGNING_REQUIRED=NO diff --git a/Tests/StoreTransactionKitTestingTests/TransactionStoreTestClockTests.swift b/Tests/StoreTransactionKitTestingTests/TransactionStoreTestClockTests.swift index 241eaad..599776d 100644 --- a/Tests/StoreTransactionKitTestingTests/TransactionStoreTestClockTests.swift +++ b/Tests/StoreTransactionKitTestingTests/TransactionStoreTestClockTests.swift @@ -161,18 +161,20 @@ struct TransactionStoreTestClockTests { try await clock.waitUntilPendingSleepCount(reaches: 0) } - @Test("A negative advance is a programmer error") - func negativeAdvance() async { - await #expect(processExitsWith: .failure) { - TransactionStoreTestClock().advance(by: .seconds(-1)) + #if os(macOS) + @Test("A negative advance is a programmer error") + func negativeAdvance() async { + await #expect(processExitsWith: .failure) { + TransactionStoreTestClock().advance(by: .seconds(-1)) + } } - } - @Test("A negative pending-sleep target is a programmer error") - func negativePendingSleepCount() async { - await #expect(processExitsWith: .failure) { - try await TransactionStoreTestClock() - .waitUntilPendingSleepCount(reaches: -1) + @Test("A negative pending-sleep target is a programmer error") + func negativePendingSleepCount() async { + await #expect(processExitsWith: .failure) { + try await TransactionStoreTestClock() + .waitUntilPendingSleepCount(reaches: -1) + } } - } + #endif } diff --git a/Tests/StoreTransactionKitTests/AutoRenewableSubscriptionCatalogTests.swift b/Tests/StoreTransactionKitTests/AutoRenewableSubscriptionCatalogTests.swift index c1da4ac..9785270 100644 --- a/Tests/StoreTransactionKitTests/AutoRenewableSubscriptionCatalogTests.swift +++ b/Tests/StoreTransactionKitTests/AutoRenewableSubscriptionCatalogTests.swift @@ -264,33 +264,35 @@ struct AutoRenewableSubscriptionCatalogTests { } } - @Test("an empty group identifier is a construction error") - func emptyGroupIDFails() async { - await #expect(processExitsWith: .failure) { - _ = SubscriptionGroupID(rawValue: "") + #if os(macOS) + @Test("an empty group identifier is a construction error") + func emptyGroupIDFails() async { + await #expect(processExitsWith: .failure) { + _ = SubscriptionGroupID(rawValue: "") + } } - } - @Test("an empty subscription declaration is a construction error") - func emptyDeclarationFails() async { - await #expect(processExitsWith: .failure) { - _ = AutoRenewableSubscriptionCatalog(EmptyPlans.self) + @Test("an empty subscription declaration is a construction error") + func emptyDeclarationFails() async { + await #expect(processExitsWith: .failure) { + _ = AutoRenewableSubscriptionCatalog(EmptyPlans.self) + } } - } - @Test("an empty product identifier is a construction error") - func emptyProductIDFails() async { - await #expect(processExitsWith: .failure) { - _ = AutoRenewableSubscriptionCatalog(EmptyProductIDPlans.self) + @Test("an empty product identifier is a construction error") + func emptyProductIDFails() async { + await #expect(processExitsWith: .failure) { + _ = AutoRenewableSubscriptionCatalog(EmptyProductIDPlans.self) + } } - } - @Test("duplicate raw product identifiers are a construction error") - func duplicateProductIDFails() async { - await #expect(processExitsWith: .failure) { - _ = AutoRenewableSubscriptionCatalog(DuplicateProductIDPlans.self) + @Test("duplicate raw product identifiers are a construction error") + func duplicateProductIDFails() async { + await #expect(processExitsWith: .failure) { + _ = AutoRenewableSubscriptionCatalog(DuplicateProductIDPlans.self) + } } - } + #endif } private enum SubscriptionEntitlement: Hashable, Sendable { diff --git a/Tests/StoreTransactionKitTests/RuntimeOwnerTests.swift b/Tests/StoreTransactionKitTests/RuntimeOwnerTests.swift index 95d1613..dbbd719 100644 --- a/Tests/StoreTransactionKitTests/RuntimeOwnerTests.swift +++ b/Tests/StoreTransactionKitTests/RuntimeOwnerTests.swift @@ -477,34 +477,36 @@ struct RuntimeOwnerTests { replacement.release() } - @Test("the live lease is process-wide and releases explicitly") - func liveLeaseAuthority() async { - await #expect(processExitsWith: .failure) { + #if os(macOS) + @Test("the live lease is process-wide and releases explicitly") + func liveLeaseAuthority() async { + await #expect(processExitsWith: .failure) { + let first = LiveTransactionStoreLease.acquire() + let second = LiveTransactionStoreLease.acquire() + _ = (first, second) + } + let first = LiveTransactionStoreLease.acquire() + first.release() let second = LiveTransactionStoreLease.acquire() - _ = (first, second) + second.release() } - let first = LiveTransactionStoreLease.acquire() - first.release() - let second = LiveTransactionStoreLease.acquire() - second.release() - } - - @Test("live-store exclusion crosses TransactionStore generic specializations") - func crossGenericLiveLease() async { - await #expect(processExitsWith: .failure) { - await MainActor.run { - let first = TransactionStore( - subscriptionCatalog: testSubscriptionCatalog - ) - let second = TransactionStore( - subscriptionCatalog: otherSubscriptionCatalog - ) - _ = (first, second) + @Test("live-store exclusion crosses TransactionStore generic specializations") + func crossGenericLiveLease() async { + await #expect(processExitsWith: .failure) { + await MainActor.run { + let first = TransactionStore( + subscriptionCatalog: testSubscriptionCatalog + ) + let second = TransactionStore( + subscriptionCatalog: otherSubscriptionCatalog + ) + _ = (first, second) + } } } - } + #endif } private enum OtherEntitlement: Hashable, Sendable {