From e5ed03ecc42eabc1f393038a2216cac892a63bfb Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Wed, 15 Jul 2026 23:45:24 +0900 Subject: [PATCH] refactor(store): encode entitlement readiness in projection --- .../Sources/Consumer/main.swift | 6 +++++- README.md | 12 +++++++++--- Sources/StoreTransactionKit/Store.swift | 17 ++++++++++++----- .../StoreTransactionKit.md | 9 ++++++--- .../StoreTransactionKitTests/StoreTests.swift | 19 +++++++++++++++++++ 5 files changed, 51 insertions(+), 12 deletions(-) diff --git a/Fixtures/ExternalConsumer/Sources/Consumer/main.swift b/Fixtures/ExternalConsumer/Sources/Consumer/main.swift index 862597c..5775fb9 100644 --- a/Fixtures/ExternalConsumer/Sources/Consumer/main.swift +++ b/Fixtures/ExternalConsumer/Sources/Consumer/main.swift @@ -17,7 +17,11 @@ struct Consumer { } ) - print("Active entitlements: \(store.activeEntitlements)") + if let activeEntitlements = store.activeEntitlements { + print("Active entitlements: \(activeEntitlements)") + } else { + print("Active entitlements are unresolved") + } try await store.close() } } diff --git a/README.md b/README.md index 1517bf2..3e3fdcc 100644 --- a/README.md +++ b/README.md @@ -54,7 +54,9 @@ and `Transaction.updates` monitoring during initialization. Retain one instance in the application's process-lifetime composition. `activeEntitlements` contains the app-defined identifiers represented by -StoreKit's current entitlements. `entitlements` contains the complete verified +StoreKit's current entitlements. It is `nil` while the initial entitlement +query is unresolved and becomes a non-`nil` empty set when no known +subscription is active. `entitlements` contains the complete verified snapshot, including product identifiers outside `SubscriptionID`. ## Use SubscriptionStoreView @@ -77,8 +79,12 @@ struct PremiumStoreView: View { var body: some View { VStack { - if store.activeEntitlements.contains(.yearly) { - Label("Premium active", systemImage: "checkmark.seal.fill") + if let activeEntitlements = store.activeEntitlements { + if activeEntitlements.contains(.yearly) { + Label("Premium active", systemImage: "checkmark.seal.fill") + } + } else { + ProgressView() } SubscriptionStoreView( diff --git a/Sources/StoreTransactionKit/Store.swift b/Sources/StoreTransactionKit/Store.swift index b4a8901..169d3b5 100644 --- a/Sources/StoreTransactionKit/Store.swift +++ b/Sources/StoreTransactionKit/Store.swift @@ -28,7 +28,18 @@ where public private(set) var entitlements: StoreEntitlements? /// App-defined identifiers represented by the latest current entitlements. - public private(set) var activeEntitlements: Set = [] + /// + /// 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. + public var activeEntitlements: Set? { + entitlements.map { entitlements in + Set( + entitlements.transactions.compactMap { + EntitlementID(rawValue: $0.productID) + }) + } + } /// The error from the initial entitlement query when startup did not reach /// readiness. @@ -156,10 +167,6 @@ where fileprivate func apply(_ entitlements: StoreEntitlements) { self.entitlements = entitlements - activeEntitlements = Set( - entitlements.transactions.compactMap { - EntitlementID(rawValue: $0.productID) - }) startupError = nil } diff --git a/Sources/StoreTransactionKit/StoreTransactionKit.docc/StoreTransactionKit.md b/Sources/StoreTransactionKit/StoreTransactionKit.docc/StoreTransactionKit.md index 14e317c..136815f 100644 --- a/Sources/StoreTransactionKit/StoreTransactionKit.docc/StoreTransactionKit.md +++ b/Sources/StoreTransactionKit/StoreTransactionKit.docc/StoreTransactionKit.md @@ -12,9 +12,12 @@ processor and publishes observable current-entitlement state. 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; ``Store/activeEntitlements`` -then exposes a typed set derived from StoreKit's verified current entitlements. -Pass direct results from custom purchase UI into ``Store/process(_:)``. +The app defines a string-backed entitlement identifier type; +``Store/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. Pass direct results from custom purchase UI into +``Store/process(_:)``. The framework owns StoreKit verification, process-local exact-revision coalescing, `finish()`, entitlement refresh, history ordering, restore diff --git a/Tests/StoreTransactionKitTests/StoreTests.swift b/Tests/StoreTransactionKitTests/StoreTests.swift index a4e1196..d8a45e0 100644 --- a/Tests/StoreTransactionKitTests/StoreTests.swift +++ b/Tests/StoreTransactionKitTests/StoreTests.swift @@ -25,6 +25,7 @@ struct StoreTests { reportFailure: { _ in } ) + #expect(store.activeEntitlements == nil) await store.waitForStartup() #expect(store.activeEntitlements == [.monthly]) @@ -57,10 +58,12 @@ struct StoreTests { reportFailure: { _ in } ) + #expect(store.activeEntitlements == nil) 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() } @@ -75,6 +78,22 @@ struct StoreTests { try await store.close() } + @Test("an empty set means entitlement resolution completed") + func emptyTypedEntitlementProjection() async throws { + let fixture = TestSourceFixture(currentEntitlements: { [] }) + fixture.unfinished.finish() + let store = Store( + source: fixture.source, + handleTransaction: { _ in }, + reportFailure: { _ in } + ) + + #expect(store.activeEntitlements == nil) + await store.waitForStartup() + #expect(store.activeEntitlements == Set()) + try await store.close() + } + @Test("the Store facade rejects handler reentry during startup") func startupHandlerReentrancy() async throws { let fixture = TestSourceFixture()