├── .github └── workflows │ └── CI.yml ├── .gitignore ├── .swiftpm └── xcode │ └── package.xcworkspace │ ├── contents.xcworkspacedata │ └── xcshareddata │ └── IDEWorkspaceChecks.plist ├── LICENSE.md ├── Package.resolved ├── Package.swift ├── README.md ├── Sources └── CloudSyncSession │ ├── CloudKitOperationHandler.swift │ ├── CloudSyncSession.swift │ ├── Extensions │ └── CKErrorExtensions.swift │ ├── Middleware.swift │ ├── Middleware │ ├── AccountStatusMiddleware.swift │ ├── ErrorMiddleware.swift │ ├── LoggerMiddleware.swift │ ├── RetryMiddleware.swift │ ├── SplittingMiddleware.swift │ ├── SubjectMiddleware.swift │ ├── WorkMiddleware.swift │ └── ZoneMiddleware.swift │ ├── OperationHandler.swift │ ├── SyncEvent.swift │ ├── SyncState.swift │ └── SyncWork.swift └── Tests └── CloudSyncSessionTests ├── CloudSyncSessionTests.swift └── SyncStateTests.swift /.github/workflows/CI.yml: -------------------------------------------------------------------------------- 1 | # This workflow will build a Swift project 2 | # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-swift 3 | 4 | name: CloudSyncSession CI 5 | 6 | on: 7 | push: 8 | branches: [ "main" ] 9 | pull_request: 10 | branches: [ "main" ] 11 | 12 | jobs: 13 | build: 14 | runs-on: macos-latest 15 | steps: 16 | - uses: actions/checkout@v4 17 | - uses: swift-actions/setup-swift@v1 18 | - name: Build 19 | run: swift build -v 20 | - name: Run tests 21 | run: swift test -v 22 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /Packages 4 | /*.xcodeproj 5 | xcuserdata/ 6 | -------------------------------------------------------------------------------- /.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /.swiftpm/xcode/package.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Ryan Ashcraft 4 | 5 | Portions of this library were taken and modified from 6 | [Cirrus](https://github.com/jayhickey/Cirrus) under the MIT license: 7 | 8 | Copyright (c) 2020 Jay Hickey 9 | 10 | Permission is hereby granted, free of charge, to any person obtaining a copy 11 | of this software and associated documentation files (the "Software"), to deal 12 | in the Software without restriction, including without limitation the rights 13 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 14 | copies of the Software, and to permit persons to whom the Software is 15 | furnished to do so, subject to the following conditions: 16 | 17 | The above copyright notice and this permission notice shall be included in all 18 | copies or substantial portions of the Software. 19 | 20 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 21 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 22 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 23 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 24 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 25 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 26 | SOFTWARE. 27 | -------------------------------------------------------------------------------- /Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "pins" : [ 3 | { 4 | "identity" : "swift-collections", 5 | "kind" : "remoteSourceControl", 6 | "location" : "https://github.com/apple/swift-collections.git", 7 | "state" : { 8 | "revision" : "d029d9d39c87bed85b1c50adee7c41795261a192", 9 | "version" : "1.0.6" 10 | } 11 | }, 12 | { 13 | "identity" : "swift-pid", 14 | "kind" : "remoteSourceControl", 15 | "location" : "https://github.com/ryanashcraft/swift-pid.git", 16 | "state" : { 17 | "revision" : "3f524ecc12bd519f27cbbc73b986be4d60351e91", 18 | "version" : "0.0.3" 19 | } 20 | } 21 | ], 22 | "version" : 2 23 | } 24 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version: 5.9 2 | // The swift-tools-version declares the minimum version of Swift required to build this package. 3 | 4 | import PackageDescription 5 | 6 | let package = Package( 7 | name: "CloudSyncSession", 8 | platforms: [ 9 | .iOS(.v15), 10 | .watchOS(.v8), 11 | .macOS(.v12), 12 | ], 13 | products: [ 14 | .library( 15 | name: "CloudSyncSession", 16 | targets: ["CloudSyncSession"] 17 | ), 18 | ], 19 | dependencies: [ 20 | .package( 21 | url: "https://github.com/ryanashcraft/swift-pid.git", 22 | .upToNextMajor(from: "0.0.1") 23 | ), 24 | ], 25 | targets: [ 26 | .target( 27 | name: "CloudSyncSession", 28 | dependencies: [ 29 | .product(name: "PID", package: "swift-pid"), 30 | ] 31 | ), 32 | .testTarget( 33 | name: "CloudSyncSessionTests", 34 | dependencies: ["CloudSyncSession"] 35 | ), 36 | ] 37 | ) 38 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # CloudSyncSession 2 | 3 | ![main branch CI status](https://github.com/ryanashcraft/CloudSyncSession/actions/workflows/CI.yml/badge.svg) 4 | 5 | CloudSyncSession is a Swift library that builds on top of the CloudKit framework to make it easier to write sync-enabled, offline-capable apps. 6 | 7 | Similar to [NSPersistentCloudKitContainer](https://developer.apple.com/documentation/coredata/nspersistentcloudkitcontainer), CloudSyncSession works for apps that need to sync all records in a zone between iCloud and the client. Unlike NSPersistentCloudKitContainer, which offers local persistence, CloudSyncSession does not persist state to disk in any way. As such, it can be used in conjunction with any local persistence solution (e.g. GRDB, Core Data, user defaults and file storage, etc.). 8 | 9 | ## Design Principles 10 | 11 | 1. Persistence-free. Data is not persisted to disk. 12 | 2. Testable. Code is structured in a way to maximize how much behavior can be tested. 13 | 3. Modular. To the extent that it makes sense, different behaviors are handled separately by different components. 14 | 4. Event-based. State is predictable, as it is updated based on the series of events that have previously occurred. 15 | 5. Resilient. Recoverable errors are gracefully handled using retries and backoffs. Non-recoverable errors halt further execution until the app signals that work should be resumed. 16 | 6. Inspectable. The current state of the session can be evaluated for troubleshooting and diagnostics. 17 | 7. Focused. This project aims to solve a particular use case and do it well. 18 | 19 | ## Usage 20 | 21 | 1. Initialize the session. 22 | 23 | ```swift 24 | static let storeIdentifier: String 25 | static let zoneID: CKRecordZone.ID 26 | static let subscriptionID: String 27 | static let log: OSLog 28 | 29 | static func makeSharedSession() -> CloudSyncSession { 30 | let container = CKContainer(identifier: Self.storeIdentifier) 31 | let database = container.privateCloudDatabase 32 | 33 | let session = CloudSyncSession( 34 | operationHandler: CloudKitOperationHandler( 35 | database: database, 36 | zoneID: Self.zoneID, 37 | subscriptionID: Self.subscriptionID, 38 | log: Self.log 39 | ), 40 | zoneID: Self.zoneID, 41 | resolveConflict: resolveConflict, 42 | resolveExpiredChangeToken: resolveExpiredChangeToken 43 | ) 44 | 45 | session.appendMiddleware( 46 | AccountStatusMiddleware( 47 | session: session, 48 | ckContainer: container 49 | ) 50 | ) 51 | 52 | return session 53 | } 54 | 55 | static func resolveConflict(clientCkRecord: CKRecord, serverCkRecord: CKRecord) -> CKRecord? { 56 | // Implement your own conflict resolution logic 57 | 58 | if let clientDate = clientCkRecord.cloudKitLastModifiedDate, 59 | let serverDate = serverCkRecord.cloudKitLastModifiedDate 60 | { 61 | return clientDate > serverDate ? clientCkRecord : serverCkRecord 62 | } 63 | 64 | return clientCkRecord 65 | } 66 | 67 | static func resolveExpiredChangeToken() -> CKServerChangeToken? { 68 | // Update persisted store to reset the change token to nil 69 | 70 | return nil 71 | } 72 | ``` 73 | 74 | 2. Listen for changes. 75 | 76 | ```swift 77 | // Listen for fetch work that has been completed 78 | cloudSyncSession.fetchWorkCompletedSubject 79 | .map { _, response in 80 | (response.changeToken, response.changedRecords, response.deletedRecordIDs) 81 | } 82 | .sink { changeToken, ckRecords, recordIDsToDelete in 83 | // Process new and deleted records 84 | 85 | if let changeToken = changeToken { 86 | var newChangeTokenData: Data? = try NSKeyedArchiver.archivedData( 87 | withRootObject: changeToken as Any, 88 | requiringSecureCoding: true 89 | ) 90 | 91 | // Save change token data to disk 92 | } 93 | } 94 | ``` 95 | 96 | ```swift 97 | // Listen for modification work that has been completed 98 | cloudSyncSession.modifyWorkCompletedSubject 99 | .map { _, response in 100 | (response.changedRecords, response.deletedRecordIDs, userInfo) 101 | } 102 | .sink { ckRecords, recordIDsToDelete, userInfo in 103 | // Process new and deleted records 104 | } 105 | ``` 106 | 107 | 3. Start the session. 108 | 109 | ```swift 110 | cloudSyncSession.start() 111 | ``` 112 | 113 | 4. Initiate a fetch. 114 | 115 | ```swift 116 | // Obtain the change token from disk 117 | let changeToken: CKServerChangeToken? 118 | 119 | // Queue a fetch operation 120 | cloudSyncSession.fetch(FetchOperation(changeToken: changeToken)) 121 | ``` 122 | 123 | 5. Initiate a modification. 124 | 125 | ```swift 126 | let records: [CKRecord] 127 | let recordIDsToDelete = [CKRecord.ID] 128 | let checkpointID = UUID() 129 | let operation = ModifyOperation( 130 | records: records, 131 | recordIDsToDelete: recordIDsToDelete, 132 | checkpointID: checkpointID, 133 | userInfo: nil 134 | ) 135 | 136 | cloudSyncSession.modify(operation) 137 | ``` 138 | 139 | 6. Handle CloudKit push notifications for live updates. 140 | 141 | ```swift 142 | // AppDelegate.swift 143 | func application(_: UIApplication, didReceiveRemoteNotification userInfo: [AnyHashable: Any], fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void) { 144 | if let notification = CKNotification(fromRemoteNotificationDictionary: userInfo), 145 | notification.subscriptionID == Self.subscriptionID { 146 | // Initiate a fetch request with the most recent change token 147 | // Wait some time to see if that fetch operation finishes in time 148 | // Call completionHandler with the appropriate value 149 | 150 | return 151 | } 152 | 153 | // Handle other kinds of notifications 154 | } 155 | ``` 156 | 157 | 7. Observe changes and errors for user-facing diagnostics. 158 | 159 | ```swift 160 | cloudSyncSession.haltedSubject 161 | .sink { error in 162 | // Update UI based on most recent error 163 | } 164 | 165 | cloudSyncSession.accountStatusSubject 166 | .sink { accountStatus in 167 | // Update UI with new account status 168 | } 169 | 170 | cloudSyncSession.$state 171 | .sink { state in 172 | // Update UI with new sync state 173 | } 174 | 175 | cloudSyncSession.eventsPublisher 176 | .sink { [weak self] event in 177 | // Update UI with most recent event 178 | } 179 | ``` 180 | 181 | ## Installation 182 | 183 | To use CloudSyncSession with Swift Package Manager, add a dependency to https://github.com/ryanashcraft/CloudSyncSession 184 | 185 | ## Under the Hood 186 | 187 | CloudSyncSession is event-based. Events describe what has occurred, or what has been requested. The current state of the session is a result of the previous events up to that point. 188 | 189 | CloudSyncSession uses the concept of event middleware in an effort to decouple and modularize independent behaviors. Middleware are ordered; they can transform events before they are passed along to the following middleware. They can also trigger side effects. 190 | 191 | By default, the following middleware are initialized: 192 | 193 | - `SplittingMiddleware`: Handles splitting up large work. 194 | - `ErrorMiddleware`: Transforms CloudKit errors into a new event (e.g. `retry`, `halt`, `resolveConflict`, etc.). 195 | - `RetryMiddleware`: Handles how to handle work that was marked to be retried. 196 | - `WorkMiddleware`: Handles translating events into calls on the operation handler, and dispatches new events based on the result. 197 | - `SubjectMiddleware`: Sends values to the various Combine subjects on the CloudSyncSession instance. 198 | - `LoggerMiddleware`: Logs all events with os_log. 199 | - `ZoneMiddleware`: On session start, dispatches events to queue work to create the zone and associated subscription. 200 | 201 | In addition, the `AccountStatusMiddleware` is required, but not initialized by default. This middleware checks the account status on session start. 202 | 203 | CloudSyncSession state transitions between different "operation modes" to determine which sort of work is to be handled: none (represented by `nil`), `createZone`, `createSubscription`, `modify`, and `fetch`. Work is queued up into separate queues, one for each operation mode. The state only operates in one operation mode at a time. Operation modes are made eligible or ineligible by certain events that occur (e.g. account status changes and errors). Operation modes are ordered, so work to create a zone always precedes work modification work, which likewise precedes fetch work. 204 | 205 | ## Tests 206 | 207 | In an effort to make as much of the logic and behavior testable, most CloudKit-specific code is decoupled and/or mockable via protocols. 208 | 209 | `OperationHandler` is a protocol that abstracts the handling all of the various operations: `FetchOperation`, `ModifyOperation`, `CreateZoneOperation`, and `CreateSubscriptionOperation`. The main implementation of this protocol, `CloudKitOperationHandler`, handles these operations using the standard CloudKit APIs. 210 | 211 | There are two test suites: `CloudSyncSessionTests` and `SyncStateTests`. `CloudSyncSessionTests` uses custom `OperationHandler` instances to simulate different scenarios and test end-to-end behaviors, including retries, splitting up work, handling success and failure, etc. 212 | 213 | `SyncStateTests` asserts that the state is correctly updated based on certain events. 214 | 215 | ## Limitations 216 | 217 | CloudSyncSession is not intended to be a drop-in solution to integrating CloudKit into your app. You need to correctly persist metadata and records to disk. In addition, you must use the appropriate hooks to convert your data models to and from CKRecords. 218 | 219 | These CloudKit features are not supported: 220 | 221 | - Shared records 222 | - Assets 223 | - Public databases 224 | - References/relationships\* 225 | 226 | Perhaps these features work in some capacity, but they are untested. If you are interested in these features and want to verify that they work, please do so and report back your learnings by filing an issue on GitHub. 227 | 228 | \* I intentionally opted to not use references as it come with limited benefits and much more overhead for the sort of use case this library was designed for (mirroring data between iCloud and multiple clients). 229 | 230 | ## Influences 231 | 232 | This library was heavily influenced by [Cirrus](https://github.com/jayhickey/Cirrus). Portions of this library were taken and modified by Cirrus. 233 | 234 | I developed CloudSyncSession because there were a few things I was looking for in a CloudKit syncing library that Cirrus didn't offer. 235 | 236 | - Cirrus requires instantiating separate sync engine instances – one for each record type. Each of these sync engines operate independently. I was concerned that for my use case, problems could arise if a sync engine for one record type would get out of sync with another. Particularly with entities that have implicit relationships. It seemed simpler, for my use case, to have a single stateful instance to orchestrate all syncing operations. 237 | - Cirrus is clever in that it saves you the grief of converting to and from CKRecords, and instead allows you to interface directly with your existing model structs. However, in my case, I wanted the flexibility afforded from being able to use raw CKRecords. 238 | - Many CloudKit libraries do not have much, if any, automated testing around the lifecycle and state management of the sync engine. CloudKit itself is extremely difficult to test in an automated fashion, because anything meaningful requires a device with a valid CloudKit account. I wanted some level of decoupling of CloudKit with the state management aspect of the library, so I could at least test most of the logic that didn't directly interface with CloudKit. 239 | 240 | These are all tradeoffs. Cirrus is a great library and likely a better fit for many apps. 241 | 242 | The event-based architecture was heavily influenced by [Redux](https://github.com/reduxjs/redux). 243 | 244 | ## Contributions 245 | 246 | I hope that you find this library helpful, either as a reference or as a solution for your app. In an effort to keep maintenance low and minimize risk, I am not interested in large refactors or expanding the greatly scope of the capabilities currently offered. Please feel free to fork (see [LICENSE](./LICENSE.md)). 247 | 248 | If you'd like to submit a bug fix or enhancement, please submit a pull request. Please include some context, your motivation, add tests if appropriate. 249 | 250 | ## License 251 | 252 | See [LICENSE](/LICENSE.md). 253 | 254 | Portions of this library were taken and modified from [Cirrus](https://github.com/jayhickey/Cirrus), an MIT-licensed library, Copyright (c) 2020 Jay Hickey. The code has been modified for use in this project. 255 | -------------------------------------------------------------------------------- /Sources/CloudSyncSession/CloudKitOperationHandler.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright (c) 2020 Jay Hickey 3 | // Copyright (c) 2020-present Ryan Ashcraft 4 | // 5 | // Permission is hereby granted, free of charge, to any person obtaining a copy 6 | // of this software and associated documentation files (the "Software"), to deal 7 | // in the Software without restriction, including without limitation the rights 8 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | // copies of the Software, and to permit persons to whom the Software is 10 | // furnished to do so, subject to the following conditions: 11 | // 12 | // The above copyright notice and this permission notice shall be included in all 13 | // copies or substantial portions of the Software. 14 | // 15 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | // SOFTWARE. 22 | // 23 | 24 | import CloudKit 25 | import os.log 26 | import PID 27 | 28 | /// An object that handles all of the key operations (fetch, modify, create zone, and create subscription) using the standard CloudKit APIs. 29 | public class CloudKitOperationHandler: OperationHandler { 30 | static let minThrottleDuration: TimeInterval = 1 31 | static let maxThrottleDuration: TimeInterval = 60 * 10 32 | 33 | let database: CKDatabase 34 | let zoneID: CKRecordZone.ID 35 | let subscriptionID: String 36 | let log: OSLog 37 | let savePolicy: CKModifyRecordsOperation.RecordSavePolicy = .ifServerRecordUnchanged 38 | let qos: QualityOfService = .userInitiated 39 | var rateLimitController = RateLimitPIDController( 40 | kp: 2, 41 | ki: 0.05, 42 | kd: 0.02, 43 | errorWindowSize: 20, 44 | targetSuccessRate: 0.96, 45 | initialRateLimit: 2, 46 | outcomeWindowSize: 1 47 | ) 48 | 49 | private let operationQueue: OperationQueue = { 50 | let queue = OperationQueue() 51 | queue.maxConcurrentOperationCount = 1 52 | 53 | return queue 54 | }() 55 | 56 | var throttleDuration: TimeInterval { 57 | didSet { 58 | nextOperationDeadline = DispatchTime.now() + throttleDuration 59 | 60 | if throttleDuration > oldValue { 61 | os_log( 62 | "Increasing throttle duration from %{public}.1f seconds to %{public}.1f seconds", 63 | log: log, 64 | type: .default, 65 | oldValue, 66 | throttleDuration 67 | ) 68 | } else if throttleDuration < oldValue { 69 | os_log( 70 | "Decreasing throttle duration from %{public}.1f seconds to %{public}.1f seconds", 71 | log: log, 72 | type: .default, 73 | oldValue, 74 | throttleDuration 75 | ) 76 | } 77 | } 78 | } 79 | 80 | var nextOperationDeadline: DispatchTime? 81 | 82 | public init(database: CKDatabase, zoneID: CKRecordZone.ID, subscriptionID: String, log: OSLog) { 83 | self.database = database 84 | self.zoneID = zoneID 85 | self.subscriptionID = subscriptionID 86 | self.log = log 87 | throttleDuration = rateLimitController.rateLimit 88 | } 89 | 90 | private func queueOperation(_ operation: Operation) { 91 | let deadline: DispatchTime = nextOperationDeadline ?? DispatchTime.now() 92 | 93 | DispatchQueue.main.asyncAfter(deadline: deadline) { 94 | self.operationQueue.addOperation(operation) 95 | } 96 | } 97 | 98 | private func onOperationSuccess() { 99 | rateLimitController.record(outcome: .success) 100 | throttleDuration = min(Self.maxThrottleDuration, max(Self.minThrottleDuration, rateLimitController.rateLimit)) 101 | } 102 | 103 | private func onOperationError(_ error: Error) { 104 | if let ckError = error as? CKError { 105 | rateLimitController.record(outcome: ckError.indicatesShouldBackoff ? .failure : .success) 106 | 107 | if let suggestedBackoffSeconds = ckError.suggestedBackoffSeconds { 108 | os_log( 109 | "CloudKit error suggests retrying after %{public}.1f seconds", 110 | log: log, 111 | type: .default, 112 | suggestedBackoffSeconds 113 | ) 114 | 115 | // Respect the amount suggested for the next operation 116 | throttleDuration = suggestedBackoffSeconds 117 | } else { 118 | throttleDuration = min(Self.maxThrottleDuration, max(Self.minThrottleDuration, rateLimitController.rateLimit)) 119 | } 120 | } 121 | } 122 | 123 | public func handle( 124 | modifyOperation: ModifyOperation, 125 | completion: @escaping (Result) -> Void 126 | ) { 127 | let recordsToSave = modifyOperation.records 128 | let recordIDsToDelete = modifyOperation.recordIDsToDelete 129 | 130 | guard !recordIDsToDelete.isEmpty || !recordsToSave.isEmpty else { 131 | completion(.success(ModifyOperation.Response(savedRecords: [], deletedRecordIDs: []))) 132 | 133 | return 134 | } 135 | 136 | let operation = CKModifyRecordsOperation( 137 | recordsToSave: recordsToSave, 138 | recordIDsToDelete: recordIDsToDelete 139 | ) 140 | 141 | operation.modifyRecordsCompletionBlock = { serverRecords, deletedRecordIDs, error in 142 | if let error = error { 143 | self.onOperationError(error) 144 | 145 | completion(.failure(error)) 146 | } else { 147 | self.onOperationSuccess() 148 | 149 | completion(.success(ModifyOperation.Response(savedRecords: serverRecords ?? [], deletedRecordIDs: deletedRecordIDs ?? []))) 150 | } 151 | } 152 | 153 | operation.savePolicy = savePolicy 154 | operation.qualityOfService = qos 155 | operation.database = database 156 | 157 | queueOperation(operation) 158 | } 159 | 160 | public func handle(fetchOperation: FetchOperation, completion: @escaping (Result) -> Void) { 161 | var hasMore = false 162 | var token: CKServerChangeToken? = fetchOperation.changeToken 163 | var changedRecords: [CKRecord] = [] 164 | var deletedRecordIDs: [CKRecord.ID] = [] 165 | 166 | let operation = CKFetchRecordZoneChangesOperation() 167 | 168 | let config = CKFetchRecordZoneChangesOperation.ZoneConfiguration( 169 | previousServerChangeToken: token, 170 | resultsLimit: nil, 171 | desiredKeys: nil 172 | ) 173 | 174 | operation.configurationsByRecordZoneID = [zoneID: config] 175 | 176 | operation.recordZoneIDs = [zoneID] 177 | operation.fetchAllChanges = true 178 | 179 | operation.recordZoneChangeTokensUpdatedBlock = { [weak self] _, newToken, _ in 180 | guard let self = self else { 181 | return 182 | } 183 | 184 | guard let newToken = newToken else { 185 | return 186 | } 187 | 188 | os_log("Received new change token", log: self.log, type: .debug) 189 | 190 | token = newToken 191 | } 192 | 193 | operation.recordChangedBlock = { record in 194 | changedRecords.append(record) 195 | } 196 | 197 | operation.recordWithIDWasDeletedBlock = { recordID, _ in 198 | deletedRecordIDs.append(recordID) 199 | } 200 | 201 | operation.recordZoneFetchCompletionBlock = { [weak self] _, newToken, _, newHasMore, _ in 202 | guard let self = self else { 203 | return 204 | } 205 | 206 | hasMore = newHasMore 207 | 208 | if let newToken = newToken { 209 | os_log("Received new change token", log: self.log, type: .debug) 210 | 211 | token = newToken 212 | } else { 213 | os_log("Confusingly received nil token", log: self.log, type: .debug) 214 | 215 | token = nil 216 | } 217 | } 218 | 219 | operation.fetchRecordZoneChangesCompletionBlock = { [weak self] error in 220 | guard let self = self else { 221 | return 222 | } 223 | 224 | if let error = error { 225 | os_log( 226 | "Failed to fetch record zone changes: %{public}@", 227 | log: self.log, 228 | type: .error, 229 | String(describing: error) 230 | ) 231 | 232 | onOperationError(error) 233 | 234 | completion(.failure(error)) 235 | } else { 236 | os_log("Finished fetching record zone changes", log: self.log, type: .info) 237 | 238 | onOperationSuccess() 239 | 240 | completion( 241 | .success( 242 | FetchOperation.Response( 243 | changeToken: token, 244 | changedRecords: changedRecords, 245 | deletedRecordIDs: deletedRecordIDs, 246 | hasMore: hasMore 247 | ) 248 | ) 249 | ) 250 | } 251 | } 252 | 253 | operation.qualityOfService = qos 254 | operation.database = database 255 | 256 | queueOperation(operation) 257 | } 258 | 259 | public func handle(createZoneOperation: CreateZoneOperation, completion: @escaping (Result) -> Void) { 260 | checkCustomZone(zoneID: createZoneOperation.zoneID) { result in 261 | switch result { 262 | case let .failure(error): 263 | if let ckError = error as? CKError { 264 | switch ckError.code { 265 | case .partialFailure, 266 | .zoneNotFound, 267 | .userDeletedZone: 268 | self.createCustomZone(zoneID: self.zoneID) { result in 269 | switch result { 270 | case let .failure(error): 271 | completion(.failure(error)) 272 | case let .success(didCreateZone): 273 | completion(.success(didCreateZone)) 274 | } 275 | } 276 | 277 | return 278 | default: 279 | break 280 | } 281 | } 282 | 283 | completion(.failure(error)) 284 | case let .success(isZoneAlreadyCreated): 285 | if isZoneAlreadyCreated { 286 | completion(.success(true)) 287 | 288 | return 289 | } 290 | 291 | self.createCustomZone(zoneID: self.zoneID) { result in 292 | switch result { 293 | case let .failure(error): 294 | completion(.failure(error)) 295 | case let .success(didCreateZone): 296 | completion(.success(didCreateZone)) 297 | } 298 | } 299 | } 300 | } 301 | } 302 | 303 | public func handle(createSubscriptionOperation _: CreateSubscriptionOperation, completion: @escaping (Result) -> Void) { 304 | checkSubscription(zoneID: zoneID) { result in 305 | switch result { 306 | case let .failure(error): 307 | if let ckError = error as? CKError { 308 | switch ckError.code { 309 | case .partialFailure, 310 | .zoneNotFound, 311 | .userDeletedZone: 312 | self.createSubscription(zoneID: self.zoneID, subscriptionID: self.subscriptionID) { result in 313 | switch result { 314 | case let .failure(error): 315 | completion(.failure(error)) 316 | case let .success(didCreateSubscription): 317 | completion(.success(didCreateSubscription)) 318 | } 319 | } 320 | 321 | return 322 | default: 323 | break 324 | } 325 | } 326 | 327 | completion(.failure(error)) 328 | case let .success(isSubscriptionAlreadyCreated): 329 | if isSubscriptionAlreadyCreated { 330 | completion(.success(true)) 331 | 332 | return 333 | } 334 | 335 | self.createSubscription(zoneID: self.zoneID, subscriptionID: self.subscriptionID) { result in 336 | switch result { 337 | case let .failure(error): 338 | completion(.failure(error)) 339 | case let .success(didCreateZone): 340 | completion(.success(didCreateZone)) 341 | } 342 | } 343 | } 344 | } 345 | } 346 | } 347 | 348 | private extension CloudKitOperationHandler { 349 | func checkCustomZone(zoneID: CKRecordZone.ID, completion: @escaping (Result) -> Void) { 350 | let operation = CKFetchRecordZonesOperation(recordZoneIDs: [zoneID]) 351 | 352 | operation.fetchRecordZonesCompletionBlock = { ids, error in 353 | if let error = error { 354 | os_log( 355 | "Failed to check for custom zone existence: %{public}@", 356 | log: self.log, 357 | type: .error, 358 | String(describing: error) 359 | ) 360 | 361 | completion(.failure(error)) 362 | 363 | return 364 | } else if (ids ?? [:]).isEmpty { 365 | os_log( 366 | "Custom zone reported as existing, but it doesn't exist", 367 | log: self.log, 368 | type: .error 369 | ) 370 | 371 | completion(.success(false)) 372 | 373 | return 374 | } 375 | 376 | os_log( 377 | "Custom zone exists", 378 | log: self.log, 379 | type: .error 380 | ) 381 | 382 | completion(.success(true)) 383 | } 384 | 385 | operation.qualityOfService = .userInitiated 386 | operation.database = database 387 | 388 | queueOperation(operation) 389 | } 390 | 391 | func createCustomZone(zoneID: CKRecordZone.ID, completion: @escaping (Result) -> Void) { 392 | let zone = CKRecordZone(zoneID: zoneID) 393 | let operation = CKModifyRecordZonesOperation( 394 | recordZonesToSave: [zone], 395 | recordZoneIDsToDelete: nil 396 | ) 397 | 398 | operation.modifyRecordZonesCompletionBlock = { _, _, error in 399 | if let error = error { 400 | os_log( 401 | "Failed to create custom zone: %{public}@", 402 | log: self.log, 403 | type: .error, 404 | String(describing: error) 405 | ) 406 | 407 | completion(.failure(error)) 408 | 409 | return 410 | } 411 | 412 | os_log("Created custom zone", log: self.log, type: .debug) 413 | 414 | completion(.success(true)) 415 | } 416 | 417 | operation.qualityOfService = .userInitiated 418 | operation.database = database 419 | 420 | queueOperation(operation) 421 | } 422 | 423 | func checkSubscription(zoneID _: CKRecordZone.ID, completion: @escaping (Result) -> Void) { 424 | let operation = CKFetchSubscriptionsOperation(subscriptionIDs: [subscriptionID]) 425 | 426 | operation.fetchSubscriptionCompletionBlock = { ids, error in 427 | if let error = error { 428 | os_log( 429 | "Failed to check for subscription existence: %{public}@", 430 | log: self.log, 431 | type: .error, 432 | String(describing: error) 433 | ) 434 | 435 | completion(.failure(error)) 436 | 437 | return 438 | } else if (ids ?? [:]).isEmpty { 439 | os_log( 440 | "Subscription reported as existing, but it doesn't exist", 441 | log: self.log, 442 | type: .error 443 | ) 444 | 445 | completion(.success(false)) 446 | 447 | return 448 | } 449 | 450 | os_log("Subscription exists", log: self.log, type: .debug) 451 | 452 | completion(.success(true)) 453 | } 454 | 455 | operation.qualityOfService = .userInitiated 456 | operation.database = database 457 | 458 | queueOperation(operation) 459 | } 460 | 461 | func createSubscription(zoneID: CKRecordZone.ID, subscriptionID: String, completion: @escaping (Result) -> Void) { 462 | let subscription = CKRecordZoneSubscription( 463 | zoneID: zoneID, 464 | subscriptionID: subscriptionID 465 | ) 466 | 467 | let notificationInfo = CKSubscription.NotificationInfo() 468 | notificationInfo.shouldSendContentAvailable = true 469 | 470 | subscription.notificationInfo = notificationInfo 471 | 472 | let operation = CKModifySubscriptionsOperation( 473 | subscriptionsToSave: [subscription], 474 | subscriptionIDsToDelete: nil 475 | ) 476 | 477 | operation.modifySubscriptionsCompletionBlock = { _, _, error in 478 | if let error = error { 479 | os_log( 480 | "Failed to create subscription: %{public}@", 481 | log: self.log, 482 | type: .error, 483 | String(describing: error) 484 | ) 485 | 486 | completion(.failure(error)) 487 | 488 | return 489 | } 490 | 491 | os_log("Created subscription", log: self.log, type: .debug) 492 | 493 | completion(.success(true)) 494 | } 495 | 496 | operation.qualityOfService = .userInitiated 497 | operation.database = database 498 | 499 | queueOperation(operation) 500 | } 501 | } 502 | -------------------------------------------------------------------------------- /Sources/CloudSyncSession/CloudSyncSession.swift: -------------------------------------------------------------------------------- 1 | import CloudKit 2 | import Combine 3 | import os.log 4 | 5 | public typealias ConflictResolver = (CKRecord, CKRecord) -> CKRecord? 6 | public typealias ChangeTokenExpiredResolver = () -> CKServerChangeToken? 7 | 8 | public struct StopError: Error {} 9 | 10 | /// An object that manages a long-lived series of CloudKit syncing operations. 11 | public class CloudSyncSession { 12 | /// Represents the state of the session. 13 | @Published public var state = SyncState() 14 | 15 | /// Handles fetch, modify, create zone, and create subscription operations. 16 | let operationHandler: OperationHandler 17 | 18 | /// The CloudKit zone ID. 19 | let zoneID: CKRecordZone.ID 20 | 21 | /// The function handler that will be called to resolve record conflicts. 22 | public let resolveConflict: ConflictResolver? 23 | 24 | /// The function handler that will be called when the change token should be expired. 25 | public let resolveExpiredChangeToken: ChangeTokenExpiredResolver? 26 | 27 | /// The ordered chain of middleware that will transform events and/or trigger side effects. 28 | private var middlewares = [AnyMiddleware]() 29 | 30 | /// A Combine subject that publishes the most recent event. 31 | public let eventsPublisher = CurrentValueSubject(nil) 32 | 33 | /// A Combine subject that publishes fetch work that has completed. 34 | public let fetchWorkCompletedSubject = PassthroughSubject<(FetchOperation, FetchOperation.Response), Never>() 35 | 36 | /// A Combine subject that publishes modify work that has completed. 37 | public let modifyWorkCompletedSubject = PassthroughSubject<(ModifyOperation, ModifyOperation.Response), Never>() 38 | 39 | /// A Combine subject that signals when the session has halted due to an error. 40 | public let haltedSubject = CurrentValueSubject(nil) 41 | 42 | /// A Combine subject that publishes the latest iCloud account status. 43 | public let accountStatusSubject = CurrentValueSubject(nil) 44 | 45 | private var dispatchQueue = DispatchQueue(label: "CloudSyncSession.Dispatch", qos: .userInitiated) 46 | 47 | /** 48 | Creates a session. 49 | 50 | - Parameter operationHandler: The object that handles fetch, modify, create zone, and create subscription operations. 51 | - Parameter zoneID: The CloudKit zone ID. 52 | - Parameter resolveConflict: The function handler that will be called to resolve record conflicts. 53 | - Parameter resolveExpiredChangeToken: The function handler that will be called when the change token should be expired. 54 | */ 55 | public init( 56 | operationHandler: OperationHandler, 57 | zoneID: CKRecordZone.ID, 58 | resolveConflict: @escaping ConflictResolver, 59 | resolveExpiredChangeToken: @escaping ChangeTokenExpiredResolver 60 | ) { 61 | self.operationHandler = operationHandler 62 | self.zoneID = zoneID 63 | self.resolveConflict = resolveConflict 64 | self.resolveExpiredChangeToken = resolveExpiredChangeToken 65 | 66 | middlewares = [ 67 | SplittingMiddleware(session: self).eraseToAnyMiddleware(), 68 | ErrorMiddleware(session: self).eraseToAnyMiddleware(), 69 | RetryMiddleware(session: self).eraseToAnyMiddleware(), 70 | WorkMiddleware(session: self).eraseToAnyMiddleware(), 71 | SubjectMiddleware(session: self).eraseToAnyMiddleware(), 72 | LoggerMiddleware(session: self).eraseToAnyMiddleware(), 73 | ZoneMiddleware(session: self).eraseToAnyMiddleware(), 74 | ] 75 | } 76 | 77 | /// Add an additional mdidleware at the end of the chain. 78 | public func appendMiddleware(_ middleware: M) { 79 | middlewares.append(middleware.eraseToAnyMiddleware()) 80 | } 81 | 82 | /// Start the session. 83 | public func start() { 84 | dispatch(event: .start) 85 | } 86 | 87 | /// Stop/halt the session. 88 | public func stop() { 89 | dispatch(event: .halt(StopError())) 90 | } 91 | 92 | /// Reset the session state. 93 | public func reset() { 94 | state = SyncState() 95 | } 96 | 97 | /// Queue a fetch operation. 98 | public func fetch(_ operation: FetchOperation) { 99 | guard state.fetchQueue.allSatisfy({ $0.changeToken != operation.changeToken }) else { 100 | return 101 | } 102 | 103 | dispatch(event: .doWork(.fetch(operation))) 104 | } 105 | 106 | /// Queue a modify operation. 107 | public func modify(_ operation: ModifyOperation) { 108 | dispatch(event: .doWork(.modify(operation))) 109 | } 110 | 111 | func dispatch(event: SyncEvent) { 112 | dispatchQueue.async { 113 | func next(event: SyncEvent, middlewaresToRun: [AnyMiddleware]) -> SyncEvent { 114 | self.eventsPublisher.send(event) 115 | 116 | if let middleware = middlewaresToRun.last { 117 | return middleware.run( 118 | next: { event in 119 | next(event: event, middlewaresToRun: middlewaresToRun.dropLast()) 120 | }, 121 | event: event 122 | ) 123 | } else { 124 | self.state = self.state.reduce(event: event) 125 | 126 | return event 127 | } 128 | } 129 | 130 | _ = next(event: event, middlewaresToRun: Array(self.middlewares.reversed())) 131 | } 132 | } 133 | } 134 | -------------------------------------------------------------------------------- /Sources/CloudSyncSession/Extensions/CKErrorExtensions.swift: -------------------------------------------------------------------------------- 1 | import CloudKit 2 | 3 | extension CKError { 4 | var suggestedBackoffSeconds: TimeInterval? { 5 | if let retryAfterSeconds { 6 | return retryAfterSeconds 7 | } 8 | 9 | return partialErrorsByItemID? 10 | .values 11 | .compactMap { ($0 as? CKError)?.retryAfterSeconds } 12 | .max() 13 | } 14 | 15 | var indicatesShouldBackoff: Bool { 16 | if retryAfterSeconds != nil { 17 | return true 18 | } 19 | 20 | switch self.code { 21 | case .serviceUnavailable, 22 | .zoneBusy, 23 | .requestRateLimited: 24 | return true 25 | case .partialFailure: 26 | guard let partialErrorsByRecordID = self.partialErrorsByItemID as? [CKRecord.ID: Error] else { 27 | return false 28 | } 29 | 30 | let partialErrors = partialErrorsByRecordID.compactMap { $0.value as? CKError } 31 | let allErrorsAreRetryable = partialErrors.allSatisfy(\.indicatesShouldBackoff) 32 | 33 | return allErrorsAreRetryable 34 | default: 35 | return false 36 | } 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /Sources/CloudSyncSession/Middleware.swift: -------------------------------------------------------------------------------- 1 | public struct AnyMiddleware: Middleware { 2 | public init(value: M) { 3 | session = value.session 4 | run = value.run 5 | } 6 | 7 | public var session: CloudSyncSession 8 | var run: (_ next: (SyncEvent) -> SyncEvent, _ event: SyncEvent) -> SyncEvent 9 | 10 | public func run(next: (SyncEvent) -> SyncEvent, event: SyncEvent) -> SyncEvent { 11 | run(next, event) 12 | } 13 | } 14 | 15 | public protocol Middleware { 16 | var session: CloudSyncSession { get } 17 | 18 | func eraseToAnyMiddleware() -> AnyMiddleware 19 | func run(next: (SyncEvent) -> SyncEvent, event: SyncEvent) -> SyncEvent 20 | } 21 | 22 | public extension Middleware { 23 | func eraseToAnyMiddleware() -> AnyMiddleware { 24 | return AnyMiddleware(value: self) 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /Sources/CloudSyncSession/Middleware/AccountStatusMiddleware.swift: -------------------------------------------------------------------------------- 1 | import CloudKit 2 | import os.log 3 | 4 | /// Middleware that looks up the account status when the session starts and dispatches `accountStatusChanged` events. 5 | public struct AccountStatusMiddleware: Middleware { 6 | public var session: CloudSyncSession 7 | let ckContainer: CKContainer 8 | 9 | private let log = OSLog( 10 | subsystem: "com.ryanashcraft.CloudSyncSession", 11 | category: "Account Status Middleware" 12 | ) 13 | 14 | /** 15 | Creates an account status middleware struct, which should be appended to the chain of session middlewares. 16 | 17 | - Parameter session: The cloud sync session. 18 | - Parameter ckContainer: The CloudKit container. 19 | */ 20 | public init(session: CloudSyncSession, ckContainer: CKContainer) { 21 | self.session = session 22 | self.ckContainer = ckContainer 23 | } 24 | 25 | public func run(next: (SyncEvent) -> SyncEvent, event: SyncEvent) -> SyncEvent { 26 | switch event { 27 | case .start: 28 | if session.state.hasGoodAccountStatus == nil { 29 | ckContainer.accountStatus { status, error in 30 | if let error = error { 31 | os_log("Failed to fetch account status: %{public}@", log: self.log, type: .error, String(describing: error)) 32 | } 33 | 34 | self.session.dispatch(event: .accountStatusChanged(status)) 35 | } 36 | } 37 | default: 38 | break 39 | } 40 | 41 | return next(event) 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /Sources/CloudSyncSession/Middleware/ErrorMiddleware.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright (c) 2020 Jay Hickey 3 | // Copyright (c) 2020-present Ryan Ashcraft 4 | // 5 | // Permission is hereby granted, free of charge, to any person obtaining a copy 6 | // of this software and associated documentation files (the "Software"), to deal 7 | // in the Software without restriction, including without limitation the rights 8 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | // copies of the Software, and to permit persons to whom the Software is 10 | // furnished to do so, subject to the following conditions: 11 | // 12 | // The above copyright notice and this permission notice shall be included in all 13 | // copies or substantial portions of the Software. 14 | // 15 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | // SOFTWARE. 22 | // 23 | 24 | import CloudKit 25 | import Foundation 26 | import os.log 27 | 28 | struct ErrorMiddleware: Middleware { 29 | var session: CloudSyncSession 30 | 31 | private let log = OSLog( 32 | subsystem: "com.ryanashcraft.CloudSyncSession", 33 | category: "Error Middleware" 34 | ) 35 | 36 | func run(next: (SyncEvent) -> SyncEvent, event: SyncEvent) -> SyncEvent { 37 | switch event { 38 | case let .workFailure(work, error): 39 | if let event = mapErrorToEvent(error: error, work: work, zoneID: session.zoneID) { 40 | return next(event) 41 | } 42 | 43 | return next(event) 44 | default: 45 | return next(event) 46 | } 47 | } 48 | 49 | func mapErrorToEvent(error: Error, work: SyncWork, zoneID: CKRecordZone.ID) -> SyncEvent? { 50 | if let ckError = error as? CKError { 51 | os_log( 52 | "Handling CloudKit error (code %{public}d): %{public}@", 53 | log: log, 54 | type: .error, 55 | ckError.errorCode, 56 | ckError.localizedDescription 57 | ) 58 | 59 | switch ckError.code { 60 | case .notAuthenticated, 61 | .managedAccountRestricted, 62 | .quotaExceeded, 63 | .badDatabase, 64 | .incompatibleVersion, 65 | .permissionFailure, 66 | .missingEntitlement, 67 | .badContainer, 68 | .constraintViolation, 69 | .referenceViolation, 70 | .invalidArguments, 71 | .serverRejectedRequest, 72 | .resultsTruncated, 73 | .batchRequestFailed, 74 | .internalError: 75 | return .halt(error) 76 | case .networkUnavailable, 77 | .networkFailure, 78 | .serviceUnavailable, 79 | .zoneBusy, 80 | .requestRateLimited, 81 | .serverResponseLost: 82 | var suggestedInterval: TimeInterval? 83 | 84 | if let retryAfter = ckError.userInfo[CKErrorRetryAfterKey] as? NSNumber { 85 | suggestedInterval = TimeInterval(retryAfter.doubleValue) 86 | } 87 | 88 | return .retry(work, error, suggestedInterval) 89 | case .changeTokenExpired: 90 | var suggestedInterval: TimeInterval? 91 | 92 | if let retryAfter = ckError.userInfo[CKErrorRetryAfterKey] as? NSNumber { 93 | suggestedInterval = TimeInterval(retryAfter.doubleValue) 94 | } 95 | 96 | switch work { 97 | case var .fetch(modifiedOperation): 98 | modifiedOperation.changeToken = resolveExpiredChangeToken() 99 | 100 | return .retry(.fetch(modifiedOperation), error, suggestedInterval) 101 | default: 102 | return .halt(error) 103 | } 104 | case .partialFailure: 105 | switch work { 106 | case .fetch: 107 | // Supported fetch partial failures: changeTokenExpired 108 | 109 | guard let partialErrors = ckError.partialErrorsByItemID else { 110 | return .halt(error) 111 | } 112 | 113 | guard let error = partialErrors.first?.value as? CKError, partialErrors.count == 1 else { 114 | return .halt(error) 115 | } 116 | 117 | return mapErrorToEvent(error: error, work: work, zoneID: zoneID) 118 | case let .modify(operation): 119 | // Supported modify partial failures: batchRequestFailed and serverRecordChanged 120 | 121 | guard let partialErrorsByRecordID = ckError.partialErrorsByItemID as? [CKRecord.ID: Error] else { 122 | return .halt(error) 123 | } 124 | 125 | let recordIDsNotSavedOrDeleted = Set(partialErrorsByRecordID.keys) 126 | 127 | let partialErrors = partialErrorsByRecordID.compactMap { $0.value as? CKError } 128 | 129 | if ckError.indicatesShouldBackoff { 130 | return .retry(work, error, ckError.suggestedBackoffSeconds) 131 | } 132 | 133 | let unhandleableErrorsByItemID = partialErrors 134 | .filter { error in 135 | 136 | switch error.code { 137 | case .batchRequestFailed, .serverRecordChanged, .unknownItem: 138 | return false 139 | default: 140 | return true 141 | } 142 | } 143 | 144 | if !unhandleableErrorsByItemID.isEmpty { 145 | // Abort due to unknown error 146 | return .halt(error) 147 | } 148 | 149 | // All IDs for records that are unknown by the container (probably deleted by another client) 150 | let unknownItemRecordIDs = Set( 151 | partialErrorsByRecordID 152 | .filter { _, error in 153 | if let error = error as? CKError, error.code == .unknownItem { 154 | return true 155 | } 156 | 157 | return false 158 | } 159 | .keys 160 | ) 161 | 162 | // All IDs for records that failed to be modified due to some other error in the batch modify operation 163 | let batchRequestFailedRecordIDs = Set( 164 | partialErrorsByRecordID 165 | .filter { _, error in 166 | if let error = error as? CKError, error.code == .batchRequestFailed { 167 | return true 168 | } 169 | 170 | return false 171 | } 172 | .keys 173 | ) 174 | 175 | // All errors for records that failed because there was a conflict 176 | let serverRecordChangedErrors = partialErrors 177 | .filter { error in 178 | if error.code == .serverRecordChanged { 179 | return true 180 | } 181 | 182 | return false 183 | } 184 | 185 | // Resolved records 186 | let resolvedConflictsToSave = serverRecordChangedErrors 187 | .compactMap { error in 188 | self.resolveConflict(error: error) 189 | } 190 | 191 | if resolvedConflictsToSave.count != serverRecordChangedErrors.count { 192 | // Abort if couldn't handle conflict for some reason 193 | os_log( 194 | "Aborting since count of resolved records not equal to number of server record changed errors", 195 | log: log, 196 | type: .error 197 | ) 198 | 199 | return .halt(error) 200 | } 201 | 202 | let recordsToSaveWithoutUnknowns = operation.records 203 | .filter { recordIDsNotSavedOrDeleted.contains($0.recordID) } 204 | .filter { !unknownItemRecordIDs.contains($0.recordID) } 205 | 206 | let recordIDsToDeleteWithoutUnknowns = operation 207 | .recordIDsToDelete 208 | .filter(recordIDsNotSavedOrDeleted.contains) 209 | .filter { !unknownItemRecordIDs.contains($0) } 210 | 211 | let conflictsToSaveSet = Set(resolvedConflictsToSave.map(\.recordID)) 212 | 213 | let batchRequestFailureRecordsToSave = recordsToSaveWithoutUnknowns 214 | .filter { 215 | !conflictsToSaveSet.contains($0.recordID) && batchRequestFailedRecordIDs.contains($0.recordID) 216 | } 217 | 218 | let allResolvedRecordsToSave = batchRequestFailureRecordsToSave + resolvedConflictsToSave 219 | 220 | return .resolveConflict(work, allResolvedRecordsToSave, recordIDsToDeleteWithoutUnknowns) 221 | default: 222 | return .halt(error) 223 | } 224 | case .serverRecordChanged: 225 | guard let resolvedConflictToSave = resolveConflict(error: error) else { 226 | // If couldn't handle conflict for the records, abort 227 | return .halt(error) 228 | } 229 | 230 | return .resolveConflict(work, [resolvedConflictToSave], []) 231 | case .limitExceeded: 232 | return .split(work, error) 233 | case .zoneNotFound, .userDeletedZone: 234 | return .doWork(.createZone(CreateZoneOperation(zoneID: zoneID))) 235 | case .assetNotAvailable, 236 | .assetFileNotFound, 237 | .assetFileModified, 238 | .participantMayNeedVerification, 239 | .alreadyShared, 240 | .tooManyParticipants, 241 | .unknownItem, 242 | .operationCancelled, 243 | .accountTemporarilyUnavailable: 244 | return .halt(error) 245 | @unknown default: 246 | return nil 247 | } 248 | } else { 249 | return .halt(error) 250 | } 251 | } 252 | 253 | func resolveConflict(error: Error) -> CKRecord? { 254 | guard let effectiveError = error as? CKError else { 255 | os_log( 256 | "resolveConflict called on an error that was not a CKError. The error was %{public}@", 257 | log: log, 258 | type: .fault, 259 | String(describing: self) 260 | ) 261 | 262 | return nil 263 | } 264 | 265 | guard effectiveError.code == .serverRecordChanged else { 266 | os_log( 267 | "resolveConflict called on a CKError that was not a serverRecordChanged error. The error was %{public}@", 268 | log: log, 269 | type: .fault, 270 | String(describing: effectiveError) 271 | ) 272 | 273 | return nil 274 | } 275 | 276 | guard let clientRecord = effectiveError.clientRecord else { 277 | os_log( 278 | "Failed to obtain client record from serverRecordChanged error. The error was %{public}@", 279 | log: log, 280 | type: .fault, 281 | String(describing: effectiveError) 282 | ) 283 | 284 | return nil 285 | } 286 | 287 | guard let serverRecord = effectiveError.serverRecord else { 288 | os_log( 289 | "Failed to obtain server record from serverRecordChanged error. The error was %{public}@", 290 | log: log, 291 | type: .fault, 292 | String(describing: effectiveError) 293 | ) 294 | 295 | return nil 296 | } 297 | 298 | os_log( 299 | "CloudKit conflict with record of type %{public}@. Running conflict resolver", log: log, 300 | type: .error, serverRecord.recordType 301 | ) 302 | 303 | guard let resolveConflict = session.resolveConflict else { 304 | return nil 305 | } 306 | 307 | guard let resolvedRecord = resolveConflict(clientRecord, serverRecord) else { 308 | return nil 309 | } 310 | 311 | if serverRecord != resolvedRecord { 312 | // Always return the server record so we don't end up in a conflict loop. 313 | // The server record has the change tag we want to use. 314 | // https://developer.apple.com/documentation/cloudkit/ckerror/2325208-serverrecordchanged 315 | 316 | // First, nil out all keys in case any keys in the newly resolved record are nil, 317 | // we don't want those to carry over into the final resolved copy 318 | serverRecord.removeAllFields() 319 | 320 | serverRecord.copyFields(from: resolvedRecord) 321 | } 322 | 323 | return serverRecord 324 | } 325 | 326 | func resolveExpiredChangeToken() -> CKServerChangeToken? { 327 | guard let resolveExpiredChangeToken = session.resolveExpiredChangeToken else { 328 | return nil 329 | } 330 | 331 | return resolveExpiredChangeToken() 332 | } 333 | } 334 | 335 | extension CKRecord { 336 | func removeAllFields() { 337 | let encryptedKeys = Set(encryptedValues.allKeys()) 338 | 339 | allKeys().forEach { key in 340 | if encryptedKeys.contains(key) { 341 | encryptedValues[key] = nil 342 | } else { 343 | self[key] = nil 344 | } 345 | } 346 | } 347 | 348 | func copyFields(from otherRecord: CKRecord) { 349 | let encryptedKeys = Set(otherRecord.encryptedValues.allKeys()) 350 | 351 | otherRecord.allKeys().forEach { key in 352 | if encryptedKeys.contains(key) { 353 | encryptedValues[key] = otherRecord.encryptedValues[key] 354 | } else { 355 | self[key] = otherRecord[key] 356 | } 357 | } 358 | } 359 | } 360 | -------------------------------------------------------------------------------- /Sources/CloudSyncSession/Middleware/LoggerMiddleware.swift: -------------------------------------------------------------------------------- 1 | import os.log 2 | 3 | struct LoggerMiddleware: Middleware { 4 | var session: CloudSyncSession 5 | 6 | var log = OSLog( 7 | subsystem: "com.ryanashcraft.CloudSyncSession", 8 | category: "Sync Event" 9 | ) 10 | 11 | func run(next: (SyncEvent) -> SyncEvent, event: SyncEvent) -> SyncEvent { 12 | os_log("%{public}@", log: log, type: .debug, event.logDescription) 13 | 14 | return next(event) 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /Sources/CloudSyncSession/Middleware/RetryMiddleware.swift: -------------------------------------------------------------------------------- 1 | import CloudKit 2 | import Foundation 3 | 4 | let maxRetryCount = 5 5 | 6 | private func getRetryTimeInterval(retryCount: Int) -> TimeInterval { 7 | return TimeInterval(pow(Double(retryCount), 2.0)) 8 | } 9 | 10 | struct RetryMiddleware: Middleware { 11 | var session: CloudSyncSession 12 | 13 | private let dispatchQueue = DispatchQueue(label: "ErrorMiddleware.Dispatch", qos: .userInitiated) 14 | 15 | func run(next: (SyncEvent) -> SyncEvent, event: SyncEvent) -> SyncEvent { 16 | switch event { 17 | case let .retry(work, error, suggestedInterval): 18 | let currentRetryCount = work.retryCount 19 | 20 | if currentRetryCount + 1 > maxRetryCount { 21 | session.dispatch(event: .halt(error)) 22 | } else { 23 | let retryInterval: TimeInterval 24 | 25 | if let suggestedInterval = suggestedInterval { 26 | retryInterval = suggestedInterval 27 | } else { 28 | retryInterval = getRetryTimeInterval(retryCount: work.retryCount) 29 | } 30 | 31 | dispatchQueue.asyncAfter(deadline: .now() + retryInterval) { 32 | session.dispatch(event: .retryWork(work)) 33 | } 34 | } 35 | 36 | return next(event) 37 | default: 38 | return next(event) 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /Sources/CloudSyncSession/Middleware/SplittingMiddleware.swift: -------------------------------------------------------------------------------- 1 | import os.log 2 | 3 | struct SplittingMiddleware: Middleware { 4 | var session: CloudSyncSession 5 | 6 | func run(next: (SyncEvent) -> SyncEvent, event: SyncEvent) -> SyncEvent { 7 | switch event { 8 | case let .doWork(work): 9 | switch work { 10 | case let .modify(operation): 11 | if operation.shouldSplit { 12 | for splitOperation in operation.split { 13 | session.dispatch(event: .doWork(.modify(splitOperation))) 14 | } 15 | 16 | return next(.noop) 17 | } 18 | default: 19 | break 20 | } 21 | default: 22 | break 23 | } 24 | 25 | return next(event) 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /Sources/CloudSyncSession/Middleware/SubjectMiddleware.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | struct SubjectMiddleware: Middleware { 4 | var session: CloudSyncSession 5 | 6 | func run(next: (SyncEvent) -> SyncEvent, event: SyncEvent) -> SyncEvent { 7 | DispatchQueue.main.async { 8 | switch event { 9 | case let .workSuccess(work, result): 10 | switch result { 11 | case let .fetch(response): 12 | if case let .fetch(operation) = work { 13 | session.fetchWorkCompletedSubject.send((operation, response)) 14 | } 15 | case let .modify(response): 16 | if case let .modify(operation) = work { 17 | session.modifyWorkCompletedSubject.send((operation, response)) 18 | } 19 | default: 20 | break 21 | } 22 | case let .halt(error): 23 | session.haltedSubject.send(error) 24 | case let .accountStatusChanged(status): 25 | session.accountStatusSubject.send(status) 26 | case .start: 27 | session.haltedSubject.send(nil) 28 | default: 29 | break 30 | } 31 | } 32 | 33 | return next(event) 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /Sources/CloudSyncSession/Middleware/WorkMiddleware.swift: -------------------------------------------------------------------------------- 1 | import Combine 2 | import Foundation 3 | 4 | private let workDelay = DispatchTimeInterval.milliseconds(60) 5 | 6 | struct WorkMiddleware: Middleware { 7 | var session: CloudSyncSession 8 | 9 | private let dispatchQueue = DispatchQueue(label: "WorkMiddleware.Dispatch", qos: .userInitiated) 10 | 11 | func run(next: (SyncEvent) -> SyncEvent, event: SyncEvent) -> SyncEvent { 12 | let prevState = session.state 13 | let event = next(event) 14 | let newState = session.state 15 | 16 | if let work = newState.currentWork { 17 | let prevWork = prevState.currentWork 18 | 19 | if prevWork?.id != work.id || prevWork?.retryCount != work.retryCount { 20 | dispatchQueue.asyncAfter(deadline: .now() + workDelay) { 21 | self.doWork(work) 22 | } 23 | } 24 | } 25 | 26 | return event 27 | } 28 | 29 | private func doWork(_ work: SyncWork) { 30 | switch work { 31 | case let .fetch(operation): 32 | session.operationHandler.handle(fetchOperation: operation) { result in 33 | switch result { 34 | case let .failure(error): 35 | session.dispatch(event: .workFailure(work, error)) 36 | case let .success(response): 37 | session.dispatch(event: .workSuccess(work, .fetch(response))) 38 | } 39 | } 40 | case let .modify(operation): 41 | session.operationHandler.handle(modifyOperation: operation) { result in 42 | switch result { 43 | case let .failure(error): 44 | session.dispatch(event: .workFailure(work, error)) 45 | case let .success(response): 46 | session.dispatch(event: .workSuccess(work, .modify(response))) 47 | } 48 | } 49 | case let .createZone(operation): 50 | session.operationHandler.handle(createZoneOperation: operation) { result in 51 | switch result { 52 | case let .failure(error): 53 | session.dispatch(event: .workFailure(work, error)) 54 | case let .success(hasCreatedZone): 55 | session.dispatch(event: .workSuccess(work, .createZone(hasCreatedZone))) 56 | } 57 | } 58 | case let .createSubscription(operation): 59 | session.operationHandler.handle(createSubscriptionOperation: operation) { result in 60 | switch result { 61 | case let .failure(error): 62 | session.dispatch(event: .workFailure(work, error)) 63 | case let .success(hasCreatedSubscription): 64 | session.dispatch(event: .workSuccess(work, .createSubscription(hasCreatedSubscription))) 65 | } 66 | } 67 | } 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /Sources/CloudSyncSession/Middleware/ZoneMiddleware.swift: -------------------------------------------------------------------------------- 1 | public struct ZoneMiddleware: Middleware { 2 | public var session: CloudSyncSession 3 | 4 | public init(session: CloudSyncSession) { 5 | self.session = session 6 | } 7 | 8 | public func run(next: (SyncEvent) -> SyncEvent, event: SyncEvent) -> SyncEvent { 9 | switch event { 10 | case .start: 11 | if session.state.hasCreatedZone == nil { 12 | session.dispatch(event: .doWork(SyncWork.createZone(CreateZoneOperation(zoneID: session.zoneID)))) 13 | } 14 | 15 | if session.state.hasCreatedSubscription == nil { 16 | session.dispatch(event: .doWork(SyncWork.createSubscription(CreateSubscriptionOperation(zoneID: session.zoneID)))) 17 | } 18 | default: 19 | break 20 | } 21 | 22 | return next(event) 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /Sources/CloudSyncSession/OperationHandler.swift: -------------------------------------------------------------------------------- 1 | public protocol OperationHandler { 2 | func handle(fetchOperation: FetchOperation, completion: @escaping (Result) -> Void) 3 | func handle(modifyOperation: ModifyOperation, completion: @escaping (Result) -> Void) 4 | func handle(createZoneOperation: CreateZoneOperation, completion: @escaping (Result) -> Void) 5 | func handle(createSubscriptionOperation: CreateSubscriptionOperation, completion: @escaping (Result) -> Void) 6 | } 7 | -------------------------------------------------------------------------------- /Sources/CloudSyncSession/SyncEvent.swift: -------------------------------------------------------------------------------- 1 | import CloudKit 2 | 3 | public enum SyncEvent { 4 | /// Should be dispatched when the session starts. 5 | case start 6 | 7 | /// Indicates a non-recoverable er ror has occured and we should halt. 8 | case halt(Error) 9 | 10 | /// Indicates the iCloud account status changed. 11 | case accountStatusChanged(CKAccountStatus) 12 | 13 | /// Queues up work. 14 | case doWork(SyncWork) 15 | 16 | /// Queues up work that has failed and will be retried. 17 | case retryWork(SyncWork) 18 | 19 | /// Indicates that work has failed. 20 | case workFailure(SyncWork, Error) 21 | 22 | /// Indicates that work has succeeded. 23 | case workSuccess(SyncWork, SyncWork.Result) 24 | 25 | /// Queues up modification work after the work had previously failed due to a conflict. 26 | /// Includes failed work, records to save including resolved records, and record IDs that should be deleted. 27 | case resolveConflict(SyncWork, [CKRecord], [CKRecord.ID]) 28 | 29 | /// Indicates that work should be retried after some time. 30 | case retry(SyncWork, Error, TimeInterval?) 31 | 32 | /// Indicates that work should be split up. 33 | case split(SyncWork, Error) 34 | 35 | /// Does nothing. 36 | case noop 37 | 38 | var logDescription: String { 39 | switch self { 40 | case .start: 41 | return "Start" 42 | case .halt: 43 | return "Halt" 44 | case let .accountStatusChanged(status): 45 | return "Account status changed: \(status.debugDescription)" 46 | case let .doWork(work): 47 | return "Do work: \(work.debugDescription)" 48 | case let .retryWork(work): 49 | return "Retry work: \(work.debugDescription)" 50 | case let .workFailure(work, _): 51 | return "Work failure: \(work.debugDescription)" 52 | case let .workSuccess(work, _): 53 | return "Work success: \(work.debugDescription)" 54 | case .retry: 55 | return "Retry" 56 | case let .split(work, _): 57 | return "Split work: \(work.debugDescription)" 58 | case let .resolveConflict(_, records, recordIDsToDelete): 59 | return "Resolving \(records.count) records with \(recordIDsToDelete.count) deleted" 60 | case .noop: 61 | return "Noop" 62 | } 63 | } 64 | } 65 | 66 | private extension CKAccountStatus { 67 | var debugDescription: String { 68 | switch self { 69 | case .available: 70 | return "Available" 71 | case .couldNotDetermine: 72 | return "Could Not Determine" 73 | case .noAccount: 74 | return "No Account" 75 | case .restricted: 76 | return "Restricted" 77 | case .temporarilyUnavailable: 78 | return "Temporarily Unavailable" 79 | @unknown default: 80 | return "Unknown" 81 | } 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /Sources/CloudSyncSession/SyncState.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /// The state of a session. 4 | public struct SyncState { 5 | /// The various modes that the session can be operating in. 6 | public enum OperationMode { 7 | case modify 8 | case fetch 9 | case createZone 10 | case createSubscription 11 | } 12 | 13 | /// The queue of modification requests to be handled. 14 | var modifyQueue = [ModifyOperation]() 15 | 16 | /// The queue of fetch requests to be handled. 17 | var fetchQueue = [FetchOperation]() 18 | 19 | /// The queue of create zone requests to be handled. 20 | var createZoneQueue = [CreateZoneOperation]() 21 | 22 | /// The queue of create subscription requests to be handled. 23 | var createSubscriptionQueue = [CreateSubscriptionOperation]() 24 | 25 | /// Indicates whether the CloudKit status is available. The value is nil if the account status is yet to be deteremined. 26 | public var hasGoodAccountStatus: Bool? = nil 27 | 28 | /// Indicates whether the zone has been created. The value is nil if the session has yet to create the zone. 29 | public var hasCreatedZone: Bool? = nil 30 | 31 | /// Indicates whether the subscription has been created. The value is nil if the session has yet to create the subscription. 32 | public var hasCreatedSubscription: Bool? = nil 33 | 34 | /// Indicates whether the sync session has halted. 35 | public var isHalted: Bool = false { 36 | didSet { 37 | lastHaltedDate = isHalted ? Date() : nil 38 | 39 | updateOperationMode() 40 | } 41 | } 42 | 43 | /// The time at which the sync session was last halted. 44 | public var lastHaltedDate: Date? 45 | 46 | /// How many times the current work has been retried. 47 | public var retryCount: Int = 0 48 | 49 | /// The error that caused the last retry. 50 | public var lastRetryError: Error? 51 | 52 | /// Indicates whether the session is currently modyifing, fetching, creating a zone, or creating a subscription. 53 | public var operationMode: OperationMode? 54 | 55 | /// Indicates whether or not there's any work to be done. 56 | public var hasWorkQueued: Bool { 57 | !modifyQueue.isEmpty || !fetchQueue.isEmpty || !createZoneQueue.isEmpty || !createSubscriptionQueue.isEmpty 58 | } 59 | 60 | /// Indicates whether the session is currently fetching. 61 | public var isFetching: Bool { 62 | operationMode == .fetch 63 | } 64 | 65 | /// Indicates whether the session is currently modifying. 66 | public var isModifying: Bool { 67 | operationMode == .modify 68 | } 69 | 70 | /// Indicates what kind of work is allowed at this time. 71 | var allowedOperationModes: Set { 72 | var allowedModes: Set = [nil] 73 | 74 | if isHalted { 75 | // Halted means no work allowed 76 | return allowedModes 77 | } 78 | 79 | if !(hasGoodAccountStatus ?? false) { 80 | // Bad or unknown account status means no work allowed 81 | return allowedModes 82 | } 83 | 84 | allowedModes.formUnion([.createZone, .createSubscription]) 85 | 86 | if hasCreatedZone ?? false, hasCreatedSubscription ?? false { 87 | allowedModes.formUnion([.fetch, .modify]) 88 | } 89 | 90 | return allowedModes 91 | } 92 | 93 | /// An ordered list of the the kind of work that is allowed at this time. 94 | var preferredOperationModes: [OperationMode?] { 95 | [.createZone, .createSubscription, .modify, .fetch, nil] 96 | .filter { allowedOperationModes.contains($0) } 97 | .filter { mode in 98 | switch mode { 99 | case .createZone: 100 | return !createZoneQueue.isEmpty 101 | case .fetch: 102 | return !fetchQueue.isEmpty 103 | case .modify: 104 | return !modifyQueue.isEmpty 105 | case .createSubscription: 106 | return !createSubscriptionQueue.isEmpty 107 | case nil: 108 | return true 109 | } 110 | } 111 | } 112 | 113 | /// Indicates whether the session is ready to perform fetches and modifications. 114 | public var isRunning: Bool { 115 | allowedOperationModes.contains(.fetch) 116 | && allowedOperationModes.contains(.modify) 117 | && !isHalted 118 | } 119 | 120 | /// Indicates whether the prerequisites have been met to fetch and/or modify records. 121 | public var hasStarted: Bool { 122 | hasCreatedZone != nil && hasCreatedSubscription != nil && hasGoodAccountStatus != nil 123 | } 124 | 125 | /// Indicates whether we are starting up, but not ready to fetch and/or modify records. 126 | public var isStarting: Bool { 127 | !hasStarted && !isHalted 128 | } 129 | 130 | /// The current work that is, or is to be, worked on. 131 | var currentWork: SyncWork? { 132 | guard allowedOperationModes.contains(operationMode) else { 133 | return nil 134 | } 135 | 136 | switch operationMode { 137 | case nil: 138 | return nil 139 | case .modify: 140 | if let operation = modifyQueue.first { 141 | return SyncWork.modify(operation) 142 | } 143 | case .fetch: 144 | if let operation = fetchQueue.first { 145 | return SyncWork.fetch(operation) 146 | } 147 | case .createZone: 148 | if let operation = createZoneQueue.first { 149 | return SyncWork.createZone(operation) 150 | } 151 | case .createSubscription: 152 | if let operation = createSubscriptionQueue.first { 153 | return SyncWork.createSubscription(operation) 154 | } 155 | } 156 | 157 | return nil 158 | } 159 | 160 | /// Transition to a new operation mode (i.e. fetching, modifying creating a zone or subscription) 161 | mutating func updateOperationMode() { 162 | if isHalted { 163 | operationMode = nil 164 | } 165 | 166 | if operationMode == nil || !preferredOperationModes.contains(operationMode) { 167 | operationMode = preferredOperationModes.first ?? nil 168 | } 169 | } 170 | 171 | /// Add work to the end of the appropriate queue 172 | mutating func pushWork(_ work: SyncWork) { 173 | switch work { 174 | case let .fetch(operation): 175 | fetchQueue.append(operation) 176 | case let .modify(operation): 177 | modifyQueue.append(operation) 178 | case let .createZone(operation): 179 | createZoneQueue.append(operation) 180 | case let .createSubscription(operation): 181 | createSubscriptionQueue.append(operation) 182 | } 183 | } 184 | 185 | /// Add work to the beginning of the appropriate queue. 186 | mutating func prioritizeWork(_ work: SyncWork) { 187 | switch work { 188 | case let .fetch(operation): 189 | fetchQueue = [operation] + fetchQueue 190 | case let .modify(operation): 191 | modifyQueue = [operation] + modifyQueue 192 | case let .createZone(operation): 193 | createZoneQueue = [operation] + createZoneQueue 194 | case let .createSubscription(operation): 195 | createSubscriptionQueue = [operation] + createSubscriptionQueue 196 | } 197 | } 198 | 199 | /// Remove work from the corresponding queue. 200 | mutating func popWork(work: SyncWork) { 201 | switch work { 202 | case let .fetch(operation): 203 | fetchQueue = fetchQueue.filter { $0.id != operation.id } 204 | case let .modify(operation): 205 | modifyQueue = modifyQueue.filter { $0.id != operation.id } 206 | case let .createZone(operation): 207 | createZoneQueue = createZoneQueue.filter { $0.id != operation.id } 208 | case let .createSubscription(operation): 209 | createSubscriptionQueue = createSubscriptionQueue.filter { $0.id != operation.id } 210 | } 211 | } 212 | 213 | /// Update based on a sync event. 214 | func reduce(event: SyncEvent) -> SyncState { 215 | var state = self 216 | 217 | switch event { 218 | case let .accountStatusChanged(accountStatus): 219 | switch accountStatus { 220 | case .available: 221 | state.hasGoodAccountStatus = true 222 | default: 223 | state.hasGoodAccountStatus = false 224 | } 225 | state.updateOperationMode() 226 | case let .retryWork(work): 227 | state.popWork(work: work) 228 | state.pushWork(work.retried) 229 | state.updateOperationMode() 230 | case let .doWork(work): 231 | state.pushWork(work) 232 | state.updateOperationMode() 233 | case let .split(work, _): 234 | state.popWork(work: work) 235 | 236 | switch work { 237 | case let .modify(operation): 238 | for splitOperation in operation.splitInHalf.reversed() { 239 | state.prioritizeWork(.modify(splitOperation)) 240 | } 241 | default: 242 | state.isHalted = true 243 | } 244 | 245 | state.updateOperationMode() 246 | case let .workFailure(work, _): 247 | state.popWork(work: work) 248 | state.updateOperationMode() 249 | case let .workSuccess(work, result): 250 | state.retryCount = 0 251 | state.lastRetryError = nil 252 | 253 | state.popWork(work: work) 254 | 255 | switch result { 256 | case let .fetch(response): 257 | if response.hasMore { 258 | state.prioritizeWork(.fetch(FetchOperation(changeToken: response.changeToken))) 259 | } 260 | case let .createZone(didCreateZone): 261 | state.hasCreatedZone = didCreateZone 262 | case let .createSubscription(didCreateSubscription): 263 | state.hasCreatedSubscription = didCreateSubscription 264 | default: 265 | break 266 | } 267 | 268 | state.updateOperationMode() 269 | case let .resolveConflict(work, records, recordIDsToDelete): 270 | if case let .modify(failedOperation) = work { 271 | let operation = ModifyOperation(records: records, recordIDsToDelete: recordIDsToDelete, checkpointID: work.checkpointID, userInfo: failedOperation.userInfo) 272 | 273 | state.popWork(work: work) 274 | state.pushWork(.modify(operation)) 275 | } 276 | case .halt: 277 | state.isHalted = true 278 | case .start: 279 | state.isHalted = false 280 | case let .retry(_, error, _): 281 | state.retryCount += 1 282 | state.lastRetryError = error 283 | case .noop: 284 | break 285 | } 286 | 287 | return state 288 | } 289 | } 290 | -------------------------------------------------------------------------------- /Sources/CloudSyncSession/SyncWork.swift: -------------------------------------------------------------------------------- 1 | import CloudKit 2 | 3 | public let maxRecommendedRecordsPerOperation = 400 4 | 5 | public enum SyncWork: Identifiable { 6 | public enum Result { 7 | case modify(ModifyOperation.Response) 8 | case fetch(FetchOperation.Response) 9 | case createZone(Bool) 10 | case createSubscription(Bool) 11 | } 12 | 13 | case modify(ModifyOperation) 14 | case fetch(FetchOperation) 15 | case createZone(CreateZoneOperation) 16 | case createSubscription(CreateSubscriptionOperation) 17 | 18 | public var id: UUID { 19 | switch self { 20 | case let .modify(operation): 21 | return operation.id 22 | case let .fetch(operation): 23 | return operation.id 24 | case let .createZone(operation): 25 | return operation.id 26 | case let .createSubscription(operation): 27 | return operation.id 28 | } 29 | } 30 | 31 | var retryCount: Int { 32 | switch self { 33 | case let .modify(operation): 34 | return operation.retryCount 35 | case let .fetch(operation): 36 | return operation.retryCount 37 | case let .createZone(operation): 38 | return operation.retryCount 39 | case let .createSubscription(operation): 40 | return operation.retryCount 41 | } 42 | } 43 | 44 | var retried: SyncWork { 45 | switch self { 46 | case var .modify(operation): 47 | operation.retryCount += 1 48 | 49 | return .modify(operation) 50 | case var .fetch(operation): 51 | operation.retryCount += 1 52 | 53 | return .fetch(operation) 54 | case var .createZone(operation): 55 | operation.retryCount += 1 56 | 57 | return .createZone(operation) 58 | case var .createSubscription(operation): 59 | operation.retryCount += 1 60 | 61 | return .createSubscription(operation) 62 | } 63 | } 64 | 65 | var checkpointID: UUID? { 66 | switch self { 67 | case let .modify(operation): 68 | return operation.checkpointID 69 | default: 70 | return nil 71 | } 72 | } 73 | 74 | var debugDescription: String { 75 | switch self { 76 | case let .modify(operation): 77 | return "Modify with \(operation.records.count) records to save and \(operation.recordIDsToDelete.count) to delete" 78 | case .fetch: 79 | return "Fetch" 80 | case .createZone: 81 | return "Create zone" 82 | case .createSubscription: 83 | return "Create subscription" 84 | } 85 | } 86 | } 87 | 88 | protocol SyncOperation { 89 | var retryCount: Int { get set } 90 | } 91 | 92 | public struct FetchOperation: Identifiable, SyncOperation { 93 | public struct Response { 94 | public let changeToken: CKServerChangeToken? 95 | public let changedRecords: [CKRecord] 96 | public let deletedRecordIDs: [CKRecord.ID] 97 | public let hasMore: Bool 98 | } 99 | 100 | public let id = UUID() 101 | 102 | var changeToken: CKServerChangeToken? 103 | var retryCount: Int = 0 104 | 105 | public init(changeToken: CKServerChangeToken?) { 106 | self.changeToken = changeToken 107 | } 108 | } 109 | 110 | public struct ModifyOperation: Identifiable, SyncOperation { 111 | public struct Response { 112 | public let savedRecords: [CKRecord] 113 | public let deletedRecordIDs: [CKRecord.ID] 114 | } 115 | 116 | public let id = UUID() 117 | public let checkpointID: UUID? 118 | public let userInfo: [String: Any]? 119 | 120 | var records: [CKRecord] 121 | var recordIDsToDelete: [CKRecord.ID] 122 | var retryCount: Int = 0 123 | 124 | public init(records: [CKRecord], recordIDsToDelete: [CKRecord.ID], checkpointID: UUID?, userInfo: [String: Any]?) { 125 | self.records = records 126 | self.recordIDsToDelete = recordIDsToDelete 127 | self.checkpointID = checkpointID 128 | self.userInfo = userInfo 129 | } 130 | 131 | var shouldSplit: Bool { 132 | return records.count + recordIDsToDelete.count > maxRecommendedRecordsPerOperation 133 | } 134 | 135 | var split: [ModifyOperation] { 136 | let splitRecords: [[CKRecord]] = records.chunked(into: maxRecommendedRecordsPerOperation) 137 | let splitRecordIDsToDelete: [[CKRecord.ID]] = recordIDsToDelete.chunked(into: maxRecommendedRecordsPerOperation) 138 | 139 | return splitRecords.map { ModifyOperation(records: $0, recordIDsToDelete: [], checkpointID: nil, userInfo: userInfo) } + 140 | splitRecordIDsToDelete.enumerated().map { ModifyOperation(records: [], recordIDsToDelete: $0.element, checkpointID: $0.offset == splitRecordIDsToDelete.count - 1 ? checkpointID : nil, userInfo: userInfo) } 141 | } 142 | 143 | var splitInHalf: [ModifyOperation] { 144 | let firstHalfRecords = Array(records[0 ..< records.count / 2]) 145 | let secondHalfRecords = Array(records[records.count / 2 ..< records.count]) 146 | 147 | let firstHalfRecordIDsToDelete = Array(recordIDsToDelete[0 ..< recordIDsToDelete.count / 2]) 148 | let secondHalfRecordIDsToDelete = Array(recordIDsToDelete[recordIDsToDelete.count / 2 ..< recordIDsToDelete.count]) 149 | 150 | return [ 151 | ModifyOperation(records: firstHalfRecords, recordIDsToDelete: firstHalfRecordIDsToDelete, checkpointID: nil, userInfo: userInfo), 152 | ModifyOperation(records: secondHalfRecords, recordIDsToDelete: secondHalfRecordIDsToDelete, checkpointID: checkpointID, userInfo: userInfo), 153 | ] 154 | } 155 | } 156 | 157 | public struct CreateZoneOperation: Identifiable, SyncOperation { 158 | var zoneID: CKRecordZone.ID 159 | var retryCount: Int = 0 160 | 161 | public let id = UUID() 162 | 163 | public init(zoneID: CKRecordZone.ID) { 164 | self.zoneID = zoneID 165 | } 166 | } 167 | 168 | public struct CreateSubscriptionOperation: Identifiable, SyncOperation { 169 | var zoneID: CKRecordZone.ID 170 | var retryCount: Int = 0 171 | 172 | public let id = UUID() 173 | 174 | public init(zoneID: CKRecordZone.ID) { 175 | self.zoneID = zoneID 176 | } 177 | } 178 | 179 | private extension Array { 180 | func chunked(into size: Int) -> [[Element]] { 181 | return stride(from: 0, to: count, by: size).map { 182 | Array(self[$0 ..< Swift.min($0 + size, count)]) 183 | } 184 | } 185 | } 186 | -------------------------------------------------------------------------------- /Tests/CloudSyncSessionTests/CloudSyncSessionTests.swift: -------------------------------------------------------------------------------- 1 | import CloudKit 2 | @testable import CloudSyncSession 3 | import Combine 4 | import XCTest 5 | 6 | class SuccessfulMockOperationHandler: OperationHandler { 7 | private var operationCount = 0 8 | 9 | func handle(createZoneOperation _: CreateZoneOperation, completion _: @escaping (Result) -> Void) {} 10 | func handle(createSubscriptionOperation _: CreateSubscriptionOperation, completion _: @escaping (Result) -> Void) {} 11 | 12 | func handle(fetchOperation _: FetchOperation, completion: @escaping (Result) -> Void) { 13 | DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(60)) { 14 | self.operationCount += 1 15 | 16 | completion( 17 | .success( 18 | FetchOperation.Response( 19 | changeToken: nil, 20 | changedRecords: (0 ..< 400).map { _ in makeTestRecord() }, 21 | deletedRecordIDs: [], 22 | hasMore: self.operationCount == 1 23 | ) 24 | ) 25 | ) 26 | } 27 | } 28 | 29 | func handle(modifyOperation: ModifyOperation, completion: @escaping (Result) -> Void) { 30 | DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(60)) { 31 | completion(.success(ModifyOperation.Response(savedRecords: modifyOperation.records, deletedRecordIDs: []))) 32 | } 33 | } 34 | } 35 | 36 | class FailingMockOperationHandler: OperationHandler { 37 | let error: Error 38 | 39 | init(error: Error) { 40 | self.error = error 41 | } 42 | 43 | func handle(createZoneOperation _: CreateZoneOperation, completion _: @escaping (Result) -> Void) {} 44 | func handle(createSubscriptionOperation _: CreateSubscriptionOperation, completion _: @escaping (Result) -> Void) {} 45 | func handle(fetchOperation _: FetchOperation, completion _: @escaping (Result) -> Void) {} 46 | 47 | func handle(modifyOperation _: ModifyOperation, completion: @escaping (Result) -> Void) { 48 | DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(60)) { 49 | completion(.failure(self.error)) 50 | } 51 | } 52 | } 53 | 54 | class FailOnceMockOperationHandler: OperationHandler { 55 | let error: Error 56 | 57 | private var operationCount = 0 58 | 59 | init(error: Error) { 60 | self.error = error 61 | } 62 | 63 | func handle(createZoneOperation _: CreateZoneOperation, completion _: @escaping (Result) -> Void) {} 64 | func handle(createSubscriptionOperation _: CreateSubscriptionOperation, completion _: @escaping (Result) -> Void) {} 65 | func handle(fetchOperation _: FetchOperation, completion _: @escaping (Result) -> Void) {} 66 | 67 | func handle(modifyOperation: ModifyOperation, completion: @escaping (Result) -> Void) { 68 | DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(60)) { 69 | self.operationCount += 1 70 | 71 | if self.operationCount > 1 { 72 | completion(.success(ModifyOperation.Response(savedRecords: modifyOperation.records, deletedRecordIDs: []))) 73 | } else { 74 | completion(.failure(self.error)) 75 | } 76 | } 77 | } 78 | } 79 | 80 | class PartialFailureMockOperationHandler: OperationHandler { 81 | func handle(createZoneOperation _: CreateZoneOperation, completion _: @escaping (Result) -> Void) {} 82 | func handle(fetchOperation _: FetchOperation, completion _: @escaping (Result) -> Void) {} 83 | func handle(createSubscriptionOperation _: CreateSubscriptionOperation, completion _: @escaping (Result) -> Void) {} 84 | 85 | func handle(modifyOperation _: ModifyOperation, completion: @escaping (Result) -> Void) { 86 | DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(60)) { 87 | completion(.failure(CKError(.partialFailure))) 88 | } 89 | } 90 | } 91 | 92 | private var testIdentifier = "8B14FD76-EA56-49B0-A184-6C01828BA20A" 93 | 94 | private var testZoneID = CKRecordZone.ID( 95 | zoneName: "test", 96 | ownerName: CKCurrentUserDefaultName 97 | ) 98 | 99 | func makeTestRecord() -> CKRecord { 100 | return CKRecord( 101 | recordType: "Test", 102 | recordID: CKRecord.ID(recordName: UUID().uuidString) 103 | ) 104 | } 105 | 106 | final class CloudSyncSessionTests: XCTestCase { 107 | func testRunsAfterAccountAvailableAndZoneCreated() { 108 | var tasks = Set() 109 | let expectation = self.expectation(description: "work") 110 | let mockOperationHandler = SuccessfulMockOperationHandler() 111 | let session = CloudSyncSession( 112 | operationHandler: mockOperationHandler, 113 | zoneID: testZoneID, 114 | resolveConflict: { _, _ in nil }, 115 | resolveExpiredChangeToken: { nil } 116 | ) 117 | 118 | session.dispatch(event: .accountStatusChanged(.available)) 119 | let createZoneWork = SyncWork.createZone(CreateZoneOperation(zoneID: testZoneID)) 120 | session.dispatch(event: .workSuccess(createZoneWork, .createZone(true))) 121 | let createSubscriptionWork = SyncWork.createSubscription(CreateSubscriptionOperation(zoneID: testZoneID)) 122 | session.dispatch(event: .workSuccess(createSubscriptionWork, .createSubscription(true))) 123 | 124 | session.$state 125 | .sink { newState in 126 | if newState.isRunning { 127 | expectation.fulfill() 128 | } 129 | } 130 | .store(in: &tasks) 131 | 132 | wait(for: [expectation], timeout: 1) 133 | } 134 | 135 | func testModifySuccess() { 136 | let expectation = self.expectation(description: "work") 137 | 138 | let mockOperationHandler = SuccessfulMockOperationHandler() 139 | let session = CloudSyncSession( 140 | operationHandler: mockOperationHandler, 141 | zoneID: testZoneID, 142 | resolveConflict: { _, _ in nil }, 143 | resolveExpiredChangeToken: { nil } 144 | ) 145 | session.state = SyncState( 146 | hasGoodAccountStatus: true, 147 | hasCreatedZone: true, 148 | hasCreatedSubscription: true 149 | ) 150 | 151 | var tasks = Set() 152 | session.modifyWorkCompletedSubject 153 | .sink { _, response in 154 | XCTAssertEqual(response.savedRecords.count, 1) 155 | 156 | expectation.fulfill() 157 | } 158 | .store(in: &tasks) 159 | 160 | let operation = ModifyOperation(records: [makeTestRecord()], recordIDsToDelete: [], checkpointID: nil, userInfo: nil) 161 | session.modify(operation) 162 | 163 | wait(for: [expectation], timeout: 1000) 164 | } 165 | 166 | func testModifyFailure() { 167 | var tasks = Set() 168 | let expectation = self.expectation(description: "work") 169 | 170 | let mockOperationHandler = FailingMockOperationHandler(error: CKError(.notAuthenticated)) 171 | let session = CloudSyncSession( 172 | operationHandler: mockOperationHandler, 173 | zoneID: testZoneID, 174 | resolveConflict: { _, _ in nil }, 175 | resolveExpiredChangeToken: { nil } 176 | ) 177 | session.state = SyncState( 178 | hasGoodAccountStatus: true, 179 | hasCreatedZone: true, 180 | hasCreatedSubscription: true 181 | ) 182 | 183 | session.modifyWorkCompletedSubject 184 | .sink { _, _ in 185 | XCTFail() 186 | } 187 | .store(in: &tasks) 188 | 189 | session.$state 190 | .sink { newState in 191 | if !newState.isRunning { 192 | expectation.fulfill() 193 | } 194 | } 195 | .store(in: &tasks) 196 | 197 | let operation = ModifyOperation(records: [makeTestRecord()], recordIDsToDelete: [], checkpointID: nil, userInfo: nil) 198 | session.modify(operation) 199 | 200 | wait(for: [expectation], timeout: 1) 201 | } 202 | 203 | func testHaltedIgnoresModifyEvents() { 204 | var tasks = Set() 205 | let expectation = self.expectation(description: "work") 206 | expectation.isInverted = true 207 | 208 | let mockOperationHandler = SuccessfulMockOperationHandler() 209 | let session = CloudSyncSession( 210 | operationHandler: mockOperationHandler, 211 | zoneID: testZoneID, 212 | resolveConflict: { _, _ in nil }, 213 | resolveExpiredChangeToken: { nil } 214 | ) 215 | session.state = SyncState( 216 | hasGoodAccountStatus: true, 217 | hasCreatedZone: true, 218 | hasCreatedSubscription: true, 219 | isHalted: true 220 | ) 221 | 222 | session.modifyWorkCompletedSubject 223 | .sink { _, _ in 224 | expectation.fulfill() 225 | } 226 | .store(in: &tasks) 227 | 228 | let operation = ModifyOperation(records: [makeTestRecord()], recordIDsToDelete: [], checkpointID: nil, userInfo: nil) 229 | session.modify(operation) 230 | 231 | wait(for: [expectation], timeout: 1) 232 | } 233 | 234 | func testDoesNotUnhaltAfterFailure() { 235 | var tasks = Set() 236 | let expectation = self.expectation(description: "work") 237 | expectation.assertForOverFulfill = false 238 | 239 | let mockOperationHandler = FailingMockOperationHandler(error: CKError(.notAuthenticated)) 240 | let session = CloudSyncSession( 241 | operationHandler: mockOperationHandler, 242 | zoneID: testZoneID, 243 | resolveConflict: { _, _ in nil }, 244 | resolveExpiredChangeToken: { nil } 245 | ) 246 | session.state = SyncState( 247 | hasGoodAccountStatus: true, 248 | hasCreatedZone: true, 249 | hasCreatedSubscription: true 250 | ) 251 | 252 | session.$state 253 | .receive(on: DispatchQueue.main) 254 | .sink { newState in 255 | if newState.isHalted { 256 | session.dispatch(event: .accountStatusChanged(.available)) 257 | XCTAssertFalse(session.state.isRunning) 258 | expectation.fulfill() 259 | } 260 | } 261 | .store(in: &tasks) 262 | 263 | let operation = ModifyOperation(records: [makeTestRecord()], recordIDsToDelete: [], checkpointID: nil, userInfo: nil) 264 | session.modify(operation) 265 | 266 | wait(for: [expectation], timeout: 1) 267 | } 268 | 269 | func testResumesWorkAfterUnhalting() { 270 | var tasks = Set() 271 | let expectation = self.expectation(description: "work") 272 | 273 | let mockOperationHandler = SuccessfulMockOperationHandler() 274 | let session = CloudSyncSession( 275 | operationHandler: mockOperationHandler, 276 | zoneID: testZoneID, 277 | resolveConflict: { _, _ in nil }, 278 | resolveExpiredChangeToken: { nil } 279 | ) 280 | session.state = SyncState( 281 | hasGoodAccountStatus: false, 282 | hasCreatedZone: true, 283 | hasCreatedSubscription: true 284 | ) 285 | 286 | session.modifyWorkCompletedSubject 287 | .sink { _, response in 288 | XCTAssertEqual(response.savedRecords.count, 1) 289 | 290 | expectation.fulfill() 291 | } 292 | .store(in: &tasks) 293 | 294 | let operation = ModifyOperation(records: [makeTestRecord()], recordIDsToDelete: [], checkpointID: nil, userInfo: nil) 295 | session.modify(operation) 296 | session.dispatch(event: .accountStatusChanged(.available)) 297 | 298 | wait(for: [expectation], timeout: 1) 299 | } 300 | 301 | func testHaltAfterPartialFailureWithoutRecovery() { 302 | var tasks = Set() 303 | let expectation = self.expectation(description: "work") 304 | 305 | let mockOperationHandler = PartialFailureMockOperationHandler() 306 | let session = CloudSyncSession( 307 | operationHandler: mockOperationHandler, 308 | zoneID: testZoneID, 309 | resolveConflict: { _, _ in nil }, 310 | resolveExpiredChangeToken: { nil } 311 | ) 312 | session.state = SyncState( 313 | hasGoodAccountStatus: true, 314 | hasCreatedZone: true, 315 | hasCreatedSubscription: true 316 | ) 317 | 318 | // Won't recover because no conflict handler set up 319 | 320 | session.$state 321 | .receive(on: DispatchQueue.main) 322 | .sink { newState in 323 | if newState.isHalted { 324 | expectation.fulfill() 325 | } 326 | } 327 | .store(in: &tasks) 328 | 329 | let operation = ModifyOperation(records: [makeTestRecord()], recordIDsToDelete: [], checkpointID: nil, userInfo: nil) 330 | session.modify(operation) 331 | 332 | wait(for: [expectation], timeout: 1) 333 | } 334 | 335 | func testRetries() { 336 | var tasks = Set() 337 | let expectation = self.expectation(description: "work") 338 | 339 | let mockOperationHandler = FailOnceMockOperationHandler(error: CKError(.networkUnavailable)) 340 | let session = CloudSyncSession( 341 | operationHandler: mockOperationHandler, 342 | zoneID: testZoneID, 343 | resolveConflict: { _, _ in nil }, 344 | resolveExpiredChangeToken: { nil } 345 | ) 346 | session.state = SyncState( 347 | hasGoodAccountStatus: true, 348 | hasCreatedZone: true, 349 | hasCreatedSubscription: true 350 | ) 351 | 352 | session.modifyWorkCompletedSubject 353 | .sink { _, response in 354 | XCTAssertEqual(response.savedRecords.count, 1) 355 | 356 | expectation.fulfill() 357 | } 358 | .store(in: &tasks) 359 | 360 | let operation = ModifyOperation(records: [makeTestRecord()], recordIDsToDelete: [], checkpointID: nil, userInfo: nil) 361 | session.modify(operation) 362 | 363 | wait(for: [expectation], timeout: 1) 364 | } 365 | 366 | func testSplitsLargeWork() { 367 | var tasks = Set() 368 | let expectation = self.expectation(description: "work") 369 | 370 | let mockOperationHandler = SuccessfulMockOperationHandler() 371 | let session = CloudSyncSession( 372 | operationHandler: mockOperationHandler, 373 | zoneID: testZoneID, 374 | resolveConflict: { _, _ in nil }, 375 | resolveExpiredChangeToken: { nil } 376 | ) 377 | session.state = SyncState( 378 | hasGoodAccountStatus: true, 379 | hasCreatedZone: true, 380 | hasCreatedSubscription: true 381 | ) 382 | 383 | var timesCalled = 0 384 | 385 | session.modifyWorkCompletedSubject 386 | .sink { _, response in 387 | timesCalled += 1 388 | 389 | XCTAssertEqual(response.savedRecords.count, 400) 390 | 391 | if timesCalled >= 2 { 392 | expectation.fulfill() 393 | } 394 | } 395 | .store(in: &tasks) 396 | 397 | let records = (0 ..< 800).map { _ in makeTestRecord() } 398 | let operation = ModifyOperation(records: records, recordIDsToDelete: [], checkpointID: nil, userInfo: nil) 399 | session.modify(operation) 400 | 401 | wait(for: [expectation], timeout: 1) 402 | } 403 | 404 | func testSplitsInHalf() { 405 | var tasks = Set() 406 | let expectation = self.expectation(description: "work") 407 | 408 | let mockOperationHandler = FailOnceMockOperationHandler(error: CKError(.limitExceeded)) 409 | let session = CloudSyncSession( 410 | operationHandler: mockOperationHandler, 411 | zoneID: testZoneID, 412 | resolveConflict: { _, _ in nil }, 413 | resolveExpiredChangeToken: { nil } 414 | ) 415 | session.state = SyncState( 416 | hasGoodAccountStatus: true, 417 | hasCreatedZone: true, 418 | hasCreatedSubscription: true 419 | ) 420 | 421 | var timesCalled = 0 422 | 423 | session.modifyWorkCompletedSubject 424 | .sink { _, response in 425 | timesCalled += 1 426 | 427 | XCTAssertEqual(response.savedRecords.count, 50) 428 | 429 | if timesCalled >= 2 { 430 | expectation.fulfill() 431 | } 432 | } 433 | .store(in: &tasks) 434 | 435 | let records = (0 ..< 100).map { _ in makeTestRecord() } 436 | let operation = ModifyOperation(records: records, recordIDsToDelete: [], checkpointID: nil, userInfo: nil) 437 | session.modify(operation) 438 | 439 | wait(for: [expectation], timeout: 1000) 440 | } 441 | 442 | func testLoadsMore() { 443 | var tasks = Set() 444 | let expectation = self.expectation(description: "work") 445 | 446 | let mockOperationHandler = SuccessfulMockOperationHandler() 447 | let session = CloudSyncSession( 448 | operationHandler: mockOperationHandler, 449 | zoneID: testZoneID, 450 | resolveConflict: { _, _ in nil }, 451 | resolveExpiredChangeToken: { nil } 452 | ) 453 | session.state = SyncState( 454 | hasGoodAccountStatus: true, 455 | hasCreatedZone: true, 456 | hasCreatedSubscription: true 457 | ) 458 | 459 | var timesCalled = 0 460 | 461 | session.fetchWorkCompletedSubject 462 | .sink { _ in 463 | timesCalled += 1 464 | 465 | if timesCalled >= 2 { 466 | expectation.fulfill() 467 | } 468 | } 469 | .store(in: &tasks) 470 | 471 | let operation = FetchOperation(changeToken: nil) 472 | session.dispatch(event: .doWork(.fetch(operation))) 473 | 474 | wait(for: [expectation], timeout: 1) 475 | } 476 | 477 | // MARK: - CKRecord Extensions 478 | 479 | func testCKRecordRemoveAllFields() { 480 | let record = makeTestRecord() 481 | record["hello"] = "world" 482 | record.encryptedValues["secrets"] = "👻" 483 | 484 | record.removeAllFields() 485 | 486 | XCTAssertEqual(record["hello"] as! String?, nil) 487 | XCTAssertEqual(record["secrets"] as! String?, nil) 488 | } 489 | 490 | func testCKRecordCopyFields() { 491 | let recordA = makeTestRecord() 492 | recordA["hello"] = "world" 493 | recordA.encryptedValues["secrets"] = "👻" 494 | 495 | let recordB = makeTestRecord() 496 | recordB["hello"] = "🌎" 497 | recordA.encryptedValues["secrets"] = "💀" 498 | 499 | recordA.copyFields(from: recordB) 500 | 501 | XCTAssertEqual(recordA["hello"] as! String?, "🌎") 502 | XCTAssertEqual(recordA.encryptedValues["secrets"] as! String?, "💀") 503 | } 504 | } 505 | -------------------------------------------------------------------------------- /Tests/CloudSyncSessionTests/SyncStateTests.swift: -------------------------------------------------------------------------------- 1 | import CloudKit 2 | @testable import CloudSyncSession 3 | import XCTest 4 | 5 | private var testZoneID = CKRecordZone.ID( 6 | zoneName: "test", 7 | ownerName: CKCurrentUserDefaultName 8 | ) 9 | 10 | final class SyncStateTests: XCTestCase { 11 | func testIgnoresWorkIfUnknownAccountStatus() { 12 | var state = SyncState() 13 | 14 | state = state.reduce(event: .doWork(.modify(ModifyOperation(records: [], recordIDsToDelete: [], checkpointID: nil, userInfo: nil)))) 15 | 16 | XCTAssertNil(state.currentWork) 17 | } 18 | 19 | func testIgnoresWorkIfUnknownZoneStatus() { 20 | var state = SyncState() 21 | 22 | state = state.reduce(event: .accountStatusChanged(.available)) 23 | state = state.reduce(event: .doWork(.modify(ModifyOperation(records: [], recordIDsToDelete: [], checkpointID: nil, userInfo: nil)))) 24 | 25 | XCTAssertNil(state.currentWork) 26 | } 27 | 28 | func testStartsWorkIfKnownAccountStatusAndZoneCreated() { 29 | var state = SyncState() 30 | 31 | state = state.reduce(event: .accountStatusChanged(.available)) 32 | let createZoneWork = SyncWork.createZone(CreateZoneOperation(zoneID: testZoneID)) 33 | state = state.reduce(event: .workSuccess(createZoneWork, .createZone(true))) 34 | let createSubscriptionWork = SyncWork.createSubscription(CreateSubscriptionOperation(zoneID: testZoneID)) 35 | state = state.reduce(event: .workSuccess(createSubscriptionWork, .createSubscription(true))) 36 | state = state.reduce(event: .doWork(.modify(ModifyOperation(records: [], recordIDsToDelete: [], checkpointID: nil, userInfo: nil)))) 37 | 38 | XCTAssertNotNil(state.currentWork) 39 | } 40 | 41 | func testStartsModificiationsBeforeFetches() { 42 | var state = SyncState() 43 | 44 | state = state.reduce(event: .doWork(.modify(ModifyOperation(records: [], recordIDsToDelete: [], checkpointID: nil, userInfo: nil)))) 45 | state = state.reduce(event: .doWork(.modify(ModifyOperation(records: [], recordIDsToDelete: [], checkpointID: nil, userInfo: nil)))) 46 | state = state.reduce(event: .doWork(.fetch(FetchOperation(changeToken: nil)))) 47 | state = state.reduce(event: .doWork(.modify(ModifyOperation(records: [], recordIDsToDelete: [], checkpointID: nil, userInfo: nil)))) 48 | 49 | state = state.reduce(event: .accountStatusChanged(.available)) 50 | let createZoneWork = SyncWork.createZone(CreateZoneOperation(zoneID: testZoneID)) 51 | state = state.reduce(event: .workSuccess(createZoneWork, .createZone(true))) 52 | let createSubscriptionWork = SyncWork.createSubscription(CreateSubscriptionOperation(zoneID: testZoneID)) 53 | state = state.reduce(event: .workSuccess(createSubscriptionWork, .createSubscription(true))) 54 | 55 | XCTAssertEqual(state.operationMode, SyncState.OperationMode.modify) 56 | 57 | switch state.currentWork { 58 | case .fetch, .createZone, .createSubscription, nil: 59 | XCTFail() 60 | case .modify: 61 | break 62 | } 63 | } 64 | 65 | func testStartsFetchingIfNoModifications() { 66 | var state = SyncState() 67 | 68 | state = state.reduce(event: .doWork(.fetch(FetchOperation(changeToken: nil)))) 69 | 70 | state = state.reduce(event: .accountStatusChanged(.available)) 71 | let createZoneWork = SyncWork.createZone(CreateZoneOperation(zoneID: testZoneID)) 72 | state = state.reduce(event: .workSuccess(createZoneWork, .createZone(true))) 73 | let createSubscriptionWork = SyncWork.createSubscription(CreateSubscriptionOperation(zoneID: testZoneID)) 74 | state = state.reduce(event: .workSuccess(createSubscriptionWork, .createSubscription(true))) 75 | 76 | XCTAssertEqual(state.operationMode, SyncState.OperationMode.fetch) 77 | 78 | switch state.currentWork { 79 | case .modify, .createZone, .createSubscription, nil: 80 | XCTFail() 81 | case .fetch: 82 | break 83 | } 84 | } 85 | 86 | func testOperationModeResetsAfterAllWorkSuccess() { 87 | var state = SyncState() 88 | 89 | let modifyWork = SyncWork.modify(ModifyOperation(records: [], recordIDsToDelete: [], checkpointID: nil, userInfo: nil)) 90 | state = state.reduce(event: .doWork(modifyWork)) 91 | state = state.reduce(event: .workSuccess(modifyWork, .modify(ModifyOperation.Response(savedRecords: [], deletedRecordIDs: [])))) 92 | 93 | state = state.reduce(event: .accountStatusChanged(.available)) 94 | let createZoneWork = SyncWork.createZone(CreateZoneOperation(zoneID: testZoneID)) 95 | state = state.reduce(event: .workSuccess(createZoneWork, .createZone(true))) 96 | 97 | XCTAssertNil(state.operationMode) 98 | XCTAssertNil(state.currentWork) 99 | } 100 | 101 | func testStartsFetchingAfterModifications() { 102 | var state = SyncState() 103 | 104 | state = state.reduce(event: .doWork(.fetch(FetchOperation(changeToken: nil)))) 105 | 106 | let modifyWork = SyncWork.modify(ModifyOperation(records: [], recordIDsToDelete: [], checkpointID: nil, userInfo: nil)) 107 | state = state.reduce(event: .doWork(modifyWork)) 108 | state = state.reduce(event: .workSuccess(modifyWork, .modify(ModifyOperation.Response(savedRecords: [], deletedRecordIDs: [])))) 109 | 110 | state = state.reduce(event: .accountStatusChanged(.available)) 111 | let createZoneWork = SyncWork.createZone(CreateZoneOperation(zoneID: testZoneID)) 112 | state = state.reduce(event: .workSuccess(createZoneWork, .createZone(true))) 113 | let createSubscriptionWork = SyncWork.createSubscription(CreateSubscriptionOperation(zoneID: testZoneID)) 114 | state = state.reduce(event: .workSuccess(createSubscriptionWork, .createSubscription(true))) 115 | 116 | XCTAssertEqual(state.operationMode, SyncState.OperationMode.fetch) 117 | 118 | switch state.currentWork { 119 | case .modify, .createZone, .createSubscription, nil: 120 | XCTFail() 121 | case .fetch: 122 | break 123 | } 124 | } 125 | 126 | func testPopWork() { 127 | var state = SyncState() 128 | 129 | let work = SyncWork.fetch(FetchOperation(changeToken: nil)) 130 | state = state.reduce(event: .doWork(work)) 131 | state.popWork(work: work) 132 | 133 | XCTAssertEqual(state.fetchQueue.count, 0) 134 | } 135 | 136 | func testPopRetriedWork() { 137 | var state = SyncState() 138 | 139 | var work = SyncWork.fetch(FetchOperation(changeToken: nil)) 140 | work = work.retried 141 | state = state.reduce(event: .doWork(work)) 142 | state.popWork(work: work) 143 | 144 | XCTAssertEqual(state.fetchQueue.count, 0) 145 | } 146 | } 147 | --------------------------------------------------------------------------------