Skip to content

Commit

Permalink
Sendable annotations and API adjustments for better Swift concurrency…
Browse files Browse the repository at this point in the history
… conformance (#21)

* Eliminating many Swift concurrency warnings

* More Sendable fixes for strict concurrency

* Removes strict concurrency from package for now

* Try to fix Swift workflow

* Specify platform availability for fewer warnings
  • Loading branch information
jaanus authored Jun 19, 2024
1 parent 83996e4 commit a6b1884
Show file tree
Hide file tree
Showing 63 changed files with 186 additions and 132 deletions.
3 changes: 2 additions & 1 deletion .github/workflows/swift.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,8 @@ jobs:
runs-on: macos-latest

steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v4
- uses: swift-actions/setup-swift@v2
- name: Build
run: swift build -v
- name: Run tests
Expand Down
2 changes: 1 addition & 1 deletion Package.swift
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
// swift-tools-version: 5.7
// swift-tools-version: 5.10
// The swift-tools-version declares the minimum version of Swift required to build this package.

import Foundation
Expand Down
2 changes: 1 addition & 1 deletion Targets/Canopy/Sources/CKContainerAPI/CKContainerAPI.swift
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ actor CKContainerAPI: CKContainerAPIType {
statusContinuation?.yield(status)
}

nonisolated func fetchShareParticipants(
func fetchShareParticipants(
with lookupInfos: [CKUserIdentity.LookupInfo],
qos: QualityOfService
) async -> Result<[CKShare.Participant], CKRecordError> {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ public enum CKContainerAPIError: Error {
/// which lets you skip specifying some parameters and provides reasonable default values for them.
///
/// To access your app’s actual data in CloudKit, see ``CKDatabaseAPIType``.
public protocol CKContainerAPIType {
public protocol CKContainerAPIType: Sendable {
/// Obtain the user record ID for the current CloudKit user.
///
/// You don’t need to do this for regular CloudKit use. Your app doesn’t need to know anything about the current user,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import CloudKit

@available(iOS 16.4, macOS 13.3, *)
extension CKDatabaseAPI {
func randomCKRecordError(
codes: Set<CKError.Code>,
Expand Down
7 changes: 4 additions & 3 deletions Targets/Canopy/Sources/CKDatabaseAPI/CKDatabaseAPI.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@ import Foundation
import os.log
import Semaphore

class CKDatabaseAPI: CKDatabaseAPIType {
@available(iOS 16.4, macOS 13.3, *)
actor CKDatabaseAPI: CKDatabaseAPIType {
private let database: CKDatabaseType
private let databaseScope: CKDatabase.Scope
internal let settingsProvider: () async -> CanopySettingsType
Expand All @@ -16,7 +17,7 @@ class CKDatabaseAPI: CKDatabaseAPIType {
init(
database: CKDatabaseType,
databaseScope: CKDatabase.Scope,
settingsProvider: @escaping () async -> CanopySettingsType = { CanopySettings() },
settingsProvider: @escaping @Sendable () async -> CanopySettingsType = { CanopySettings() },
tokenStore: TokenStoreType
) {
self.database = database
Expand All @@ -32,7 +33,7 @@ class CKDatabaseAPI: CKDatabaseAPIType {
qos: QualityOfService
) async -> Result<ModifyRecordsResult, CKRecordError> {
let settings = await settingsProvider()
let modifyRecordsBehavior = settings.modifyRecordsBehavior
let modifyRecordsBehavior = await settings.modifyRecordsBehavior
switch modifyRecordsBehavior {
case let .regular(delay):
if let delay {
Expand Down
6 changes: 3 additions & 3 deletions Targets/Canopy/Sources/CKDatabaseAPI/CKDatabaseAPIType.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,10 @@ import Foundation
///
/// Methods of this protocol have a preferred shorthand way of calling them via a protocol extension,
/// which lets you skip specifying some parameters and provides reasonable default values for them.
public protocol CKDatabaseAPIType {
public protocol CKDatabaseAPIType: Sendable {

typealias PerRecordProgressBlock = (CKRecord, Double) -> Void
typealias PerRecordIDProgressBlock = (CKRecord.ID, Double) -> Void
typealias PerRecordProgressBlock = @Sendable (CKRecord, Double) -> Void
typealias PerRecordIDProgressBlock = @Sendable (CKRecord.ID, Double) -> Void

/// See ``CKDatabaseAPIType/queryRecords(with:in:resultsLimit:qualityOfService:)`` for preferred way of calling this API.
func queryRecords(
Expand Down
25 changes: 13 additions & 12 deletions Targets/Canopy/Sources/Canopy/Canopy.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,14 @@ import Foundation
/// You construct Canopy with injected CloudKit container and databases, token store, and settings provider.
/// Canopy has reasonable defaults for all of these, and you need to only override the ones that need to use
/// a different value from the default.
@available(iOS 16.4, macOS 13.3, *)
public actor Canopy: CanopyType {
private let containerProvider: () -> CKContainerType
private let publicCloudDatabaseProvider: () -> CKDatabaseType
private let privateCloudDatabaseProvider: () -> CKDatabaseType
private let sharedCloudDatabaseProvider: () -> CKDatabaseType
private let settingsProvider: () -> CanopySettingsType
private let tokenStoreProvider: () -> TokenStoreType
private let containerProvider: @Sendable () -> CKContainerType
private let publicCloudDatabaseProvider: @Sendable () -> CKDatabaseType
private let privateCloudDatabaseProvider: @Sendable () -> CKDatabaseType
private let sharedCloudDatabaseProvider: @Sendable () -> CKDatabaseType
private let settingsProvider: @Sendable () -> CanopySettingsType
private let tokenStoreProvider: @Sendable () -> TokenStoreType

private var containerAPI: CKContainerAPI?
private var databaseAPIs: [CKDatabase.Scope: CKDatabaseAPI] = [:]
Expand All @@ -31,12 +32,12 @@ public actor Canopy: CanopyType {
/// - tokenStore: an object that stores and returns zone and database tokens for the requests that work with the tokens.
/// Canopy only interacts with the token store when using the ``CKDatabaseAPIType/fetchDatabaseChanges(qualityOfService:)`` and ``CKDatabaseAPIType/fetchZoneChanges(recordZoneIDs:fetchMethod:qualityOfService:)`` APIs. If you don’t use these APIs, you can ignore this parameter.
public init(
container: @escaping @autoclosure () -> CKContainerType = CKContainer.default(),
publicCloudDatabase: @escaping @autoclosure () -> CKDatabaseType = CKContainer.default().publicCloudDatabase,
privateCloudDatabase: @escaping @autoclosure () -> CKDatabaseType = CKContainer.default().privateCloudDatabase,
sharedCloudDatabase: @escaping @autoclosure () -> CKDatabaseType = CKContainer.default().sharedCloudDatabase,
settings: @escaping () -> CanopySettingsType = { CanopySettings() },
tokenStore: @escaping @autoclosure () -> TokenStoreType = UserDefaultsTokenStore()
container: @escaping @autoclosure @Sendable () -> CKContainerType = CKContainer.default(),
publicCloudDatabase: @escaping @autoclosure @Sendable () -> CKDatabaseType = CKContainer.default().publicCloudDatabase,
privateCloudDatabase: @escaping @autoclosure @Sendable () -> CKDatabaseType = CKContainer.default().privateCloudDatabase,
sharedCloudDatabase: @escaping @autoclosure @Sendable () -> CKDatabaseType = CKContainer.default().sharedCloudDatabase,
settings: @escaping @Sendable () -> CanopySettingsType = { CanopySettings() },
tokenStore: @escaping @autoclosure @Sendable () -> TokenStoreType = UserDefaultsTokenStore()
) {
self.containerProvider = container
self.publicCloudDatabaseProvider = publicCloudDatabase
Expand Down
2 changes: 1 addition & 1 deletion Targets/Canopy/Sources/Canopy/CanopyType.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import Foundation
///
/// For testability, you should build your features in a way where they interact with Canopy CloudKit APIs, without needing
/// to know whether they are talking to a real or mock backend.
public protocol CanopyType {
public protocol CanopyType: Sendable {

/// Get the API provider to run requests against a CloudKit container.
func containerAPI() async -> CKContainerAPIType
Expand Down
5 changes: 3 additions & 2 deletions Targets/Canopy/Sources/Canopy/MockCanopy.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,19 +12,20 @@ import CloudKit
/// You only need to inject the containers and databases that your tests actually use.
/// If you try to use a dependency that’s not been injected correctly, MockCanopy crashes
/// with an error message indicating that.
@available(iOS 16.4, macOS 13.3, *)
public struct MockCanopy: CanopyType {
private let mockPrivateDatabase: CKDatabaseType?
private let mockSharedDatabase: CKDatabaseType?
private let mockPublicDatabase: CKDatabaseType?
private let mockContainer: CKContainerType?
private let settingsProvider: () async -> CanopySettingsType
private let settingsProvider: @Sendable () async -> CanopySettingsType

public init(
mockPrivateDatabase: CKDatabaseType? = nil,
mockSharedDatabase: CKDatabaseType? = nil,
mockPublicDatabase: CKDatabaseType? = nil,
mockContainer: CKContainerType? = nil,
settingsProvider: @escaping () async -> CanopySettingsType = { CanopySettings() }
settingsProvider: @escaping @Sendable () async -> CanopySettingsType = { CanopySettings() }
) {
self.mockPublicDatabase = mockPublicDatabase
self.mockSharedDatabase = mockSharedDatabase
Expand Down
4 changes: 3 additions & 1 deletion Targets/Canopy/Sources/Dependency/Canopy+Dependency.swift
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import Dependencies

private enum CanopyKey: DependencyKey {
@available(iOS 16.4, macOS 13.3, *)
private enum CanopyKey: DependencyKey, Sendable {
static let liveValue: CanopyType = Canopy()
static let testValue: CanopyType = MockCanopy()
static let previewValue: CanopyType = MockCanopy()
}

@available(iOS 16.4, macOS 13.3, *)
public extension DependencyValues {
/// Canopy packaged as CloudKit dependency via swift-dependencies.
var cloudKit: CanopyType {
Expand Down
2 changes: 1 addition & 1 deletion Targets/Canopy/Sources/Results/DeletedCKRecord.swift
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import CloudKit

public struct DeletedCKRecord: Codable, Equatable {
public struct DeletedCKRecord: Codable, Equatable, Sendable {
private let typeString: String
private let recordName: String
private let zoneName: String
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import CloudKit
import Foundation

public struct FetchDatabaseChangesResult: Equatable {
public struct FetchDatabaseChangesResult: Equatable, Sendable {
public let changedRecordZoneIDs: [CKRecordZone.ID]
public let deletedRecordZoneIDs: [CKRecordZone.ID]
public let purgedRecordZoneIDs: [CKRecordZone.ID]
Expand Down
2 changes: 1 addition & 1 deletion Targets/Canopy/Sources/Results/FetchRecordsResult.swift
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import CloudKit

/// Successful result for a function call to fetch records.
public struct FetchRecordsResult {
public struct FetchRecordsResult: Sendable {
/// Records that were found.
public let foundRecords: [CKRecord]

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import Foundation
/// Limiting record key fields may reduce the download size if you are
/// interested only in the tokens, or only some specific fields (but e.g
/// not asset fields that may contain large files).
public enum FetchZoneChangesMethod {
public enum FetchZoneChangesMethod: Sendable {
/// Fetch tokens and all available data.
case changeTokenAndAllData

Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import CloudKit
import Foundation

public struct FetchZoneChangesResult {
public struct FetchZoneChangesResult: Sendable {
public let changedRecords: [CKRecord]
public let deletedRecords: [DeletedCKRecord]

Expand Down
2 changes: 1 addition & 1 deletion Targets/Canopy/Sources/Results/ModifyRecordsResult.swift
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import CloudKit

/// Successful result of record modification and deletion functions, containing details about saved and deleted records.
public struct ModifyRecordsResult: Equatable {
public struct ModifyRecordsResult: Equatable, Sendable {
/// An array of saved records. The records likely have different metadata from the records that you gave to the modification function
/// as input, because CloudKit updates the record modification timestamp and change tag on the server side when saving records.
public let savedRecords: [CKRecord]
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import CloudKit

public struct ModifySubscriptionsResult: Equatable {
public struct ModifySubscriptionsResult: Equatable, Sendable {
public let savedSubscriptions: [CKSubscription]
public let deletedSubscriptionIDs: [CKSubscription.ID]

Expand Down
2 changes: 1 addition & 1 deletion Targets/Canopy/Sources/Results/ModifyZonesResult.swift
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import CloudKit
import Foundation

public struct ModifyZonesResult: Equatable {
public struct ModifyZonesResult: Equatable, Sendable {
public let savedZones: [CKRecordZone]
public let deletedZoneIDs: [CKRecordZone.ID]

Expand Down
14 changes: 7 additions & 7 deletions Targets/Canopy/Sources/Settings/CanopySettingsType.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
/// is to let you test failed requests in a real client environment. You could have a
/// “developer switch” somewhere in your app to simulate errors, to see how your app
/// responds to errors in a real build.
public enum RequestBehavior: Equatable {
public enum RequestBehavior: Equatable, Sendable {
/// Regular behavior. Attempt to run the request against the backend. If the optional
/// associated value is present, the request is delayed for the given number of seconds,
/// somewhat simulating slow network conditions and letting you see how your UI
Expand All @@ -30,17 +30,17 @@ public enum RequestBehavior: Equatable {
/// By default, Canopy uses reasonable defaults for all these settings. If you would like
/// to modify Canopy behavior, you can construct Canopy with a `CanopySettings` struct
/// which has some of the values modified, or pass any custom value that implements this protocol.
public protocol CanopySettingsType {
public protocol CanopySettingsType: Sendable {
/// Behavior for “modify records” request.
///
/// Applies to both saving and deleting records.
var modifyRecordsBehavior: RequestBehavior { get }
var modifyRecordsBehavior: RequestBehavior { get async }

/// Behavior for “fetch database changes” request.
var fetchDatabaseChangesBehavior: RequestBehavior { get }
var fetchDatabaseChangesBehavior: RequestBehavior { get async }

/// Behavior for “fetch zone changes” request.
var fetchZoneChangesBehavior: RequestBehavior { get }
var fetchZoneChangesBehavior: RequestBehavior { get async }

/// Resend a modification request if the initial batch is too large.
///
Expand All @@ -50,7 +50,7 @@ public protocol CanopySettingsType {
///
/// Canopy does this by default. If you wish, you can turn this off.
/// You will then get `limitExceeded` error returned.
var autoBatchTooLargeModifyOperations: Bool { get }
var autoBatchTooLargeModifyOperations: Bool { get async }

/// Retry failed operations that are retriable.
///
Expand All @@ -66,5 +66,5 @@ public protocol CanopySettingsType {
///
/// Currently this is implemented in Canopy only for modifying records.
/// All other requests fail immediately without retrying if there is an error.
var autoRetryForRetriableErrors: Bool { get }
var autoRetryForRetriableErrors: Bool { get async }
}
2 changes: 1 addition & 1 deletion Targets/Canopy/Sources/TokenStore/TestTokenStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import CloudKit
///
/// The function call counts are used by Canopy test suite. You can also use them in your own tests, to make sure
/// that the tokens are actually stored and requested as you expect.
public class TestTokenStore: TokenStoreType {
public actor TestTokenStore: TokenStoreType {
public init() {}

/// How many times "storeToken:forDatabaseScope:" has been called.
Expand Down
2 changes: 1 addition & 1 deletion Targets/Canopy/Sources/TokenStore/TokenStoreType.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import CloudKit
/// TokenStore and Canopy currently assume that the application works with only one CKContainer.
/// There is currently no facility to distinguish between multiple CKContainers. This is a good enough assumption
/// for most CloudKit applications.
public protocol TokenStoreType {
public protocol TokenStoreType: Sendable {
/// Store a token for the given database scope.
///
/// - Parameter token: token to be stored. May be nil if it needs to be removed from storage for whatever reason.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import os.log
/// on macOS during development, because the `defaults` command-line utility and many other tools
/// provide you easy access to the stored tokens in your system. You can verify that the tokens do get stored,
/// and clear them manually if needed.
public struct UserDefaultsTokenStore: TokenStoreType {
public actor UserDefaultsTokenStore: TokenStoreType {
private let logger = Logger(subsystem: "Canopy", category: "UserDefaultsTokenStore")

public init() {}
Expand Down
19 changes: 12 additions & 7 deletions Targets/Canopy/Tests/CanopyTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import CanopyTestTools
import CloudKit
import XCTest

@available(iOS 16.4, macOS 13.3, *)
final class CanopyTests: XCTestCase {
func test_init_with_default_settings() async {
let _ = Canopy(
Expand All @@ -18,15 +19,19 @@ final class CanopyTests: XCTestCase {
let changedRecordID = CKRecord.ID(recordName: "SomeRecordName")
let changedRecord = CKRecord(recordType: "TestRecord", recordID: changedRecordID)

struct ModifiableSettings: CanopySettingsType {
actor ModifiableSettings: CanopySettingsType {
var modifyRecordsBehavior: RequestBehavior = .regular(nil)
var fetchZoneChangesBehavior: RequestBehavior = .regular(nil)
var fetchDatabaseChangesBehavior: RequestBehavior = .regular(nil)
var autoBatchTooLargeModifyOperations: Bool = true
var autoRetryForRetriableErrors: Bool = true
let fetchZoneChangesBehavior: RequestBehavior = .regular(nil)
let fetchDatabaseChangesBehavior: RequestBehavior = .regular(nil)
let autoBatchTooLargeModifyOperations: Bool = true
let autoRetryForRetriableErrors: Bool = true

func setModifyRecordsBehavior(behavior: RequestBehavior) {
modifyRecordsBehavior = behavior
}
}

var modifiableSettings = ModifiableSettings()
let modifiableSettings = ModifiableSettings()

let canopy = Canopy(
container: ReplayingMockCKContainer(),
Expand Down Expand Up @@ -64,7 +69,7 @@ final class CanopyTests: XCTestCase {
XCTAssertTrue(result1.savedRecords[0].isEqualToRecord(changedRecord))

// Second request will fail after modifying the settings.
modifiableSettings.modifyRecordsBehavior = .simulatedFail(nil)
await modifiableSettings.setModifyRecordsBehavior(behavior: .simulatedFail(nil))

do {
let _ = try await api.modifyRecords(saving: [changedRecord]).get()
Expand Down
1 change: 1 addition & 0 deletions Targets/Canopy/Tests/DatabaseAPITests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import XCTest
/// Contains most Canopy database API tests.
///
/// Some tests are in individual test classes (fetch changes).
@available(iOS 16.4, macOS 13.3, *)
final class DatabaseAPITests: XCTestCase {
private func databaseAPI(_ db: CKDatabaseType, settings: CanopySettingsType = CanopySettings()) -> CKDatabaseAPIType {
CKDatabaseAPI(database: db, databaseScope: .private, settingsProvider: { settings }, tokenStore: TestTokenStore())
Expand Down
1 change: 1 addition & 0 deletions Targets/Canopy/Tests/DependencyTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import CloudKit
import Dependencies
import XCTest

@available(iOS 16.4, macOS 13.3, *)
final class DependencyTests: XCTestCase {
struct Fetcher {
@Dependency(\.cloudKit) private var canopy
Expand Down
Loading

0 comments on commit a6b1884

Please sign in to comment.