Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
#if canImport(UIKit)
import UIKit

private enum DefaultNotificationCenterKey: DependencyKey {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we want to release a new version of Dependencies and use that instead, or is it still work quarantining here?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

just to keep our dependencies looser we might as well use this local one for a bit.

static let liveValue = NotificationCenter.default
static var testValue: NotificationCenter {
NotificationCenter()
}
}
extension DependencyValues {
package var defaultNotificationCenter: NotificationCenter {
get { self[DefaultNotificationCenterKey.self] }
set { self[DefaultNotificationCenterKey.self] = newValue }
}
}
#endif
143 changes: 139 additions & 4 deletions Sources/SQLiteData/CloudKit/Internal/MockSyncEngine.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,18 +5,18 @@ import OrderedCollections
@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *)
package final class MockSyncEngine: SyncEngineProtocol {
package let database: MockCloudDatabase
package let delegate: any SyncEngineDelegate
package let parentSyncEngine: SyncEngine
private let _state: LockIsolated<MockSyncEngineState>
private let _fetchChangesScopes = LockIsolated<[CKSyncEngine.FetchChangesOptions.Scope]>([])
private let _acceptedShareMetadata = LockIsolated<Set<ShareMetadata>>([])

package init(
database: MockCloudDatabase,
delegate: any SyncEngineDelegate,
parentSyncEngine: SyncEngine,
state: MockSyncEngineState
) {
self.database = database
self.delegate = delegate
self.parentSyncEngine = parentSyncEngine
self._state = LockIsolated(state)
}

Expand Down Expand Up @@ -50,12 +50,19 @@ package final class MockSyncEngine: SyncEngineProtocol {
($0[zoneID]?.values).map { Array($0) } ?? []
}
}
await delegate.handleEvent(
await parentSyncEngine.handleEvent(
.fetchedRecordZoneChanges(modifications: records, deletions: []),
syncEngine: self
)
}

package func sendChanges(_ options: CKSyncEngine.SendChangesOptions) async throws {
guard
!parentSyncEngine.syncEngine(for: database.databaseScope).state.pendingRecordZoneChanges.isEmpty
else { return }
try await parentSyncEngine.processPendingRecordZoneChanges(scope: database.databaseScope)
}

package func recordZoneChangeBatch(
pendingChanges: [CKSyncEngine.PendingRecordZoneChange],
recordProvider: @Sendable (CKRecord.ID) async -> CKRecord?
Expand Down Expand Up @@ -286,3 +293,131 @@ private func comparePendingDatabaseChange(
false
}
}

@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *)
extension SyncEngine {
package func processPendingRecordZoneChanges(
options: CKSyncEngine.SendChangesOptions = CKSyncEngine.SendChangesOptions(),
scope: CKDatabase.Scope,
fileID: StaticString = #fileID,
filePath: StaticString = #filePath,
line: UInt = #line,
column: UInt = #column
) async throws {
let syncEngine = syncEngine(for: scope)
guard !syncEngine.state.pendingRecordZoneChanges.isEmpty
else {
reportIssue(
"Processing empty set of record zone changes.",
fileID: fileID,
filePath: filePath,
line: line,
column: column
)
return
}
guard try await container.accountStatus() == .available
else {
reportIssue(
"""
User must be logged in to process pending changes.
""",
fileID: fileID,
filePath: filePath,
line: line,
column: column
)
return
}

let batch = await nextRecordZoneChangeBatch(
reason: .scheduled,
options: options,
syncEngine: {
switch scope {
case .private:
self.private
case .shared:
self.shared
case .public:
fatalError("Public database not supported in tests.")
@unknown default:
fatalError("Unknown database scope not supported in tests.")
}
}()
)
guard let batch
else { return }

let (saveResults, deleteResults) = try syncEngine.database.modifyRecords(
saving: batch.recordsToSave,
deleting: batch.recordIDsToDelete,
savePolicy: .ifServerRecordUnchanged,
atomically: true
)

var savedRecords: [CKRecord] = []
var failedRecordSaves: [(record: CKRecord, error: CKError)] = []
var deletedRecordIDs: [CKRecord.ID] = []
var failedRecordDeletes: [CKRecord.ID: CKError] = [:]
for (recordID, result) in saveResults {
switch result {
case .success(let record):
savedRecords.append(record)
case .failure(let error as CKError):
guard let record = batch.recordsToSave.first(where: { $0.recordID == recordID })
else { fatalError("\(recordID.debugDescription) not found in pending changes") }
failedRecordSaves.append((record: record, error: error))
case .failure:
fatalError("Mocks should only raise 'CKError' values.")
}
}
for (recordID, result) in deleteResults {
switch result {
case .success:
deletedRecordIDs.append(recordID)
case .failure(let error as CKError):
failedRecordDeletes[recordID] = error
case .failure:
fatalError("Mocks should only raise 'CKError' values.")
}
}
syncEngine.state.remove(
pendingRecordZoneChanges: savedRecords.map { .saveRecord($0.recordID) }
)
syncEngine.state.remove(
pendingRecordZoneChanges: deletedRecordIDs.map { .deleteRecord($0) }
)

await syncEngine.parentSyncEngine
.handleEvent(
.sentRecordZoneChanges(
savedRecords: savedRecords,
failedRecordSaves: failedRecordSaves,
deletedRecordIDs: deletedRecordIDs,
failedRecordDeletes: failedRecordDeletes
),
syncEngine: syncEngine
)
}

package var `private`: MockSyncEngine {
syncEngines.private as! MockSyncEngine
}
package var shared: MockSyncEngine {
syncEngines.shared as! MockSyncEngine
}

package func syncEngine(for scope: CKDatabase.Scope) -> MockSyncEngine {
switch scope {
case .public:
fatalError("Public database not supported in sync engines.")
case .private:
`private`
case .shared:
shared
@unknown default:
fatalError("Unknown database scope not supported in sync engines.")
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
pendingChanges: [CKSyncEngine.PendingRecordZoneChange],
recordProvider: @Sendable (CKRecord.ID) async -> CKRecord?
) async -> CKSyncEngine.RecordZoneChangeBatch?
func sendChanges(_ options: CKSyncEngine.SendChangesOptions) async throws
}

@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *)
Expand Down
37 changes: 35 additions & 2 deletions Sources/SQLiteData/CloudKit/SyncEngine.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@
import StructuredQueriesCore
import SwiftData

#if canImport(UIKit)
import UIKit
#endif

/// An object that manages the synchronization of local and remote SQLite data.
@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *)
public final class SyncEngine: Observable, Sendable {
Expand All @@ -29,6 +33,7 @@
package let container: any CloudContainer
let dataManager = Dependency(\.dataManager)
private let observationRegistrar = ObservationRegistrar()
private let notificationsObserver = LockIsolated<(any NSObjectProtocol)?>(nil)

/// The error message used when a write occurs to a record for which the current user
/// does not have permission.
Expand Down Expand Up @@ -111,12 +116,12 @@
(
private: MockSyncEngine(
database: privateDatabase,
delegate: syncEngine,
parentSyncEngine: syncEngine,
state: MockSyncEngineState()
),
shared: MockSyncEngine(
database: sharedDatabase,
delegate: syncEngine,
parentSyncEngine: syncEngine,
state: MockSyncEngineState()
)
)
Expand Down Expand Up @@ -248,9 +253,37 @@
tables: allTables,
tablesByName: tablesByName
)
#if canImport(UIKit)
@Dependency(\.defaultNotificationCenter) var defaultNotificationCenter
notificationsObserver.withValue {
$0 = defaultNotificationCenter.addObserver(
forName: UIScene.willDeactivateNotification,
object: nil,
queue: nil
) { [syncEngines] _ in
Task { @MainActor in
let taskIdentifier = UIApplication.shared.beginBackgroundTask()
defer { UIApplication.shared.endBackgroundTask(taskIdentifier) }
let (privateSyncEngine, sharedSyncEngine) = syncEngines.withValue {
($0.private, $0.shared)
}
try await privateSyncEngine?.sendChanges(CKSyncEngine.SendChangesOptions())
try await sharedSyncEngine?.sendChanges(CKSyncEngine.SendChangesOptions())
}
}
}
#endif
try validateSchema()
}

deinit {
notificationsObserver.withValue {
guard let observer = $0
else { return }
NotificationCenter.default.removeObserver(observer)
}
}

nonisolated package func setUpSyncEngine() throws {
let migrator = metadatabaseMigrator()
#if DEBUG
Expand Down
Loading