From a489f5a387e23297703c3cf7fa81ebfe67f3c80e Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Fri, 17 Jul 2026 15:42:35 +0900 Subject: [PATCH 1/4] docs(storekit): restructure README and add transaction handling article The README had accreted normative contract prose across audit fixes, mixing the adoption guide with the contract reference and duplicating symbol documentation. Restructure it as an adoption funnel: ownership split, quick start, the handler contract, entitlement behavior summary, and short recipes, each stating a rule once with a link to its source of truth. Move the detailed delivery, reconciliation, and failure-reporting model into a new DocC article, Understanding transaction handling, linked from the README, the DocC landing page Topics, and the TransactionStore symbol documentation, so the contract text lives in one place. Co-Authored-By: Claude Fable 5 --- README.md | 146 ++++++++++-------- .../StoreTransactionKit.md | 44 +++--- .../UnderstandingTransactionHandling.md | 107 +++++++++++++ .../TransactionStore.swift | 3 + 4 files changed, 216 insertions(+), 84 deletions(-) create mode 100644 Sources/StoreTransactionKit/StoreTransactionKit.docc/UnderstandingTransactionHandling.md diff --git a/README.md b/README.md index 5dd4bbf..348c179 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,27 @@ durable processing, and `Transaction.finish()` into one process-owned, observable store. It supports iOS 18.4 and later and macOS 15.4 and later. -## Create a TransactionStore +## What it owns — and what your app owns + +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 + elements are skipped and reported +- Ordering: durable handling first, then `finish()`, with at-least-once + delivery to an idempotent handler and exact-revision deduplication +- 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 `Product.purchase`) +- The durable ledger that the transaction handler writes to +- Subscription status presentation (`Product.SubscriptionInfo.Status`) +- Purchases that begin outside the app (`PurchaseIntent.intents`) + +## Quick start Define the entitlement identifiers in the app. A string-backed enum keeps StoreKit product identifiers typed without requiring a framework protocol. @@ -59,45 +79,10 @@ func makeStore( } ``` -`TransactionStore` is `@MainActor` and `@Observable`. It monitors -`Transaction.unfinished`, `Transaction.updates`, and subscription status -changes during initialization. Retain one instance in the application's -process-lifetime composition. - -Startup and each entitlement refresh reconcile `Transaction.unfinished` -before publishing entitlement state. Every verified delivery is durably -handled, including consumables. If the handler fails, startup or the refresh -fails, the transaction remains unfinished, and a later refresh retries it. -An unverified unfinished delivery is sent to `reportFailure` with source -`.unfinished`. - -`activeEntitlements` contains the app-defined identifiers represented by -StoreKit's current entitlements. It is `nil` while the initial entitlement -query is unresolved and becomes a non-`nil` empty set when no known -identifier matches a current entitlement. A subscription superseded by an -upgrade is excluded from this set. `entitlements` contains the complete -verified snapshot, including superseded transactions and product identifiers -outside `SubscriptionID`. Use `StoreTransactionSnapshot.subscriptionGroupID` -when the app grants access at subscription-group rather than product-tier -granularity. A current-entitlement element that StoreKit can't verify is -omitted from the projection and delivered to `reportFailure` with source -`.currentEntitlementVerification`. - -For renewal dates, grace periods, billing retry, and expiration messaging, -read `Product.SubscriptionInfo.Status` directly. `TransactionStore` owns the -durable transaction path and current-entitlement projection, not subscription -status presentation. - -## Use SubscriptionStoreView - -Place the same store in the SwiftUI environment. StoreKit presents and -completes the purchase; the store's transaction listener updates observable -state when the entitlement changes. - -```swift -PremiumStoreView() - .environment(store) -``` +`TransactionStore` is `@MainActor` and `@Observable`. It starts monitoring +during initialization; retain one instance in the application's +process-lifetime composition. Place it in the SwiftUI environment and render +the three entitlement states — resolving, failed, and resolved: ```swift import StoreKit @@ -139,29 +124,62 @@ struct PremiumStoreView: View { } ``` -No `onInAppPurchaseCompletion` modifier is needed here. By default, successful -StoreKit view purchases are delivered through `Transaction.updates`, which the -store already monitors. A non-`nil` completion action replaces that default; -if you add one, pass each `.success` value (the `Product.PurchaseResult`) to -`store.process(_:)`. The action also replaces StoreKit's default failure alert, -so it must own `.failure` presentation or diagnostics. - -StoreTransactionKit exposes an at-least-once handler-delivery contract, so -`PurchaseLedger.apply(_:)` must be idempotent. It must return only after the -business effect is durable. The store calls `finish()` after that return. Treat -purchase and revocation revisions as distinct durable business events; -transaction ID alone is not a sufficient idempotency key for both. The handler -must not call methods on the same store, including through an awaited detached -task, because that creates a dependency cycle with the transaction being -handled. - -Apps that support promoted purchases or applicable win-back flows own -`PurchaseIntent.intents`: complete each intent's product purchase and pass its -result to `store.process(_:)`. - -Call `store.restorePurchases()` only from an explicit user action because -`AppStore.sync()` presents authentication UI. Treat `StoreKitError.userCancelled` -as a normal user outcome rather than a diagnostic failure. +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. + +## The handler contract + +`handleTransaction` is the one place where correctness depends on your code: + +- **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. + +The complete contract is 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. +- 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`. +- 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 + `StoreTransactionSnapshot.subscriptionGroupID` to grant at + subscription-group granularity. + +For the full delivery, reconciliation, and failure-reporting model, see +[Understanding transaction handling][understanding]. + +## Beyond the basics + +- **Custom purchase UI** — load products and purchase with StoreKit, then pass + the `Product.PurchaseResult` to `store.process(_:)`. `.pending` outcomes + arrive later through the handler. +- **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** — the app owns + `PurchaseIntent.intents`: complete each intent's purchase and pass 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) @@ -177,3 +195,5 @@ command. ## License StoreTransactionKit is available under the MIT License. + +[understanding]: https://lynnswap.github.io/StoreTransactionKit/documentation/storetransactionkit/understandingtransactionhandling diff --git a/Sources/StoreTransactionKit/StoreTransactionKit.docc/StoreTransactionKit.md b/Sources/StoreTransactionKit/StoreTransactionKit.docc/StoreTransactionKit.md index c0cf6a1..0f437e7 100644 --- a/Sources/StoreTransactionKit/StoreTransactionKit.docc/StoreTransactionKit.md +++ b/Sources/StoreTransactionKit/StoreTransactionKit.docc/StoreTransactionKit.md @@ -8,32 +8,30 @@ verified transaction only after its durable business effect succeeds. 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. - -Startup and entitlement refreshes reconcile every verified transaction still -reported by `Transaction.unfinished`, including consumables, before publishing -entitlement state. A durable handler failure fails readiness or the refresh and -leaves that transaction available for a later retry. Unverified unfinished -deliveries are reported through ``StoreTransactionBackgroundFailure/Source/unfinished``. - -Create one store in the application composition root. Supply an idempotent -transaction handler that commits the app's business effect before returning. -The app defines a string-backed entitlement identifier type; +processor and publishes observable current-entitlement state. It supports +iOS 18.4 and later and macOS 15.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. `nil` means the initial -entitlement query remains unresolved; an empty set means the query completed -with no matching entitlement. Transactions superseded by a subscription upgrade -remain in the complete snapshot but don't appear in the typed active set. Pass -direct results from custom purchase UI into ``TransactionStore/process(_:)``. +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, and -the concrete purchase scene or window. It also owns raw -`Product.SubscriptionInfo.Status` interpretation for renewal, grace-period, -and billing-retry UI, and `PurchaseIntent.intents` handling for purchases that -begin outside 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 `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 @@ -43,6 +41,10 @@ begin outside the app. ## Topics +### Essentials + +- + ### Creating a store - ``TransactionStore`` diff --git a/Sources/StoreTransactionKit/StoreTransactionKit.docc/UnderstandingTransactionHandling.md b/Sources/StoreTransactionKit/StoreTransactionKit.docc/UnderstandingTransactionHandling.md new file mode 100644 index 0000000..26b32b1 --- /dev/null +++ b/Sources/StoreTransactionKit/StoreTransactionKit.docc/UnderstandingTransactionHandling.md @@ -0,0 +1,107 @@ +# 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. Published entitlement state therefore +never runs ahead of the durable ledger: a purchase made on another device is +handled and finished before it appears in +``TransactionStore/activeEntitlements``. + +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. Unverified deliveries 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 + +Failures travel on exactly one of two channels. Work with an attached caller +throws to that caller. Work with no attached public caller — background +deliveries, and operations whose every waiting caller cancelled — is reported +once through the failure callback as a +``StoreTransactionBackgroundFailure``, which is delivered losslessly with +backpressure. A reconciliation failure that was already reported with source +``StoreTransactionBackgroundFailure/Source/unfinished`` is not reported a +second time when it also fails the refresh that triggered it. + +``TransactionStore/startupError`` holds the error from the initial readiness +attempt. A later successful entitlement refresh — which also retries the +unfinished work that failed — clears it; call +``TransactionStore/refreshEntitlements()`` from a retry affordance in the UI. + +## 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. diff --git a/Sources/StoreTransactionKit/TransactionStore.swift b/Sources/StoreTransactionKit/TransactionStore.swift index a7c9bb2..c6ba8db 100644 --- a/Sources/StoreTransactionKit/TransactionStore.swift +++ b/Sources/StoreTransactionKit/TransactionStore.swift @@ -15,6 +15,9 @@ import StoreKit /// ``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. @MainActor @Observable public final class TransactionStore From 4fc2a1c2e63335692ded439c036a52bad9580352 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Fri, 17 Jul 2026 15:48:02 +0900 Subject: [PATCH 2/4] docs(storekit): scope reconciliation and verification-reporting claims Codex review found two overclaims in the new article: the reconciliation guarantee does not cover transactions already finished on another device (they never appear in Transaction.unfinished), and an unverified purchase result passed to process(_:) throws to the caller rather than reaching the failure callback. State both boundaries explicitly and align the README verification bullet. Co-Authored-By: Claude Fable 5 --- README.md | 2 +- .../UnderstandingTransactionHandling.md | 17 +++++++++++------ 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 348c179..6ee6d7d 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ 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 - elements are skipped and reported + 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 - State: the observable current-entitlement projection, restore diff --git a/Sources/StoreTransactionKit/StoreTransactionKit.docc/UnderstandingTransactionHandling.md b/Sources/StoreTransactionKit/StoreTransactionKit.docc/UnderstandingTransactionHandling.md index 26b32b1..2556822 100644 --- a/Sources/StoreTransactionKit/StoreTransactionKit.docc/UnderstandingTransactionHandling.md +++ b/Sources/StoreTransactionKit/StoreTransactionKit.docc/UnderstandingTransactionHandling.md @@ -22,10 +22,12 @@ idempotent across process launches and cache eviction. Startup and every entitlement refresh query `Transaction.unfinished` and durably handle each verified delivery — including consumables — before the -entitlement projection is published. Published entitlement state therefore -never runs ahead of the durable ledger: a purchase made on another device is -handled and finished before it appears in -``TransactionStore/activeEntitlements``. +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 @@ -38,8 +40,11 @@ retry. ## Verification Snapshots exist only for transactions that StoreKit verified; the handler and -the projection never observe unverified data. Unverified deliveries are -reported through the failure callback instead: +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 deliveries on paths with no attached caller +are reported through the failure callback instead: - From `Transaction.updates`, with source ``StoreTransactionBackgroundFailure/Source/updates``. From 224549f43c06a8349dfd2d78ee89867a7ac1b9a8 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Fri, 17 Jul 2026 15:49:53 +0900 Subject: [PATCH 3/4] docs(storekit): add Requirements section and fix symbol link warning List platform and toolchain requirements as a scannable README section. Replace the unresolved StoreKit/StoreKitError/userCancelled symbol links with plain code voice, matching the existing convention for StoreKit symbols and silencing the docbuild warning. Co-Authored-By: Claude Fable 5 --- README.md | 7 ++++++- Sources/StoreTransactionKit/StoreTransactionSession.swift | 2 +- Sources/StoreTransactionKit/TransactionStore.swift | 2 +- 3 files changed, 8 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 6ee6d7d..0174b40 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,12 @@ StoreTransactionKit moves StoreKit 2 transaction monitoring, verification, durable processing, and `Transaction.finish()` into one process-owned, observable store. -It supports iOS 18.4 and later and macOS 15.4 and later. + +## Requirements + +- iOS 18.4+ +- macOS 15.4+ +- Swift 6.3+ ## What it owns — and what your app owns diff --git a/Sources/StoreTransactionKit/StoreTransactionSession.swift b/Sources/StoreTransactionKit/StoreTransactionSession.swift index e79df6e..02f06f2 100644 --- a/Sources/StoreTransactionKit/StoreTransactionSession.swift +++ b/Sources/StoreTransactionKit/StoreTransactionSession.swift @@ -211,7 +211,7 @@ package actor StoreTransactionSession { /// - 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 - /// ``StoreKit/StoreKitError/userCancelled`` when the user dismisses + /// `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) diff --git a/Sources/StoreTransactionKit/TransactionStore.swift b/Sources/StoreTransactionKit/TransactionStore.swift index c6ba8db..40063df 100644 --- a/Sources/StoreTransactionKit/TransactionStore.swift +++ b/Sources/StoreTransactionKit/TransactionStore.swift @@ -179,7 +179,7 @@ where /// /// This method can present authentication UI. It refreshes observable /// entitlement state before returning. StoreKit may throw - /// ``StoreKit/StoreKitError/userCancelled`` when the user dismisses + /// `StoreKitError.userCancelled` when the user dismisses /// authentication; treat that as a normal user outcome rather than a /// diagnostic failure. @discardableResult From 30e476df021043111cbdf49d178b043e38d89e8e Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Fri, 17 Jul 2026 17:04:50 +0900 Subject: [PATCH 4/4] docs(storekit): align lifecycle and failure reporting guidance --- README.md | 47 ++++++++++++++--- .../StoreTransactionFailure.swift | 6 +-- .../UnderstandingTransactionHandling.md | 51 +++++++++++++------ .../StoreTransactionSession.swift | 11 ++-- .../TransactionStore.swift | 15 ++++-- 5 files changed, 97 insertions(+), 33 deletions(-) diff --git a/README.md b/README.md index 0174b40..1391ac2 100644 --- a/README.md +++ b/README.md @@ -85,9 +85,39 @@ func makeStore( ``` `TransactionStore` is `@MainActor` and `@Observable`. It starts monitoring -during initialization; retain one instance in the application's -process-lifetime composition. Place it in the SwiftUI environment and render -the three entitlement states — resolving, failed, and resolved: +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: + +```swift +import SwiftUI + +@main +struct ExampleApp: App { + @State private var store: TransactionStore + + init() { + let ledger = PurchaseLedger() + let diagnostics = StoreDiagnostics() + _store = State( + initialValue: makeStore( + ledger: ledger, + diagnostics: diagnostics + ) + ) + } + + var body: some Scene { + WindowGroup { + PremiumStoreView() + .environment(store) + } + } +} +``` + +The view reads the store directly and renders the three entitlement states — +resolving, failed, and resolved: ```swift import StoreKit @@ -135,9 +165,9 @@ 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 handler contract +## The callback contracts -`handleTransaction` is the one place where correctness depends on your code: +`handleTransaction` 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. @@ -151,7 +181,12 @@ monitors. If you add a non-`nil` completion action, it replaces that default `reportFailure`, even through an awaited detached task — doing so creates a dependency cycle with the work being handled. -The complete contract is documented on `TransactionStore.init`. +`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. + +Both callback contracts are documented on `TransactionStore.init`. ## How entitlement state behaves diff --git a/Sources/StoreTransactionKit/StoreTransactionFailure.swift b/Sources/StoreTransactionKit/StoreTransactionFailure.swift index 2fd2bf6..2b01604 100644 --- a/Sources/StoreTransactionKit/StoreTransactionFailure.swift +++ b/Sources/StoreTransactionKit/StoreTransactionFailure.swift @@ -24,9 +24,9 @@ package enum StoreTransactionCallback: Sendable { case failureReporter } -/// A failure produced by work that has no attached public caller. +/// A failure delivered through the process-owned background reporting path. public struct StoreTransactionBackgroundFailure: Error, Sendable { - /// The background path that produced the failure. + /// The background owner that reported the failure. public enum Source: Sendable, Hashable { /// A delivery from `Transaction.updates`. case updates @@ -44,7 +44,7 @@ public struct StoreTransactionBackgroundFailure: Error, Sendable { case abandonedDirectOperation(StoreTransactionOperation) } - /// The background path that produced the failure. + /// The background owner that reported the failure. public let source: Source /// The verified transaction identifier, when verification reached a transaction snapshot. diff --git a/Sources/StoreTransactionKit/StoreTransactionKit.docc/UnderstandingTransactionHandling.md b/Sources/StoreTransactionKit/StoreTransactionKit.docc/UnderstandingTransactionHandling.md index 2556822..14cf719 100644 --- a/Sources/StoreTransactionKit/StoreTransactionKit.docc/UnderstandingTransactionHandling.md +++ b/Sources/StoreTransactionKit/StoreTransactionKit.docc/UnderstandingTransactionHandling.md @@ -43,8 +43,9 @@ 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 deliveries on paths with no attached caller -are reported through the failure callback instead: +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``. @@ -77,22 +78,40 @@ 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 - -Failures travel on exactly one of two channels. Work with an attached caller -throws to that caller. Work with no attached public caller — background -deliveries, and operations whose every waiting caller cancelled — is reported -once through the failure callback as a -``StoreTransactionBackgroundFailure``, which is delivered losslessly with -backpressure. A reconciliation failure that was already reported with source -``StoreTransactionBackgroundFailure/Source/unfinished`` is not reported a -second time when it also fails the refresh that triggered it. - -``TransactionStore/startupError`` holds the error from the initial readiness -attempt. A later successful entitlement refresh — which also retries the -unfinished work that failed — clears it; call +## 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 diff --git a/Sources/StoreTransactionKit/StoreTransactionSession.swift b/Sources/StoreTransactionKit/StoreTransactionSession.swift index 02f06f2..1f06574 100644 --- a/Sources/StoreTransactionKit/StoreTransactionSession.swift +++ b/Sources/StoreTransactionKit/StoreTransactionSession.swift @@ -39,10 +39,15 @@ package actor StoreTransactionSession { /// successfully. /// - entitlementsDidChange: Receives complete, ordered entitlement /// snapshots when the current entitlement content changes. - /// - reportFailure: Receives failures from process-owned work that has no - /// attached public caller. The callback is lossless and backpressured. + /// - 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. + /// 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: diff --git a/Sources/StoreTransactionKit/TransactionStore.swift b/Sources/StoreTransactionKit/TransactionStore.swift index 40063df..3cd518d 100644 --- a/Sources/StoreTransactionKit/TransactionStore.swift +++ b/Sources/StoreTransactionKit/TransactionStore.swift @@ -71,11 +71,16 @@ where /// 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 task, because doing so creates a - /// dependency cycle with the operation being handled. - /// - reportFailure: Receives failures from process-owned transaction work - /// that has no attached public caller. This callback must not call back - /// into the same store, directly or through an awaited child task. + /// 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. public convenience init( handleTransaction: @escaping @Sendable (StoreTransactionSnapshot) async throws -> Void,