From 3b75789e5764903ef712da759799978d048fa255 Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Tue, 26 Aug 2025 14:08:09 -0700 Subject: [PATCH 01/10] Add `SyncEngine.{start,stop}()` --- .../SharingGRDBCore/CloudKit/SyncEngine.swift | 60 ++++++++++--------- .../CloudKitTests/CloudKitTests.swift | 9 +-- .../CloudKitTests/RecordTypeTests.swift | 12 ++-- .../CloudKitTests/SyncEngineTests.swift | 2 +- .../CloudKitTests/TriggerTests.swift | 5 +- .../Internal/BaseCloudKitTests.swift | 3 +- 6 files changed, 50 insertions(+), 41 deletions(-) diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift index b0d8ea73..7784ec7f 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,19 @@ ) } } + } + + public func start() async throws { + try await start().value + } + public func stop() { + syncEngines.withValue { + $0 = SyncEngines() + } + } + + private func start() throws -> Task { let (privateSyncEngine, sharedSyncEngine) = defaultSyncEngines(metadatabase, self) syncEngines.withValue { $0 = SyncEngines( @@ -402,9 +408,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 +421,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 +429,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,7 +443,7 @@ } } } - try await setUpSyncEngine() + try setUpSyncEngine() } func didUpdate(recordName: String, zoneID: CKRecordZone.ID?) { @@ -866,8 +870,8 @@ } } case .signOut, .switchAccounts: - await withErrorReporting(.sqliteDataCloudKitFailure) { - try await deleteLocalData() + withErrorReporting(.sqliteDataCloudKitFailure) { + try deleteLocalData() } @unknown default: break diff --git a/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift b/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift index 06757dc4..849767b3 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 { @@ -563,7 +564,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..a2450993 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,9 @@ extension BaseCloudKitTests { let recordTypes = try await userDatabase.userRead { db in try RecordType.all.fetchAll(db) } - try await syncEngine.tearDownSyncEngine() - try await syncEngine.setUpSyncEngine() + 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 +476,7 @@ extension BaseCloudKitTests { let recordTypes = try await userDatabase.userRead { db in try RecordType.order(by: \.tableName).fetchAll(db) } - try await syncEngine.tearDownSyncEngine() + try syncEngine.tearDownSyncEngine() try await userDatabase.userWrite { db in try #sql( """ @@ -484,7 +485,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/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..9d68bd20 100644 --- a/Tests/SharingGRDBTests/Internal/BaseCloudKitTests.swift +++ b/Tests/SharingGRDBTests/Internal/BaseCloudKitTests.swift @@ -160,7 +160,8 @@ extension SyncEngine { tables: tables, privateTables: privateTables ) - try await setUpSyncEngine(userDatabase: userDatabase, metadatabase: metadatabase)?.value + try setUpSyncEngine() + try await start() } } From 330fc34ed6268d4921a55687b29834d049798528 Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Tue, 26 Aug 2025 14:44:27 -0700 Subject: [PATCH 02/10] wip --- Examples/Examples.xcodeproj/project.pbxproj | 2 ++ Examples/Reminders/Info.plist | 2 -- Examples/Reminders/RemindersLists.swift | 36 ++++++++++++++----- Examples/Reminders/Schema.swift | 3 +- .../SharingGRDBCore/CloudKit/SyncEngine.swift | 28 +++++++++------ 5 files changed, 50 insertions(+), 21 deletions(-) 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/Info.plist b/Examples/Reminders/Info.plist index 9ef96ef8..ca9a074a 100644 --- a/Examples/Reminders/Info.plist +++ b/Examples/Reminders/Info.plist @@ -2,8 +2,6 @@ - CKSharingSupported - UIBackgroundModes remote-notification diff --git a/Examples/Reminders/RemindersLists.swift b/Examples/Reminders/RemindersLists.swift index aa38ff5d..72c12e92 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/Examples/Reminders/Schema.swift b/Examples/Reminders/Schema.swift index cf8b4f7e..60157394 100644 --- a/Examples/Reminders/Schema.swift +++ b/Examples/Reminders/Schema.swift @@ -106,7 +106,8 @@ extension DependencyValues { RemindersListAsset.self, Reminder.self, Tag.self, - ReminderTag.self + ReminderTag.self, + startImmediately: false ) } } diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift index 7784ec7f..498cacd0 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift @@ -270,12 +270,20 @@ } 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( @@ -1633,31 +1641,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` } } From e1cba1aade6cbaf4aea8acd60a0adff96acca52f Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Tue, 26 Aug 2025 14:47:28 -0700 Subject: [PATCH 03/10] wip --- Examples/Reminders/Info.plist | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Examples/Reminders/Info.plist b/Examples/Reminders/Info.plist index ca9a074a..9ef96ef8 100644 --- a/Examples/Reminders/Info.plist +++ b/Examples/Reminders/Info.plist @@ -2,6 +2,8 @@ + CKSharingSupported + UIBackgroundModes remote-notification From 1da5f18f33207d2de0c36e78a51db71593e1d760 Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Tue, 26 Aug 2025 15:15:45 -0700 Subject: [PATCH 04/10] wip --- Examples/Reminders/RemindersLists.swift | 2 +- .../CloudKit/CloudKit+StructuredQueries.swift | 2 +- .../CloudKit/CloudKitSharing.swift | 2 +- .../SharingGRDBCore/CloudKit/SyncEngine.swift | 16 ++++++++-------- 4 files changed, 11 insertions(+), 11 deletions(-) diff --git a/Examples/Reminders/RemindersLists.swift b/Examples/Reminders/RemindersLists.swift index 72c12e92..ccd2580d 100644 --- a/Examples/Reminders/RemindersLists.swift +++ b/Examples/Reminders/RemindersLists.swift @@ -329,7 +329,7 @@ struct RemindersListsView: View { } } } label: { - Text("\(syncEngine.isRunning ? "Stop" : "Start") Synchronizing") + Text("\(syncEngine.isRunning ? "Stop" : "Start") synchronizing") Image(systemName: syncEngine.isRunning ? "stop" : "play") } } label: { diff --git a/Sources/SharingGRDBCore/CloudKit/CloudKit+StructuredQueries.swift b/Sources/SharingGRDBCore/CloudKit/CloudKit+StructuredQueries.swift index 40448072..aac3aec8 100644 --- a/Sources/SharingGRDBCore/CloudKit/CloudKit+StructuredQueries.swift +++ b/Sources/SharingGRDBCore/CloudKit/CloudKit+StructuredQueries.swift @@ -166,7 +166,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/SyncEngine.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift index 498cacd0..cf51a864 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift @@ -714,7 +714,7 @@ } func open(_: T.Type) async -> CKRecord? { let row = - withErrorReporting { + withErrorReporting(.sqliteDataCloudKitFailure) { try userDatabase.read { db in try T .where { @@ -786,7 +786,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) @@ -848,7 +848,7 @@ ) } - await withErrorReporting { + await withErrorReporting(.sqliteDataCloudKitFailure) { try await userDatabase.write { db in try SyncMetadata .where { $0.recordName.in(deletedRecordNames) } @@ -1019,7 +1019,7 @@ open(table) } else if recordType == CKRecord.SystemType.share { for recordID in recordIDs { - withErrorReporting { + withErrorReporting(.sqliteDataCloudKitFailure) { try deleteShare(recordID: recordID) } } @@ -1097,7 +1097,7 @@ group.addTask { switch share { case .share(let share): - await withErrorReporting { + await withErrorReporting(.sqliteDataCloudKitFailure) { try await self.cacheShare(share) } case .reference(let shareReference): @@ -1105,7 +1105,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) } } @@ -1133,7 +1133,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) } @@ -1604,7 +1604,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, *) From 6aba57e4a8d1d956061436091023c900ab04d8b7 Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Tue, 26 Aug 2025 17:55:25 -0700 Subject: [PATCH 05/10] wip --- .../SharingGRDBCore/CloudKit/SyncEngine.swift | 23 ++++++++++ .../CloudKitTests/CloudKitTests.swift | 45 +++++++++++++++++++ 2 files changed, 68 insertions(+) diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift index cf51a864..e85dc3b1 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift @@ -351,6 +351,28 @@ previousRecordTypeByTableName: [String: RecordType], currentRecordTypeByTableName: [String: RecordType] ) async throws { + let recordNames = try await userDatabase.read { db in + try dump(SyncMetadata.all.fetchAll(db)) + return try SyncMetadata + // TODO: Add/index a generated 'isSynchronized' column instead? + .where { $0.lastKnownServerRecord.is(nil) } + .select(\.recordName) + .fetchAll(db) + } + + syncEngines.withValue { + $0.private?.state.add( + pendingRecordZoneChanges: recordNames.map { + .saveRecord( + CKRecord.ID( + recordName: $0, + zoneID: defaultZone.zoneID + ) + ) + } + ) + } + let newTableNames = currentRecordTypeByTableName.keys.filter { tableName in previousRecordTypeByTableName[tableName] == nil } @@ -455,6 +477,7 @@ } func didUpdate(recordName: String, zoneID: CKRecordZone.ID?) { + guard isRunning else { return } let zoneID = zoneID ?? defaultZone.zoneID let syncEngine = self.syncEngines.withValue { zoneID.ownerName == CKCurrentUserDefaultName ? $0.private : $0.shared diff --git a/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift b/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift index 849767b3..e2d3f1bd 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift @@ -539,6 +539,51 @@ extension BaseCloudKitTests { #expect(metadata != nil) } + @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") + } + } + + let metadata = try #require( + try await userDatabase.userRead { db in + try RemindersList.metadata(for: 1).fetchOne(db) + } + ) + #expect(metadata.lastKnownServerRecord == nil) + + 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: [] + ) + ) + """ + } + } + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) @Test func addAndRemoveFunctions() async throws { let query = #sql( From 4daf21f4abd9e3a5ae9312e869400fd812cc59f9 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Thu, 28 Aug 2025 17:32:02 -0500 Subject: [PATCH 06/10] wip --- .../CloudKit/CloudKit+StructuredQueries.swift | 8 +- .../CloudKit/Metadatabase.swift | 8 + ...ndingRecordZoneChange+MacroExpansion.swift | 41 ++ .../CloudKit/PendingRecordZoneChange.swift | 64 +++ .../SharingGRDBCore/CloudKit/SyncEngine.swift | 84 ++-- .../CloudKitTests/CloudKitTests.swift | 44 -- .../SyncEngineLifecycleTests.swift | 436 ++++++++++++++++++ .../Internal/BaseCloudKitTests.swift | 15 +- 8 files changed, 614 insertions(+), 86 deletions(-) create mode 100644 Sources/SharingGRDBCore/CloudKit/PendingRecordZoneChange+MacroExpansion.swift create mode 100644 Sources/SharingGRDBCore/CloudKit/PendingRecordZoneChange.swift create mode 100644 Tests/SharingGRDBTests/CloudKitTests/SyncEngineLifecycleTests.swift diff --git a/Sources/SharingGRDBCore/CloudKit/CloudKit+StructuredQueries.swift b/Sources/SharingGRDBCore/CloudKit/CloudKit+StructuredQueries.swift index aac3aec8..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 { 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 2c0d6630..501ce5ba 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift @@ -351,26 +351,26 @@ previousRecordTypeByTableName: [String: RecordType], currentRecordTypeByTableName: [String: RecordType] ) async throws { - let recordNames = try await userDatabase.read { db in - try dump(SyncMetadata.all.fetchAll(db)) - return try SyncMetadata - // TODO: Add/index a generated 'isSynchronized' column instead? - .where { $0.lastKnownServerRecord.is(nil) } - .select(\.recordName) + 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: recordNames.map { - .saveRecord( - CKRecord.ID( - recordName: $0, - zoneID: defaultZone.zoneID - ) - ) - } - ) + $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 @@ -477,28 +477,34 @@ } func didUpdate(recordName: String, zoneID: CKRecordZone.ID?) { - guard isRunning else { return } 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( @@ -510,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) } diff --git a/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift b/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift index e2d3f1bd..197e0582 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift @@ -539,50 +539,6 @@ extension BaseCloudKitTests { #expect(metadata != nil) } - @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") - } - } - - let metadata = try #require( - try await userDatabase.userRead { db in - try RemindersList.metadata(for: 1).fetchOne(db) - } - ) - #expect(metadata.lastKnownServerRecord == nil) - - 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: [] - ) - ) - """ - } - } @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) @Test func addAndRemoveFunctions() async throws { diff --git a/Tests/SharingGRDBTests/CloudKitTests/SyncEngineLifecycleTests.swift b/Tests/SharingGRDBTests/CloudKitTests/SyncEngineLifecycleTests.swift new file mode 100644 index 00000000..627e1d7f --- /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 Task.sleep(for: .seconds(0.1)) + 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 Task.sleep(for: .seconds(0.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 Task.sleep(for: .seconds(0.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: [] + ) + ) + """ + } + } + + @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 Task.sleep(for: .seconds(0.1)) + 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/Internal/BaseCloudKitTests.swift b/Tests/SharingGRDBTests/Internal/BaseCloudKitTests.swift index 9d68bd20..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, @@ -161,7 +164,9 @@ extension SyncEngine { privateTables: privateTables ) try setUpSyncEngine() - try await start() + if startImmediately { + try await start() + } } } From 228b8d63379773e854f1a7d8ff1c999adc90fc2a Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Thu, 28 Aug 2025 17:35:34 -0500 Subject: [PATCH 07/10] wip --- Tests/SharingGRDBTests/CloudKitTests/RecordTypeTests.swift | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Tests/SharingGRDBTests/CloudKitTests/RecordTypeTests.swift b/Tests/SharingGRDBTests/CloudKitTests/RecordTypeTests.swift index a2450993..14cddc17 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/RecordTypeTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/RecordTypeTests.swift @@ -463,6 +463,7 @@ extension BaseCloudKitTests { let recordTypes = try await userDatabase.userRead { db in try RecordType.all.fetchAll(db) } + syncEngine.stop() try syncEngine.tearDownSyncEngine() try syncEngine.setUpSyncEngine() try await syncEngine.start() @@ -476,6 +477,7 @@ extension BaseCloudKitTests { let recordTypes = try await userDatabase.userRead { db in try RecordType.order(by: \.tableName).fetchAll(db) } + syncEngine.stop() try syncEngine.tearDownSyncEngine() try await userDatabase.userWrite { db in try #sql( From 6dbe8972ae779c1a26b1e65cabe164d0a169a72e Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Thu, 28 Aug 2025 15:48:57 -0700 Subject: [PATCH 08/10] wip --- Examples/Reminders/Schema.swift | 3 +-- Sources/SharingGRDBCore/CloudKit/SyncEngine.swift | 4 ++-- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/Examples/Reminders/Schema.swift b/Examples/Reminders/Schema.swift index 60157394..cf8b4f7e 100644 --- a/Examples/Reminders/Schema.swift +++ b/Examples/Reminders/Schema.swift @@ -106,8 +106,7 @@ extension DependencyValues { RemindersListAsset.self, Reminder.self, Tag.self, - ReminderTag.self, - startImmediately: false + ReminderTag.self ) } } diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift index 501ce5ba..b032f5ca 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift @@ -1660,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 { From 352e63e128e7603008bec95c4f30f9bce4355049 Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Fri, 29 Aug 2025 13:29:21 -0700 Subject: [PATCH 09/10] Remove unneeded sleeps --- .../CloudKitTests/SyncEngineLifecycleTests.swift | 4 ---- 1 file changed, 4 deletions(-) diff --git a/Tests/SharingGRDBTests/CloudKitTests/SyncEngineLifecycleTests.swift b/Tests/SharingGRDBTests/CloudKitTests/SyncEngineLifecycleTests.swift index 627e1d7f..7216ea1c 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/SyncEngineLifecycleTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/SyncEngineLifecycleTests.swift @@ -137,7 +137,6 @@ extension BaseCloudKitTests { try RemindersList.find(1).update { $0.title += "!" }.execute(db) } - try await Task.sleep(for: .seconds(0.1)) try await userDatabase.read { db in try #expect(PendingRecordZoneChange.all.fetchCount(db) == 1) try #expect(RemindersList.find(1).fetchOne(db)?.title == "Personal!") @@ -206,7 +205,6 @@ extension BaseCloudKitTests { } } - try await Task.sleep(for: .seconds(0.1)) try await syncEngine.start() try await syncEngine.processPendingRecordZoneChanges(scope: .shared) @@ -270,7 +268,6 @@ extension BaseCloudKitTests { try RemindersList.find(1).delete().execute(db) } - try await Task.sleep(for: .seconds(0.1)) try await syncEngine.start() try await syncEngine.processPendingRecordZoneChanges(scope: .shared) @@ -332,7 +329,6 @@ extension BaseCloudKitTests { try RemindersList.find(1).delete().execute(db) } - try await Task.sleep(for: .seconds(0.1)) try await syncEngine.start() try await syncEngine.processPendingRecordZoneChanges(scope: .private) From addad3ad0d9c7a279592787eeb46c7b4015457d0 Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Fri, 29 Aug 2025 13:29:47 -0700 Subject: [PATCH 10/10] format --- .../SyncEngineLifecycleTests.swift | 186 +++++++++--------- 1 file changed, 95 insertions(+), 91 deletions(-) diff --git a/Tests/SharingGRDBTests/CloudKitTests/SyncEngineLifecycleTests.swift b/Tests/SharingGRDBTests/CloudKitTests/SyncEngineLifecycleTests.swift index 7216ea1c..0ef1aaf9 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/SyncEngineLifecycleTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/SyncEngineLifecycleTests.swift @@ -13,7 +13,8 @@ extension BaseCloudKitTests { struct SyncEngineLifecycleTests { @MainActor @Suite - final class SyncEngineLifecycleTests_ImmediatelyStarted: BaseCloudKitTests, @unchecked Sendable { + final class SyncEngineLifecycleTests_ImmediatelyStarted: BaseCloudKitTests, @unchecked Sendable + { @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) @Test func stopAndReStart() async throws { syncEngine.stop() @@ -35,55 +36,55 @@ extension BaseCloudKitTests { } assertInlineSnapshot(of: container, as: .customDump) { - """ - MockCloudContainer( - privateCloudDatabase: MockCloudDatabase( - databaseScope: .private, - storage: [] - ), - sharedCloudDatabase: MockCloudDatabase( - databaseScope: .shared, - storage: [] + """ + 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: [] + """ + 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: [] + ) ) - ) - """ + """ } } @@ -127,24 +128,24 @@ extension BaseCloudKitTests { } } 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( @@ -168,7 +169,7 @@ extension BaseCloudKitTests { ) """ } - + try await userDatabase.read { db in try #expect(PendingRecordZoneChange.all.fetchCount(db) == 0) } @@ -244,7 +245,9 @@ extension BaseCloudKitTests { } } - @Test func extenalSharedRecord_StopSyncEngine_DeleteSharedRecord_StartSyncEngine() async throws { + @Test func extenalSharedRecord_StopSyncEngine_DeleteSharedRecord_StartSyncEngine() + async throws + { let externalZoneID = CKRecordZone.ID( zoneName: "external.zone", ownerName: "external.owner" @@ -350,7 +353,8 @@ extension BaseCloudKitTests { } @MainActor - final class SyncEngineLifecycleTests_ImmediatelyStopped: BaseCloudKitTests, @unchecked Sendable { + final class SyncEngineLifecycleTests_ImmediatelyStopped: BaseCloudKitTests, @unchecked Sendable + { init() async throws { try await super.init(startImmediately: false) } @@ -374,18 +378,18 @@ extension BaseCloudKitTests { } assertInlineSnapshot(of: container, as: .customDump) { - """ - MockCloudContainer( - privateCloudDatabase: MockCloudDatabase( - databaseScope: .private, - storage: [] - ), - sharedCloudDatabase: MockCloudDatabase( - databaseScope: .shared, - storage: [] + """ + MockCloudContainer( + privateCloudDatabase: MockCloudDatabase( + databaseScope: .private, + storage: [] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [] + ) ) - ) - """ + """ } try await syncEngine.start() @@ -394,37 +398,37 @@ extension BaseCloudKitTests { 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: [] + """ + 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: [] + ) ) - ) - """ + """ } } }