diff --git a/Sources/SQLiteData/CloudKit/Internal/DefaultNotificationCenter.swift b/Sources/SQLiteData/CloudKit/Internal/DefaultNotificationCenter.swift new file mode 100644 index 00000000..9fc612d8 --- /dev/null +++ b/Sources/SQLiteData/CloudKit/Internal/DefaultNotificationCenter.swift @@ -0,0 +1,16 @@ +#if canImport(UIKit) + import UIKit + + private enum DefaultNotificationCenterKey: DependencyKey { + 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 diff --git a/Sources/SQLiteData/CloudKit/Internal/MockSyncEngine.swift b/Sources/SQLiteData/CloudKit/Internal/MockSyncEngine.swift index 82d4819d..c71c7ea1 100644 --- a/Sources/SQLiteData/CloudKit/Internal/MockSyncEngine.swift +++ b/Sources/SQLiteData/CloudKit/Internal/MockSyncEngine.swift @@ -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 private let _fetchChangesScopes = LockIsolated<[CKSyncEngine.FetchChangesOptions.Scope]>([]) private let _acceptedShareMetadata = LockIsolated>([]) package init( database: MockCloudDatabase, - delegate: any SyncEngineDelegate, + parentSyncEngine: SyncEngine, state: MockSyncEngineState ) { self.database = database - self.delegate = delegate + self.parentSyncEngine = parentSyncEngine self._state = LockIsolated(state) } @@ -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? @@ -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.") + } + } +} diff --git a/Sources/SQLiteData/CloudKit/Internal/SyncEngineProtocol.swift b/Sources/SQLiteData/CloudKit/Internal/SyncEngineProtocol.swift index 99dc5569..6dd52194 100644 --- a/Sources/SQLiteData/CloudKit/Internal/SyncEngineProtocol.swift +++ b/Sources/SQLiteData/CloudKit/Internal/SyncEngineProtocol.swift @@ -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, *) diff --git a/Sources/SQLiteData/CloudKit/SyncEngine.swift b/Sources/SQLiteData/CloudKit/SyncEngine.swift index 60d88507..2de4ae84 100644 --- a/Sources/SQLiteData/CloudKit/SyncEngine.swift +++ b/Sources/SQLiteData/CloudKit/SyncEngine.swift @@ -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 { @@ -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. @@ -111,12 +116,12 @@ ( private: MockSyncEngine( database: privateDatabase, - delegate: syncEngine, + parentSyncEngine: syncEngine, state: MockSyncEngineState() ), shared: MockSyncEngine( database: sharedDatabase, - delegate: syncEngine, + parentSyncEngine: syncEngine, state: MockSyncEngineState() ) ) @@ -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 diff --git a/Tests/SQLiteDataTests/CloudKitTests/AppLifecycleTests.swift b/Tests/SQLiteDataTests/CloudKitTests/AppLifecycleTests.swift new file mode 100644 index 00000000..18a2183c --- /dev/null +++ b/Tests/SQLiteDataTests/CloudKitTests/AppLifecycleTests.swift @@ -0,0 +1,137 @@ +#if canImport(CloudKit) && canImport(UIKit) + import CloudKit + import CustomDump + import Foundation + import InlineSnapshotTesting + import SQLiteData + import SnapshotTestingCustomDump + import Testing + import SQLiteDataTestSupport + + import UIKit + + extension BaseCloudKitTests { + @MainActor + @Suite + final class AppLifecycleTests: BaseCloudKitTests, @unchecked Sendable { + @Dependency(\.defaultNotificationCenter) var defaultNotificationCenter + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func sendChangesOnBackground() async throws { + try await userDatabase.userWrite { db in + try db.seed { + RemindersList(id: 1, title: "Personal") + } + } + defaultNotificationCenter.post(name: UIScene.willDeactivateNotification, object: nil) + try await Task.sleep(for: .seconds(0.1)) + assertInlineSnapshot(of: container, as: .customDump) { + """ + MockCloudContainer( + privateCloudDatabase: MockCloudDatabase( + databaseScope: .private, + storage: [ + [0]: CKRecord( + recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__), + recordType: "remindersLists", + parent: nil, + share: nil, + id: 1, + title: "Personal" + ) + ] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [] + ) + ) + """ + } + } + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func sendSharedChanges() async throws { + let externalZone = CKRecordZone( + zoneID: CKRecordZone.ID( + zoneName: "external.zone", + ownerName: "external.owner" + ) + ) + try await syncEngine.modifyRecordZones(scope: .shared, saving: [externalZone]).notify() + + let remindersListRecord = CKRecord( + recordType: RemindersList.tableName, + recordID: RemindersList.recordID(for: 1, zoneID: externalZone.zoneID) + ) + remindersListRecord.setValue(1, forKey: "id", at: now) + remindersListRecord.setValue("Personal", forKey: "title", at: now) + let share = CKShare( + rootRecord: remindersListRecord, + shareID: CKRecord.ID( + recordName: "share-\(remindersListRecord.recordID.recordName)", + zoneID: remindersListRecord.recordID.zoneID + ) + ) + + try await syncEngine + .acceptShare( + metadata: ShareMetadata( + containerIdentifier: container.containerIdentifier!, + hierarchicalRootRecordID: remindersListRecord.recordID, + rootRecord: remindersListRecord, + share: share + ) + ) + + try await userDatabase.userWrite { db in + try db.seed { + Reminder(id: 1, title: "Get milk", remindersListID: 1) + } + } + + defaultNotificationCenter.post(name: UIScene.willDeactivateNotification, object: nil) + try await Task.sleep(for: .seconds(0.1)) + assertInlineSnapshot(of: container, as: .customDump) { + """ + MockCloudContainer( + privateCloudDatabase: MockCloudDatabase( + databaseScope: .private, + storage: [] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [ + [0]: CKRecord( + recordID: CKRecord.ID(share-1:remindersLists/external.zone/external.owner), + recordType: "cloudkit.share", + parent: nil, + share: nil + ), + [1]: CKRecord( + recordID: CKRecord.ID(1:reminders/external.zone/external.owner), + recordType: "reminders", + parent: CKReference(recordID: CKRecord.ID(1:remindersLists/external.zone/external.owner)), + share: nil, + id: 1, + isCompleted: 0, + remindersListID: 1, + title: "Get milk" + ), + [2]: CKRecord( + recordID: CKRecord.ID(1:remindersLists/external.zone/external.owner), + recordType: "remindersLists", + parent: nil, + share: CKReference(recordID: CKRecord.ID(share-1:remindersLists/external.zone/external.owner)), + id: 1, + title: "Personal" + ) + ] + ) + ) + """ + } + } + } + } +#endif diff --git a/Tests/SQLiteDataTests/Internal/BaseCloudKitTests.swift b/Tests/SQLiteDataTests/Internal/BaseCloudKitTests.swift index 659acd77..4989c864 100644 --- a/Tests/SQLiteDataTests/Internal/BaseCloudKitTests.swift +++ b/Tests/SQLiteDataTests/Internal/BaseCloudKitTests.swift @@ -158,12 +158,12 @@ extension SyncEngine { ( MockSyncEngine( database: container.privateCloudDatabase as! MockCloudDatabase, - delegate: syncEngine, + parentSyncEngine: syncEngine, state: MockSyncEngineState() ), MockSyncEngine( database: container.sharedCloudDatabase as! MockCloudDatabase, - delegate: syncEngine, + parentSyncEngine: syncEngine, state: MockSyncEngineState() ) ) diff --git a/Tests/SQLiteDataTests/Internal/CloudKitTestHelpers.swift b/Tests/SQLiteDataTests/Internal/CloudKitTestHelpers.swift index bb6f907d..0b924e71 100644 --- a/Tests/SQLiteDataTests/Internal/CloudKitTestHelpers.swift +++ b/Tests/SQLiteDataTests/Internal/CloudKitTestHelpers.swift @@ -40,7 +40,7 @@ extension SyncEngine { ) return ModifyRecordsCallback { - await syncEngine.delegate + await syncEngine.parentSyncEngine .handleEvent( .fetchedDatabaseChanges( modifications: saveResults.values.compactMap { try? $0.get().zoneID }, @@ -75,7 +75,7 @@ extension SyncEngine { ) return ModifyRecordsCallback { - await syncEngine.delegate.handleEvent( + await syncEngine.parentSyncEngine.handleEvent( .fetchedRecordZoneChanges( modifications: saveResults.values.compactMap { try? $0.get() }, deletions: deleteResults.compactMap { recordID, result in @@ -93,115 +93,6 @@ extension SyncEngine { } } - 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 { - Issue.record( - "Processing empty set of record zone changes.", - sourceLocation: SourceLocation.init( - fileID: String(describing: fileID), - filePath: String(describing: filePath), - line: Int(line), - column: Int(column) - ) - ) - return - } - guard try await container.accountStatus() == .available - else { - Issue.record( - """ - User must be logged in to process pending changes. - """, - sourceLocation: SourceLocation.init( - fileID: String(describing: fileID), - filePath: String(describing: filePath), - line: Int(line), - column: Int(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.delegate - .handleEvent( - .sentRecordZoneChanges( - savedRecords: savedRecords, - failedRecordSaves: failedRecordSaves, - deletedRecordIDs: deletedRecordIDs, - failedRecordDeletes: failedRecordDeletes - ), - syncEngine: syncEngine - ) - } - func processPendingDatabaseChanges( scope: CKDatabase.Scope, fileID: StaticString = #fileID, @@ -214,7 +105,7 @@ extension SyncEngine { else { Issue.record( "Processing empty set of database changes.", - sourceLocation: SourceLocation.init( + sourceLocation: SourceLocation( fileID: String(describing: fileID), filePath: String(describing: filePath), line: Int(line), @@ -229,7 +120,7 @@ extension SyncEngine { """ User must be logged in to process pending changes. """, - sourceLocation: SourceLocation.init( + sourceLocation: SourceLocation( fileID: String(describing: fileID), filePath: String(describing: filePath), line: Int(line), @@ -287,7 +178,7 @@ extension SyncEngine { syncEngine.state.remove(pendingDatabaseChanges: savedZones.map { .saveZone($0) }) syncEngine.state.remove(pendingDatabaseChanges: deletedZoneIDs.map { .deleteZone($0) }) - await syncEngine.delegate + await syncEngine.parentSyncEngine .handleEvent( .sentDatabaseChanges( savedZones: savedZones, @@ -298,17 +189,4 @@ extension SyncEngine { syncEngine: syncEngine ) } - - private func syncEngine(for scope: CKDatabase.Scope) -> MockSyncEngine { - switch scope { - case .public: - fatalError("Public database not supported in tests.") - case .private: - `private` - case .shared: - shared - @unknown default: - fatalError("Unknown database scope not supported in tests.") - } - } }