├── .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 | 
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 |
--------------------------------------------------------------------------------