Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
e621c25
docs(readme): redesign subscription quick start
lynnswap Jul 17, 2026
984ad61
docs(readme): model subscription groups explicitly
lynnswap Jul 17, 2026
223db9f
docs(readme): add entitlement query API
lynnswap Jul 17, 2026
edb556e
docs(readme): clarify catalog failure state
lynnswap Jul 17, 2026
751648b
docs(api): add subscription catalog design proposal
lynnswap Jul 17, 2026
b801e43
docs(api): document optional transaction delegate
lynnswap Jul 17, 2026
345fee2
docs(api): scope catalog to auto-renewable subscriptions
lynnswap Jul 19, 2026
089ef82
docs(api): refine subscription catalog contracts
lynnswap Jul 22, 2026
74d6afc
docs(api): redesign subscription catalog declarations
lynnswap Jul 23, 2026
220b407
feat(api)!: add transaction policy and state primitives
lynnswap Jul 23, 2026
b6d18fc
feat(testing): add deterministic transaction store clock
lynnswap Jul 23, 2026
e409419
feat(catalog): add typed subscription catalog API
lynnswap Jul 23, 2026
07a05d9
feat(runtime): implement entitlement transaction store
lynnswap Jul 23, 2026
27c1e66
fix(runtime): release completed finite tasks
lynnswap Jul 23, 2026
22cab96
test(runtime): restore transaction lifecycle coverage
lynnswap Jul 23, 2026
5b1b0ac
feat(testing): add deterministic subscription harness
lynnswap Jul 23, 2026
6cfb874
test(catalog): use typed throws in assertions
lynnswap Jul 23, 2026
084901e
docs(api): align consumer contract
lynnswap Jul 23, 2026
d17bd5c
ci(test): run external consumer tests
lynnswap Jul 23, 2026
f7db25c
docs(api): remove implemented design proposal
lynnswap Jul 23, 2026
2f7a063
docs(testing): show consumer-owned clock injection
lynnswap Jul 23, 2026
9ac9ad6
test(runtime): cover coalesced refresh failure ownership
lynnswap Jul 23, 2026
12d603a
test(runtime): cover restore synchronization retry
lynnswap Jul 23, 2026
700d50c
docs(api): align raw projection with StoreKit
lynnswap Jul 23, 2026
d77b455
test(storekit): migrate app-hosted subscription scenarios
lynnswap Jul 23, 2026
8100a72
ci(test): fix iOS package test lane
lynnswap Jul 23, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 5 additions & 4 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 }})
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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

Expand Down
21 changes: 20 additions & 1 deletion Fixtures/ExternalConsumer/Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
]
),
]
)
75 changes: 60 additions & 15 deletions Fixtures/ExternalConsumer/Sources/Consumer/Consumer.swift
Original file line number Diff line number Diff line change
@@ -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<SubscriptionEntitlement> {
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<SubscriptionEntitlement>

public var canExportPDF: Bool {
store.isEntitled(to: .tier1)
}

public init(store: TransactionStore<SubscriptionEntitlement>) {
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<EntitlementID>(
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()
}
}
Original file line number Diff line number Diff line change
@@ -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)
}
}
16 changes: 15 additions & 1 deletion Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
),
]
)
Loading