diff --git a/Examples/Examples.xcodeproj/project.pbxproj b/Examples/Examples.xcodeproj/project.pbxproj index de94ce25..e36be4b8 100644 --- a/Examples/Examples.xcodeproj/project.pbxproj +++ b/Examples/Examples.xcodeproj/project.pbxproj @@ -831,6 +831,7 @@ DEVELOPMENT_TEAM = VFRXY8HC3H; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = Reminders/Info.plist; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES; @@ -860,6 +861,7 @@ DEVELOPMENT_TEAM = VFRXY8HC3H; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = Reminders/Info.plist; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES; diff --git a/Examples/Reminders/RemindersLists.swift b/Examples/Reminders/RemindersLists.swift index aa38ff5d..ccd2580d 100644 --- a/Examples/Reminders/RemindersLists.swift +++ b/Examples/Reminders/RemindersLists.swift @@ -12,7 +12,8 @@ class RemindersListsModel { RemindersList .group(by: \.id) .order(by: \.position) - .leftJoin(Reminder.all) { $0.id.eq($1.remindersListID) && !$1.isCompleted + .leftJoin(Reminder.all) { + $0.id.eq($1.remindersListID) && !$1.isCompleted } .leftJoin(SyncMetadata.all) { $0.recordName.eq($2.recordName) } .select { @@ -131,7 +132,7 @@ class RemindersListsModel { let ids = Array(ids.enumerated()) let (first, rest) = (ids.first!, ids.dropFirst()) $0.position = - rest + rest .reduce(Case($0.id).when(first.element, then: first.offset)) { cases, id in cases.when(id.element, then: id.offset) } @@ -143,13 +144,13 @@ class RemindersListsModel { } #if DEBUG - func seedDatabaseButtonTapped() { - withErrorReporting { - try database.write { db in - try db.seedSampleData() + func seedDatabaseButtonTapped() { + withErrorReporting { + try database.write { db in + try db.seedSampleData() + } } } - } #endif @CasePathable @@ -191,6 +192,8 @@ class RemindersListsModel { struct RemindersListsView: View { @Bindable var model: RemindersListsModel + @State var id = UUID() + @Dependency(\.defaultSyncEngine) var syncEngine var body: some View { List { @@ -305,7 +308,7 @@ struct RemindersListsView: View { .listStyle(.insetGrouped) .toolbar { #if DEBUG - ToolbarItem(placement: .automatic) { + ToolbarItem(placement: .automatic) { Menu { Button { model.seedDatabaseButtonTapped() @@ -313,6 +316,22 @@ struct RemindersListsView: View { Text("Seed data") Image(systemName: "leaf") } + Button { + if syncEngine.isRunning { + syncEngine.stop() + id = UUID() + } else { + Task { + await withErrorReporting { + try await syncEngine.start() + } + id = UUID() + } + } + } label: { + Text("\(syncEngine.isRunning ? "Stop" : "Start") synchronizing") + Image(systemName: syncEngine.isRunning ? "stop" : "play") + } } label: { Image(systemName: "ellipsis.circle") } @@ -368,6 +387,7 @@ struct RemindersListsView: View { .navigationDestination(item: $model.destination.detail) { detailModel in RemindersDetailView(model: detailModel) } + .id(id) } } diff --git a/Sources/SharingGRDBCore/CloudKit/CloudKit+StructuredQueries.swift b/Sources/SharingGRDBCore/CloudKit/CloudKit+StructuredQueries.swift index 40448072..726609fe 100644 --- a/Sources/SharingGRDBCore/CloudKit/CloudKit+StructuredQueries.swift +++ b/Sources/SharingGRDBCore/CloudKit/CloudKit+StructuredQueries.swift @@ -36,9 +36,7 @@ public struct _SystemFieldsRepresentation: QueryBindable, Quer } public init(decoder: inout some StructuredQueriesCore.QueryDecoder) throws { - guard let data = try Data?(decoder: &decoder) else { - throw QueryDecodingError.missingRequiredColumn - } + let data = try Data(decoder: &decoder) let coder = try NSKeyedUnarchiver(forReadingFrom: data) coder.requiresSecureCoding = true guard let queryOutput = Record(coder: coder) else { @@ -71,9 +69,7 @@ package struct _AllFieldsRepresentation: QueryBindable, QueryR } package init(decoder: inout some StructuredQueriesCore.QueryDecoder) throws { - guard let data = try Data?(decoder: &decoder) else { - throw QueryDecodingError.missingRequiredColumn - } + let data = try Data(decoder: &decoder) let coder = try NSKeyedUnarchiver(forReadingFrom: data) coder.requiresSecureCoding = true guard let queryOutput = Record(coder: coder) else { @@ -166,7 +162,7 @@ extension CKRecord { let asset = CKAsset(fileURL: URL(hash: newValue)) guard let fileURL = asset.fileURL, (self[key] as? CKAsset)?.fileURL != fileURL else { return false } - withErrorReporting { + withErrorReporting(.sqliteDataCloudKitFailure) { try dataManager.save(Data(newValue), to: fileURL) } self[key] = asset diff --git a/Sources/SharingGRDBCore/CloudKit/CloudKitSharing.swift b/Sources/SharingGRDBCore/CloudKit/CloudKitSharing.swift index 50dd1928..a74e54b5 100644 --- a/Sources/SharingGRDBCore/CloudKit/CloudKitSharing.swift +++ b/Sources/SharingGRDBCore/CloudKit/CloudKitSharing.swift @@ -262,7 +262,7 @@ public func cloudSharingControllerDidStopSharing(_ csc: UICloudSharingController) { @Dependency(\.defaultSyncEngine) var syncEngine - withErrorReporting { + withErrorReporting(.sqliteDataCloudKitFailure) { try syncEngine.deleteShare(recordID: share.recordID) } didStopSharing() diff --git a/Sources/SharingGRDBCore/CloudKit/Metadatabase.swift b/Sources/SharingGRDBCore/CloudKit/Metadatabase.swift index fc4e2e90..e728a8bf 100644 --- a/Sources/SharingGRDBCore/CloudKit/Metadatabase.swift +++ b/Sources/SharingGRDBCore/CloudKit/Metadatabase.swift @@ -116,6 +116,14 @@ func defaultMetadatabase( ) .execute(db) } + migrator.registerMigration("Create PendingRecodZoneChanges Table") { db in + try SQLQueryExpression(""" + CREATE TABLE IF NOT EXISTS "\(raw: .sqliteDataCloudKitSchemaName)_pendingRecordZoneChanges" ( + "pendingRecordZoneChange" BLOB NOT NULL + ) STRICT + """) + .execute(db) + } try migrator.migrate(metadatabase) return metadatabase } diff --git a/Sources/SharingGRDBCore/CloudKit/PendingRecordZoneChange+MacroExpansion.swift b/Sources/SharingGRDBCore/CloudKit/PendingRecordZoneChange+MacroExpansion.swift new file mode 100644 index 00000000..6ea13bc7 --- /dev/null +++ b/Sources/SharingGRDBCore/CloudKit/PendingRecordZoneChange+MacroExpansion.swift @@ -0,0 +1,41 @@ +#if canImport(CloudKit) + import CloudKit + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + extension PendingRecordZoneChange { + public nonisolated struct TableColumns: StructuredQueriesCore.TableDefinition { + public typealias QueryValue = PendingRecordZoneChange + public let pendingRecordZoneChange = StructuredQueriesCore.TableColumn< + QueryValue, CKSyncEngine.PendingRecordZoneChange.DataRepresentation + >("pendingRecordZoneChange", keyPath: \QueryValue.pendingRecordZoneChange) + public static var allColumns: [any StructuredQueriesCore.TableColumnExpression] { + [QueryValue.columns.pendingRecordZoneChange] + } + public static var writableColumns: [any StructuredQueriesCore.WritableTableColumnExpression] { + [QueryValue.columns.pendingRecordZoneChange] + } + public var queryFragment: QueryFragment { + "\(self.pendingRecordZoneChange)" + } + } + } + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + nonisolated extension PendingRecordZoneChange: StructuredQueriesCore.Table { + public nonisolated static var columns: TableColumns { + TableColumns() + } + public nonisolated static var tableName: String { + "sqlitedata_icloud_pendingRecordZoneChanges" + } + public nonisolated init(decoder: inout some StructuredQueriesCore.QueryDecoder) throws { + let pendingRecordZoneChange = try decoder.decode( + CKSyncEngine.PendingRecordZoneChange.DataRepresentation.self + ) + guard let pendingRecordZoneChange else { + throw QueryDecodingError.missingRequiredColumn + } + self.pendingRecordZoneChange = pendingRecordZoneChange + } + } +#endif diff --git a/Sources/SharingGRDBCore/CloudKit/PendingRecordZoneChange.swift b/Sources/SharingGRDBCore/CloudKit/PendingRecordZoneChange.swift new file mode 100644 index 00000000..7db94d26 --- /dev/null +++ b/Sources/SharingGRDBCore/CloudKit/PendingRecordZoneChange.swift @@ -0,0 +1,64 @@ +#if canImport(CloudKit) + import CloudKit + + // @Table("\(String.sqliteDataCloudKitSchemaName)_pendingRecordZoneChanges") + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + package struct PendingRecordZoneChange { + // @Column(as: CKSyncEngine.PendingRecordZoneChange.DataRepresentation.self) + package let pendingRecordZoneChange: CKSyncEngine.PendingRecordZoneChange + } + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + extension PendingRecordZoneChange { + package init(_ pendingRecordZoneChange: CKSyncEngine.PendingRecordZoneChange) { + self.pendingRecordZoneChange = pendingRecordZoneChange + } + } + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + extension CKSyncEngine.PendingRecordZoneChange { + package struct DataRepresentation: QueryBindable, QueryRepresentable { + package var queryOutput: CKSyncEngine.PendingRecordZoneChange + + package init(queryOutput: CKSyncEngine.PendingRecordZoneChange) { + self.queryOutput = queryOutput + } + + package var queryBinding: StructuredQueriesCore.QueryBinding { + let archiver = NSKeyedArchiver(requiringSecureCoding: true) + switch queryOutput { + case .saveRecord(let recordID): + recordID.encode(with: archiver) + archiver.encode("saveRecord", forKey: "changeType") + case .deleteRecord(let recordID): + recordID.encode(with: archiver) + archiver.encode("deleteRecord", forKey: "changeType") + @unknown default: + return .invalid(BindingError()) + } + return archiver.encodedData.queryBinding + } + + package init(decoder: inout some StructuredQueriesCore.QueryDecoder) throws { + let data = try Data(decoder: &decoder) + let coder = try NSKeyedUnarchiver(forReadingFrom: data) + coder.requiresSecureCoding = true + guard let recordID = CKRecord.ID(coder: coder) else { + throw DecodingError() + } + let changeType = coder.decodeObject(of: NSString.self, forKey: "changeType") as? String + switch changeType { + case "saveRecord": + self.init(queryOutput: .saveRecord(recordID)) + case "deleteRecord": + self.init(queryOutput: .deleteRecord(recordID)) + default: + throw DecodingError() + } + } + } + + private struct DecodingError: Error {} + private struct BindingError: Error {} + } +#endif diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift index 8d98538e..b032f5ca 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift @@ -33,6 +33,7 @@ privateTables: repeat (each T2).Type, containerIdentifier: String? = nil, defaultZone: CKRecordZone = CKRecordZone(zoneName: "co.pointfree.SQLiteData.defaultZone"), + startImmediately: Bool = true, logger: Logger = isTesting ? Logger(.disabled) : Logger(subsystem: "SQLiteData", category: "CloudKit") ) throws @@ -60,7 +61,7 @@ let sharedDatabase = MockCloudDatabase(databaseScope: .shared) try self.init( container: MockCloudContainer( - containerIdentifier: containerIdentifier ?? "co.pointfree.sqlitedata-icloud.testing", + containerIdentifier: containerIdentifier ?? "iCloud.co.pointfree.SQLiteData.Tests", privateCloudDatabase: privateDatabase, sharedCloudDatabase: sharedDatabase ), @@ -84,10 +85,10 @@ tables: allTables, privateTables: allPrivateTables ) - _ = try setUpSyncEngine( - userDatabase: userDatabase, - metadatabase: metadatabase - ) + try setUpSyncEngine() + if startImmediately { + _ = try start() + } return } @@ -138,10 +139,10 @@ tables: allTables, privateTables: allPrivateTables ) - _ = try setUpSyncEngine( - userDatabase: userDatabase, - metadatabase: metadatabase - ) + try setUpSyncEngine() + if startImmediately { + _ = try start() + } } package init( @@ -208,14 +209,7 @@ @TaskLocal package static var _isSynchronizingChanges = false - package func setUpSyncEngine() async throws { - try await setUpSyncEngine(userDatabase: userDatabase, metadatabase: metadatabase)?.value - } - - nonisolated package func setUpSyncEngine( - userDatabase: UserDatabase, - metadatabase: any DatabaseReader - ) throws -> Task? { + nonisolated package func setUpSyncEngine() throws { try userDatabase.write { db in let attachedMetadatabasePath: String? = try SQLQueryExpression( @@ -238,7 +232,7 @@ ), debugDescription: """ Metadatabase attached in 'prepareDatabase' does not match metadatabase prepared in \ - 'SyncEngine.init'. Are the CloudKit container identifiers different? + 'SyncEngine.init'. Are different CloudKit container identifiers being provided? """ ) } @@ -269,7 +263,27 @@ ) } } + } + public func start() async throws { + try await start().value + } + + public func stop() { + guard isRunning else { return } + syncEngines.withValue { + $0 = SyncEngines() + } + } + + public var isRunning: Bool { + syncEngines.withValue { + $0.isRunning + } + } + + private func start() throws -> Task { + guard !isRunning else { return Task {} } let (privateSyncEngine, sharedSyncEngine) = defaultSyncEngines(metadatabase, self) syncEngines.withValue { $0 = SyncEngines( @@ -337,6 +351,28 @@ previousRecordTypeByTableName: [String: RecordType], currentRecordTypeByTableName: [String: RecordType] ) async throws { + let pendingRecordZoneChanges = try await userDatabase.read { db in + try PendingRecordZoneChange + .select(\.pendingRecordZoneChange) + .fetchAll(db) + } + let changesByIsPrivate = Dictionary.init(grouping: pendingRecordZoneChanges) { + switch $0 { + case .deleteRecord(let recordID), .saveRecord(let recordID): + recordID.zoneID.ownerName == CKCurrentUserDefaultName + @unknown default: + false + } + } + syncEngines.withValue { + $0.private?.state.add(pendingRecordZoneChanges: changesByIsPrivate[true] ?? []) + $0.shared?.state.add(pendingRecordZoneChanges: changesByIsPrivate[false] ?? []) + } + + try await userDatabase.write { db in + try PendingRecordZoneChange.delete().execute(db) + } + let newTableNames = currentRecordTypeByTableName.keys.filter { tableName in previousRecordTypeByTableName[tableName] == nil } @@ -402,9 +438,9 @@ } } - package func tearDownSyncEngine() async throws { - try await userDatabase.write { db in - for table in self.tables { + package func tearDownSyncEngine() throws { + try userDatabase.write { db in + for table in tables { try table.dropTriggers(db: db) } for trigger in SyncMetadata.callbackTriggers.reversed() { @@ -415,8 +451,6 @@ db.remove(function: .didUpdate(syncEngine: self)) db.remove(function: .syncEngineIsSynchronizingChanges) db.remove(function: .datetime) - } - try await userDatabase.write { db in // TODO: Do an `.erase()` + re-migrate try SyncMetadata.delete().execute(db) try RecordType.delete().execute(db) @@ -425,8 +459,8 @@ } } - func deleteLocalData() async throws { - try await tearDownSyncEngine() + func deleteLocalData() throws { + try tearDownSyncEngine() withErrorReporting(.sqliteDataCloudKitFailure) { try userDatabase.write { db in for table in tables { @@ -439,31 +473,38 @@ } } } - try await setUpSyncEngine() + try setUpSyncEngine() } func didUpdate(recordName: String, zoneID: CKRecordZone.ID?) { let zoneID = zoneID ?? defaultZone.zoneID + let change = CKSyncEngine.PendingRecordZoneChange.saveRecord( + CKRecord.ID( + recordName: recordName, + zoneID: zoneID + ) + ) + guard isRunning else { + Task { + await withErrorReporting(.sqliteDataCloudKitFailure) { + try await userDatabase.write { db in + try PendingRecordZoneChange + .insert { PendingRecordZoneChange(change) } + .execute(db) + } + } + } + return + } + let syncEngine = self.syncEngines.withValue { zoneID.ownerName == CKCurrentUserDefaultName ? $0.private : $0.shared } - syncEngine?.state.add( - pendingRecordZoneChanges: [ - .saveRecord( - CKRecord.ID( - recordName: recordName, - zoneID: zoneID - ) - ) - ] - ) + syncEngine?.state.add(pendingRecordZoneChanges: [change]) } func didDelete(recordName: String, zoneID: CKRecordZone.ID?, share: CKShare?) { let zoneID = zoneID ?? defaultZone.zoneID - let syncEngine = self.syncEngines.withValue { - zoneID.ownerName == CKCurrentUserDefaultName ? $0.private : $0.shared - } var changes: [CKSyncEngine.PendingRecordZoneChange] = [ .deleteRecord( CKRecord.ID( @@ -475,6 +516,22 @@ if let share { changes.append(.deleteRecord(share.recordID)) } + guard isRunning else { + Task { [changes] in + await withErrorReporting(.sqliteDataCloudKitFailure) { + try await userDatabase.write { db in + try PendingRecordZoneChange + .insert { changes.map { PendingRecordZoneChange($0) } } + .execute(db) + } + } + } + return + } + + let syncEngine = self.syncEngines.withValue { + zoneID.ownerName == CKCurrentUserDefaultName ? $0.private : $0.shared + } syncEngine?.state.add(pendingRecordZoneChanges: changes) } @@ -702,7 +759,7 @@ } func open(_: T.Type) async -> CKRecord? { let row = - withErrorReporting { + withErrorReporting(.sqliteDataCloudKitFailure) { try userDatabase.read { db in try T .where { @@ -774,7 +831,7 @@ let deletedRecordNames = deletedRecordIDs.map(\.recordName) let (metadataOfDeletions, recordsWithRoot): ([SyncMetadata], [RecordWithRoot]) = - await withErrorReporting { + await withErrorReporting(.sqliteDataCloudKitFailure) { try await userDatabase.read { db in let metadataOfDeletions = try SyncMetadata.where { $0.recordName.in(deletedRecordNames) @@ -836,7 +893,7 @@ ) } - await withErrorReporting { + await withErrorReporting(.sqliteDataCloudKitFailure) { try await userDatabase.write { db in try SyncMetadata .where { $0.recordName.in(deletedRecordNames) } @@ -866,8 +923,8 @@ } } case .signOut, .switchAccounts: - await withErrorReporting(.sqliteDataCloudKitFailure) { - try await deleteLocalData() + withErrorReporting(.sqliteDataCloudKitFailure) { + try deleteLocalData() } @unknown default: break @@ -1007,7 +1064,7 @@ open(table) } else if recordType == CKRecord.SystemType.share { for recordID in recordIDs { - withErrorReporting { + withErrorReporting(.sqliteDataCloudKitFailure) { try deleteShare(recordID: recordID) } } @@ -1085,7 +1142,7 @@ group.addTask { switch share { case .share(let share): - await withErrorReporting { + await withErrorReporting(.sqliteDataCloudKitFailure) { try await self.cacheShare(share) } case .reference(let shareReference): @@ -1093,7 +1150,7 @@ let record = try? await syncEngine.database.record(for: shareReference.recordID), let share = record as? CKShare else { return } - await withErrorReporting { + await withErrorReporting(.sqliteDataCloudKitFailure) { try await self.cacheShare(share) } } @@ -1121,7 +1178,7 @@ } for (failedRecord, error) in failedRecordSaves { func clearServerRecord() { - withErrorReporting { + withErrorReporting(.sqliteDataCloudKitFailure) { try userDatabase.write { db in try SyncMetadata .where { $0.recordName.eq(failedRecord.recordID.recordName) } @@ -1592,7 +1649,7 @@ extension String { package static let sqliteDataCloudKitSchemaName = "sqlitedata_icloud" - fileprivate static let sqliteDataCloudKitFailure = "SharingGRDB CloudKit Failure" + package static let sqliteDataCloudKitFailure = "SQLiteData CloudKit Failure" } @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) @@ -1603,8 +1660,8 @@ ) throws -> URL { guard let databaseURL = URL(string: databasePath) else { - struct InvalidDatabsePath: Error {} - throw InvalidDatabsePath() + struct InvalidDatabasePath: Error {} + throw InvalidDatabasePath() } guard !databaseURL.isInMemory else { @@ -1629,31 +1686,31 @@ @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) package struct SyncEngines { - let _private: (any SyncEngineProtocol)? - let _shared: (any SyncEngineProtocol)? + private let rawValue: (private: any SyncEngineProtocol, shared: any SyncEngineProtocol)? init() { - _private = nil - _shared = nil + rawValue = nil } init(private: any SyncEngineProtocol, shared: any SyncEngineProtocol) { - self._private = `private` - self._shared = shared + rawValue = (`private`, shared) + } + var isRunning: Bool { + rawValue != nil } package var `private`: (any SyncEngineProtocol)? { - guard let _private + guard let `private` = rawValue?.private else { reportIssue("Private sync engine has not been set.") return nil } - return _private + return `private` } package var `shared`: (any SyncEngineProtocol)? { - guard let _shared + guard let `shared` = rawValue?.shared else { reportIssue("Shared sync engine has not been set.") return nil } - return _shared + return `shared` } } diff --git a/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift b/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift index 06757dc4..197e0582 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift @@ -489,7 +489,7 @@ extension BaseCloudKitTests { let metadataCount = try SyncMetadata.count().fetchOne(db) ?? 0 #expect(metadataCount == 1) } - try await syncEngine.tearDownSyncEngine() + try syncEngine.tearDownSyncEngine() try await self.userDatabase.userRead { db in let metadataCount = try SyncMetadata.count().fetchOne(db) ?? 0 #expect(metadataCount == 0) @@ -498,8 +498,9 @@ extension BaseCloudKitTests { @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) @Test func tearDownAndReSetUp() async throws { - try await syncEngine.tearDownSyncEngine() - try await syncEngine.setUpSyncEngine() + try syncEngine.tearDownSyncEngine() + try syncEngine.setUpSyncEngine() + try await syncEngine.start() try await userDatabase.userWrite { db in try db.seed { @@ -538,6 +539,7 @@ extension BaseCloudKitTests { #expect(metadata != nil) } + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) @Test func addAndRemoveFunctions() async throws { let query = #sql( @@ -563,7 +565,7 @@ extension BaseCloudKitTests { ] """ } - try await syncEngine.tearDownSyncEngine() + try syncEngine.tearDownSyncEngine() assertInlineSnapshot( of: try { try userDatabase.userRead { try query.fetchAll($0) } }(), diff --git a/Tests/SharingGRDBTests/CloudKitTests/RecordTypeTests.swift b/Tests/SharingGRDBTests/CloudKitTests/RecordTypeTests.swift index 44272b58..14cddc17 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/RecordTypeTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/RecordTypeTests.swift @@ -453,7 +453,7 @@ extension BaseCloudKitTests { } @Test func tearDown() async throws { - try await syncEngine.tearDownSyncEngine() + try syncEngine.tearDownSyncEngine() try await userDatabase.userRead { db in try #expect(RecordType.all.fetchAll(db) == []) } @@ -463,8 +463,10 @@ extension BaseCloudKitTests { let recordTypes = try await userDatabase.userRead { db in try RecordType.all.fetchAll(db) } - try await syncEngine.tearDownSyncEngine() - try await syncEngine.setUpSyncEngine() + syncEngine.stop() + try syncEngine.tearDownSyncEngine() + try syncEngine.setUpSyncEngine() + try await syncEngine.start() let recordTypesAfterReSetup = try await userDatabase.userRead { db in try RecordType.all.fetchAll(db) } @@ -475,7 +477,8 @@ extension BaseCloudKitTests { let recordTypes = try await userDatabase.userRead { db in try RecordType.order(by: \.tableName).fetchAll(db) } - try await syncEngine.tearDownSyncEngine() + syncEngine.stop() + try syncEngine.tearDownSyncEngine() try await userDatabase.userWrite { db in try #sql( """ @@ -484,7 +487,8 @@ extension BaseCloudKitTests { ) .execute(db) } - try await syncEngine.setUpSyncEngine() + try syncEngine.setUpSyncEngine() + try await syncEngine.start() let recordTypesAfterMigration = try await userDatabase.userRead { db in try RecordType.order(by: \.tableName).fetchAll(db) diff --git a/Tests/SharingGRDBTests/CloudKitTests/SyncEngineLifecycleTests.swift b/Tests/SharingGRDBTests/CloudKitTests/SyncEngineLifecycleTests.swift new file mode 100644 index 00000000..0ef1aaf9 --- /dev/null +++ b/Tests/SharingGRDBTests/CloudKitTests/SyncEngineLifecycleTests.swift @@ -0,0 +1,436 @@ +import CloudKit +import DependenciesTestSupport +import InlineSnapshotTesting +import OrderedCollections +import SharingGRDB +import SnapshotTesting +import SnapshotTestingCustomDump +import Testing +import os + +extension BaseCloudKitTests { + @Suite + struct SyncEngineLifecycleTests { + @MainActor + @Suite + final class SyncEngineLifecycleTests_ImmediatelyStarted: BaseCloudKitTests, @unchecked Sendable + { + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func stopAndReStart() async throws { + syncEngine.stop() + + try await userDatabase.userWrite { db in + try db.seed { + RemindersList(id: 1, title: "Personal") + Reminder(id: 1, title: "Get milk", remindersListID: 1) + } + } + + try await userDatabase.userRead { db in + let remindersListMetadata = try #require(try RemindersList.metadata(for: 1).fetchOne(db)) + #expect(remindersListMetadata.lastKnownServerRecord == nil) + + let reminderMetadata = try #require(try Reminder.metadata(for: 1).fetchOne(db)) + #expect(reminderMetadata.lastKnownServerRecord == nil) + #expect(reminderMetadata.parentRecordName == RemindersList.recordName(for: 1)) + } + + assertInlineSnapshot(of: container, as: .customDump) { + """ + MockCloudContainer( + privateCloudDatabase: MockCloudDatabase( + databaseScope: .private, + storage: [] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [] + ) + ) + """ + } + + try await syncEngine.start() + try await syncEngine.processPendingRecordZoneChanges(scope: .private) + + assertInlineSnapshot(of: container, as: .customDump) { + """ + MockCloudContainer( + privateCloudDatabase: MockCloudDatabase( + databaseScope: .private, + storage: [ + [0]: CKRecord( + recordID: CKRecord.ID(1:reminders/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), + recordType: "reminders", + parent: CKReference(recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__)), + share: nil, + id: 1, + isCompleted: 0, + remindersListID: 1, + title: "Get milk" + ), + [1]: CKRecord( + recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), + recordType: "remindersLists", + parent: nil, + share: nil, + id: 1, + title: "Personal" + ) + ] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [] + ) + ) + """ + } + } + + @Test func writeStopDeleteStart() async throws { + try await userDatabase.userWrite { db in + try db.seed { + RemindersList(id: 1, title: "Personal") + } + } + try await syncEngine.processPendingRecordZoneChanges(scope: .private) + + syncEngine.stop() + + try await userDatabase.userWrite { db in + try RemindersList.find(1).delete().execute(db) + } + + try await syncEngine.start() + try await syncEngine.processPendingRecordZoneChanges(scope: .private) + + assertInlineSnapshot(of: container, as: .customDump) { + """ + MockCloudContainer( + privateCloudDatabase: MockCloudDatabase( + databaseScope: .private, + storage: [] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [] + ) + ) + """ + } + } + + @Test func addRemindersList_StopSyncEngine_EditTitle_StartSyncEngine() async throws { + try await userDatabase.userWrite { db in + try db.seed { + RemindersList(id: 1, title: "Personal") + } + } + try await syncEngine.processPendingRecordZoneChanges(scope: .private) + + syncEngine.stop() + + try await withDependencies { + $0.datetime.now.addTimeInterval(1) + } operation: { + try await userDatabase.userWrite { db in + try RemindersList.find(1).update { $0.title += "!" }.execute(db) + } + + try await userDatabase.read { db in + try #expect(PendingRecordZoneChange.all.fetchCount(db) == 1) + try #expect(RemindersList.find(1).fetchOne(db)?.title == "Personal!") + } + + try await syncEngine.start() + try await syncEngine.processPendingRecordZoneChanges(scope: .private) + + assertInlineSnapshot(of: container, as: .customDump) { + """ + MockCloudContainer( + privateCloudDatabase: MockCloudDatabase( + databaseScope: .private, + storage: [ + [0]: CKRecord( + recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), + recordType: "remindersLists", + parent: nil, + share: nil, + id: 1, + title: "Personal!" + ) + ] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [] + ) + ) + """ + } + + try await userDatabase.read { db in + try #expect(PendingRecordZoneChange.all.fetchCount(db) == 0) + } + } + } + + @Test func getSharedRecord_StopSyncEngine_WriteToSharedRecord_StartSyncing() async throws { + let externalZoneID = CKRecordZone.ID( + zoneName: "external.zone", + ownerName: "external.owner" + ) + let externalZone = CKRecordZone(zoneID: externalZoneID) + + let remindersListRecord = CKRecord( + recordType: RemindersList.tableName, + recordID: RemindersList.recordID(for: 1, zoneID: externalZoneID) + ) + remindersListRecord.setValue(1, forKey: "id", at: now) + remindersListRecord.setValue(false, forKey: "isCompleted", at: now) + remindersListRecord.setValue("Personal", forKey: "title", at: now) + + try await syncEngine.modifyRecordZones(scope: .shared, saving: [externalZone]).notify() + try await syncEngine.modifyRecords(scope: .shared, saving: [remindersListRecord]).notify() + + syncEngine.stop() + + try await withDependencies { + $0.datetime.now.addTimeInterval(60) + } operation: { + try await userDatabase.userWrite { db in + try db.seed { + Reminder(id: 1, title: "Get milk", remindersListID: 1) + } + } + } + + try await syncEngine.start() + try await syncEngine.processPendingRecordZoneChanges(scope: .shared) + + assertInlineSnapshot(of: container, as: .customDump) { + """ + MockCloudContainer( + privateCloudDatabase: MockCloudDatabase( + databaseScope: .private, + storage: [] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [ + [0]: 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" + ), + [1]: CKRecord( + recordID: CKRecord.ID(1:remindersLists/external.zone/external.owner), + recordType: "remindersLists", + parent: nil, + share: nil, + id: 1, + isCompleted: 0, + title: "Personal" + ) + ] + ) + ) + """ + } + } + + @Test func extenalSharedRecord_StopSyncEngine_DeleteSharedRecord_StartSyncEngine() + async throws + { + let externalZoneID = CKRecordZone.ID( + zoneName: "external.zone", + ownerName: "external.owner" + ) + let externalZone = CKRecordZone(zoneID: externalZoneID) + + let remindersListRecord = CKRecord( + recordType: RemindersList.tableName, + recordID: RemindersList.recordID(for: 1, zoneID: externalZoneID) + ) + remindersListRecord.setValue(1, forKey: "id", at: now) + remindersListRecord.setValue(false, forKey: "isCompleted", at: now) + remindersListRecord.setValue("Personal", forKey: "title", at: now) + + try await syncEngine.modifyRecordZones(scope: .shared, saving: [externalZone]).notify() + try await syncEngine.modifyRecords(scope: .shared, saving: [remindersListRecord]).notify() + + syncEngine.stop() + + try await userDatabase.userWrite { db in + try RemindersList.find(1).delete().execute(db) + } + + try await syncEngine.start() + try await syncEngine.processPendingRecordZoneChanges(scope: .shared) + + assertInlineSnapshot(of: container, as: .customDump) { + """ + MockCloudContainer( + privateCloudDatabase: MockCloudDatabase( + databaseScope: .private, + storage: [] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [] + ) + ) + """ + } + } + + @Test func sharedRecord_StopSyncEngine_DeleteSharedRecord_StartSyncEngine() async throws { + let remindersList = RemindersList(id: 1, title: "Personal") + try await userDatabase.userWrite { db in + try db.seed { remindersList } + } + try await syncEngine.processPendingRecordZoneChanges(scope: .private) + + let _ = try await syncEngine.share(record: remindersList, configure: { _ in }) + assertInlineSnapshot(of: container, as: .customDump) { + """ + MockCloudContainer( + privateCloudDatabase: MockCloudDatabase( + databaseScope: .private, + storage: [ + [0]: CKRecord( + recordID: CKRecord.ID(share-1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), + recordType: "cloudkit.share", + parent: nil, + share: nil + ), + [1]: CKRecord( + recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), + recordType: "remindersLists", + parent: nil, + share: CKReference(recordID: CKRecord.ID(share-1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__)) + ) + ] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [] + ) + ) + """ + } + + syncEngine.stop() + + try await userDatabase.userWrite { db in + try RemindersList.find(1).delete().execute(db) + } + + try await syncEngine.start() + try await syncEngine.processPendingRecordZoneChanges(scope: .private) + + assertInlineSnapshot(of: container, as: .customDump) { + """ + MockCloudContainer( + privateCloudDatabase: MockCloudDatabase( + databaseScope: .private, + storage: [] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [] + ) + ) + """ + } + } + } + + @MainActor + final class SyncEngineLifecycleTests_ImmediatelyStopped: BaseCloudKitTests, @unchecked Sendable + { + init() async throws { + try await super.init(startImmediately: false) + } + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func writeAndThenStart() async throws { + try await userDatabase.userWrite { db in + try db.seed { + RemindersList(id: 1, title: "Personal") + Reminder(id: 1, title: "Get milk", remindersListID: 1) + } + } + + try await userDatabase.userRead { db in + let remindersListMetadata = try #require(try RemindersList.metadata(for: 1).fetchOne(db)) + #expect(remindersListMetadata.lastKnownServerRecord == nil) + + let reminderMetadata = try #require(try Reminder.metadata(for: 1).fetchOne(db)) + #expect(reminderMetadata.lastKnownServerRecord == nil) + #expect(reminderMetadata.parentRecordName == RemindersList.recordName(for: 1)) + } + + assertInlineSnapshot(of: container, as: .customDump) { + """ + MockCloudContainer( + privateCloudDatabase: MockCloudDatabase( + databaseScope: .private, + storage: [] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [] + ) + ) + """ + } + + try await syncEngine.start() + await signIn() + try await syncEngine.processPendingDatabaseChanges(scope: .private) + try await syncEngine.processPendingRecordZoneChanges(scope: .private) + + assertInlineSnapshot(of: container, as: .customDump) { + """ + MockCloudContainer( + privateCloudDatabase: MockCloudDatabase( + databaseScope: .private, + storage: [ + [0]: CKRecord( + recordID: CKRecord.ID(1:reminders/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), + recordType: "reminders", + parent: CKReference(recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__)), + share: nil, + id: 1, + isCompleted: 0, + remindersListID: 1, + title: "Get milk" + ), + [1]: CKRecord( + recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), + recordType: "remindersLists", + parent: nil, + share: nil, + id: 1, + title: "Personal" + ) + ] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [] + ) + ) + """ + } + } + } + } +} diff --git a/Tests/SharingGRDBTests/CloudKitTests/SyncEngineTests.swift b/Tests/SharingGRDBTests/CloudKitTests/SyncEngineTests.swift index acf9d5be..3d83ba55 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/SyncEngineTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/SyncEngineTests.swift @@ -86,7 +86,7 @@ extension BaseCloudKitTests { attachedPath: "/private/tmp/.db.metadata-iCloud.co.pointfree.sqlite", syncEngineConfiguredPath: "/tmp/.db.metadata-iCloud.co.point-free.sqlite" ), - debugDescription: "Metadatabase attached in \'prepareDatabase\' does not match metadatabase prepared in \'SyncEngine.init\'. Are the CloudKit container identifiers different?" + debugDescription: "Metadatabase attached in \'prepareDatabase\' does not match metadatabase prepared in \'SyncEngine.init\'. Are different CloudKit container identifiers being provided?" ) """# } diff --git a/Tests/SharingGRDBTests/CloudKitTests/TriggerTests.swift b/Tests/SharingGRDBTests/CloudKitTests/TriggerTests.swift index a7baa5a1..e18de701 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/TriggerTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/TriggerTests.swift @@ -982,7 +982,7 @@ extension BaseCloudKitTests { } #endif - try await syncEngine.tearDownSyncEngine() + try syncEngine.tearDownSyncEngine() let triggersAfterTearDown = try await userDatabase.userWrite { db in try #sql("SELECT sql FROM sqlite_temp_master", as: String?.self).fetchAll(db) } @@ -992,7 +992,8 @@ extension BaseCloudKitTests { """ } - try await syncEngine.setUpSyncEngine() + try syncEngine.setUpSyncEngine() + try await syncEngine.start() let triggersAfterReSetUp = try await userDatabase.userWrite { db in try #sql("SELECT sql FROM sqlite_temp_master ORDER BY sql", as: String?.self).fetchAll(db) } diff --git a/Tests/SharingGRDBTests/Internal/BaseCloudKitTests.swift b/Tests/SharingGRDBTests/Internal/BaseCloudKitTests.swift index 00b5419b..195b2c9a 100644 --- a/Tests/SharingGRDBTests/Internal/BaseCloudKitTests.swift +++ b/Tests/SharingGRDBTests/Internal/BaseCloudKitTests.swift @@ -28,7 +28,8 @@ class BaseCloudKitTests: @unchecked Sendable { @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) init( accountStatus: CKAccountStatus = _AccountStatusScope.accountStatus, - setUpUserDatabase: @Sendable (UserDatabase) async throws -> Void = { _ in } + setUpUserDatabase: @Sendable (UserDatabase) async throws -> Void = { _ in }, + startImmediately: Bool = true ) async throws { let testContainerIdentifier = "iCloud.co.pointfree.Testing.\(UUID())" @@ -64,9 +65,10 @@ class BaseCloudKitTests: @unchecked Sendable { ], privateTables: [ RemindersListPrivate.self - ] + ], + startImmediately: startImmediately ) - if accountStatus == .available { + if startImmediately, accountStatus == .available { await syncEngine.handleEvent( .accountChange(changeType: .signIn(currentUser: currentUserRecordID)), syncEngine: syncEngine.private @@ -136,7 +138,8 @@ extension SyncEngine { container: any CloudContainer, userDatabase: UserDatabase, tables: [any PrimaryKeyedTable.Type], - privateTables: [any PrimaryKeyedTable.Type] = [] + privateTables: [any PrimaryKeyedTable.Type] = [], + startImmediately: Bool = true ) async throws { try self.init( container: container, @@ -160,7 +163,10 @@ extension SyncEngine { tables: tables, privateTables: privateTables ) - try await setUpSyncEngine(userDatabase: userDatabase, metadatabase: metadatabase)?.value + try setUpSyncEngine() + if startImmediately { + try await start() + } } }