From 745027df087a467ecdcf95c91ba51ecaab1b7ef7 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Thu, 14 Aug 2025 22:06:40 -0500 Subject: [PATCH 1/7] Beginnings of making sharing more testable. --- .../CloudKit/CloudContainer.swift | 51 ++++- .../CloudKit/CloudKitSharing.swift | 3 +- .../Internal/MockCloudContainer.swift | 7 +- .../CloudKit/Internal/MockCloudDatabase.swift | 23 ++- .../SharingGRDBCore/CloudKit/SyncEngine.swift | 9 +- .../CloudKit/SyncEngineProtocol.swift | 17 -- .../CloudKitTests/SharingTests.swift | 189 ++++++++++++++---- 7 files changed, 224 insertions(+), 75 deletions(-) diff --git a/Sources/SharingGRDBCore/CloudKit/CloudContainer.swift b/Sources/SharingGRDBCore/CloudKit/CloudContainer.swift index c2bfc71e..93a09160 100644 --- a/Sources/SharingGRDBCore/CloudKit/CloudContainer.swift +++ b/Sources/SharingGRDBCore/CloudKit/CloudContainer.swift @@ -1,6 +1,7 @@ #if canImport(CloudKit) import CloudKit +@available(macOS 12, *) package protocol CloudContainer: AnyObject, Equatable, Hashable, Sendable { associatedtype Database: CloudDatabase @@ -8,13 +9,38 @@ package protocol CloudContainer: AnyObject, Equatable, Hashable, Senda var containerIdentifier: String? { get } var rawValue: CKContainer { get } var privateCloudDatabase: Database { get } - func accept(_ metadata: CKShare.Metadata) async throws -> CKShare + func accept(_ metadata: ShareMetadata) async throws -> CKShare static func createContainer(identifier containerIdentifier: String) -> Self var sharedCloudDatabase: Database { get } - @available(macOS 12.0, iOS 15.0, tvOS 15.0, watchOS 8.0, *) - func shareMetadata(for url: URL, shouldFetchRootRecord: Bool) async throws -> CKShare.Metadata + @available(macOS 12, iOS 15, tvOS 15, watchOS 8, *) + func shareMetadata(for share: CKShare, shouldFetchRootRecord: Bool) async throws -> ShareMetadata } +@available(macOS 12, *) +package struct ShareMetadata: Hashable { + package var containerIdentifier: String + package var hierarchicalRootRecordID: CKRecord.ID? + package var rootRecord: CKRecord? + package var rawValue: CKShare.Metadata? + package init(rawValue: CKShare.Metadata) { + self.containerIdentifier = rawValue.containerIdentifier + self.hierarchicalRootRecordID = rawValue.hierarchicalRootRecordID + self.rootRecord = rawValue.rootRecord + self.rawValue = rawValue + } + package init( + containerIdentifier: String, + hierarchicalRootRecordID: CKRecord.ID?, + rootRecord: CKRecord? + ) { + self.containerIdentifier = containerIdentifier + self.hierarchicalRootRecordID = hierarchicalRootRecordID + self.rootRecord = rootRecord + self.rawValue = nil + } +} + +@available(macOS 12, *) extension CloudContainer { package func database(for recordID: CKRecord.ID) -> any CloudDatabase { recordID.zoneID.ownerName == CKCurrentUserDefaultName @@ -23,7 +49,16 @@ extension CloudContainer { } } +@available(macOS 12, *) extension CKContainer: CloudContainer { + package func accept(_ metadata: ShareMetadata) async throws -> CKShare { + guard let metadata = metadata.rawValue + else { + fatalError("This should never be called with 'ShareMetadata' that has a nil 'rawValue'") + } + return try await self.accept(metadata) + } + package static func createContainer(identifier containerIdentifier: String) -> Self { Self(identifier: containerIdentifier) } @@ -32,16 +67,16 @@ extension CKContainer: CloudContainer { self } - @available(macOS 12.0, iOS 15.0, tvOS 15.0, watchOS 8.0, *) + @available(macOS 12, iOS 15, tvOS 15, watchOS 8, *) package func shareMetadata( - for url: URL, + for share: CKShare, shouldFetchRootRecord: Bool = false - ) async throws -> CKShare.Metadata { + ) async throws -> ShareMetadata { try await withUnsafeThrowingContinuation { continuation in - let operation = CKFetchShareMetadataOperation(shareURLs: [url]) + let operation = CKFetchShareMetadataOperation(shareURLs: [share.url].compactMap(\.self)) operation.shouldFetchRootRecord = true operation.perShareMetadataResultBlock = { url, result in - continuation.resume(with: result) + continuation.resume(with: result.map(ShareMetadata.init(rawValue:))) } add(operation) } diff --git a/Sources/SharingGRDBCore/CloudKit/CloudKitSharing.swift b/Sources/SharingGRDBCore/CloudKit/CloudKitSharing.swift index ed301c64..97a5ba54 100644 --- a/Sources/SharingGRDBCore/CloudKit/CloudKitSharing.swift +++ b/Sources/SharingGRDBCore/CloudKit/CloudKitSharing.swift @@ -7,6 +7,7 @@ import SwiftUI import UIKit #endif +@available(macOS 12, *) public struct SharedRecord: Hashable, Identifiable, Sendable { let container: any CloudContainer public let share: CKShare @@ -124,7 +125,7 @@ extension SyncEngine { let sharedRecord = try await existingShare ?? CKShare( rootRecord: rootRecord, shareID: CKRecord.ID( - recordName: UUID().uuidString, + recordName: "share-\(recordName)", zoneID: rootRecord.recordID.zoneID ) ) diff --git a/Sources/SharingGRDBCore/CloudKit/Internal/MockCloudContainer.swift b/Sources/SharingGRDBCore/CloudKit/Internal/MockCloudContainer.swift index 56fe5bf9..d2447223 100644 --- a/Sources/SharingGRDBCore/CloudKit/Internal/MockCloudContainer.swift +++ b/Sources/SharingGRDBCore/CloudKit/Internal/MockCloudContainer.swift @@ -32,11 +32,14 @@ package final class MockCloudContainer: CloudContainer, CustomDumpReflectable { _accountStatus.withValue { $0 } } - package func shareMetadata(for url: URL, shouldFetchRootRecord: Bool) async throws -> CKShare.Metadata { + package func shareMetadata( + for share: CKShare, + shouldFetchRootRecord: Bool + ) async throws -> ShareMetadata { fatalError() } - package func accept(_ metadata: CKShare.Metadata) async throws -> CKShare { + package func accept(_ metadata: ShareMetadata) async throws -> CKShare { fatalError() } diff --git a/Sources/SharingGRDBCore/CloudKit/Internal/MockCloudDatabase.swift b/Sources/SharingGRDBCore/CloudKit/Internal/MockCloudDatabase.swift index 1fb82e7b..b73c7f7a 100644 --- a/Sources/SharingGRDBCore/CloudKit/Internal/MockCloudDatabase.swift +++ b/Sources/SharingGRDBCore/CloudKit/Internal/MockCloudDatabase.swift @@ -87,6 +87,27 @@ package final class MockCloudDatabase: CloudDatabase { switch savePolicy { case .ifServerRecordUnchanged: for recordToSave in recordsToSave { + storage[recordToSave.recordID.zoneID] = storage[recordToSave.recordID.zoneID] ?? [:] + + if let share = recordToSave as? CKShare { + let isSavingRootRecord = recordsToSave.contains(where: { $0.share?.recordID == share.recordID }) + let rootRecordWasPreviouslySaved = storage[share.recordID.zoneID]?.contains(where: { + _, record in record.share?.recordID == share.recordID + }) + ?? false + guard isSavingRootRecord || rootRecordWasPreviouslySaved + else { + reportIssue( + """ + An added share is being saved without its rootRecord being saved in the same \ + operation. + """ + ) + saveResults[recordToSave.recordID] = .failure(CKError(.invalidArguments)) + continue + } + } + guard storage[recordToSave.recordID.zoneID] != nil else { saveResults[recordToSave.recordID] = .failure(CKError(.zoneNotFound)) @@ -268,7 +289,7 @@ extension MockCloudDatabase: CustomDumpReflectable { } } -@available(macOS 13.0, iOS 16.0, tvOS 16.0, watchOS 9.0, *) +@available(macOS 13, iOS 16, tvOS 16, watchOS 9, *) private func ckError(forAccountStatus accountStatus: CKAccountStatus) -> CKError { switch accountStatus { case .couldNotDetermine, .restricted, .noAccount: diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift index 4ae48eab..37da81da 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift @@ -485,9 +485,9 @@ return } let container = type(of: container).createContainer(identifier: metadata.containerIdentifier) - _ = try await container.accept(metadata) + _ = try await container.accept(ShareMetadata(rawValue: metadata)) try await syncEngines.shared?.fetchChanges( - .init( + CKSyncEngine.FetchChangesOptions( scope: .zoneIDs([rootRecordID.zoneID]), operationGroup: nil ) @@ -1152,12 +1152,9 @@ } private func cacheShare(_ share: CKShare) async throws { - guard let url = share.url - else { return } - guard let metadata = try? await container.shareMetadata( - for: url, + for: share, shouldFetchRootRecord: true ) else { diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngineProtocol.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngineProtocol.swift index fbb0426d..1ddc0b7a 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngineProtocol.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngineProtocol.swift @@ -27,23 +27,6 @@ package protocol SyncEngineProtocol: AnyObject, Sendable { ) async -> CKSyncEngine.RecordZoneChangeBatch? } -@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) -package struct ShareMetadata: Hashable { - package var containerIdentifier: String - package var hierarchicalRootRecordID: CKRecord.ID? - package var rawValue: CKShare.Metadata? - package init(rawValue: CKShare.Metadata) { - self.containerIdentifier = rawValue.containerIdentifier - self.hierarchicalRootRecordID = rawValue.hierarchicalRootRecordID - self.rawValue = rawValue - } - package init(containerIdentifier: String, hierarchicalRootRecordID: CKRecord.ID?) { - self.containerIdentifier = containerIdentifier - self.hierarchicalRootRecordID = hierarchicalRootRecordID - self.rawValue = nil - } -} - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) package protocol CKSyncEngineStateProtocol: Sendable { var pendingRecordZoneChanges: [CKSyncEngine.PendingRecordZoneChange] { get } diff --git a/Tests/SharingGRDBTests/CloudKitTests/SharingTests.swift b/Tests/SharingGRDBTests/CloudKitTests/SharingTests.swift index aa6d0880..46834b97 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/SharingTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/SharingTests.swift @@ -61,7 +61,10 @@ extension BaseCloudKitTests { configure: { _ in } ) } - assertInlineSnapshot(of: (error as? any LocalizedError)?.localizedDescription, as: .customDump) { + assertInlineSnapshot( + of: (error as? any LocalizedError)?.localizedDescription, + as: .customDump + ) { """ "The record could not be shared." """ @@ -86,7 +89,10 @@ extension BaseCloudKitTests { configure: { _ in } ) } - assertInlineSnapshot(of: (error as? any LocalizedError)?.localizedDescription, as: .customDump) { + assertInlineSnapshot( + of: (error as? any LocalizedError)?.localizedDescription, + as: .customDump + ) { """ "The record could not be shared." """ @@ -122,7 +128,10 @@ extension BaseCloudKitTests { configure: { _ in } ) } - assertInlineSnapshot(of: (error as? any LocalizedError)?.localizedDescription, as: .customDump) { + assertInlineSnapshot( + of: (error as? any LocalizedError)?.localizedDescription, + as: .customDump + ) { """ "The record could not be shared." """ @@ -206,7 +215,8 @@ extension BaseCloudKitTests { } @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - @Test func shareDelieveredBeforeRecord() async throws { + @Test(.snapshots(record: .failed)) + func shareDelieveredBeforeRecord() async throws { let externalZone = CKRecordZone( zoneID: CKRecordZone.ID( zoneName: "external.zone", @@ -226,23 +236,25 @@ extension BaseCloudKitTests { let share = CKShare( rootRecord: remindersListRecord, shareID: CKRecord.ID( - recordName: "Share-\(1)", + recordName: "Share-1", zoneID: externalZone.zoneID ) ) - try await syncEngine.modifyRecords(scope: .shared, saving: [share]).notify() - try await syncEngine.modifyRecords(scope: .shared, saving: [remindersListRecord]).notify() + _ = try syncEngine.modifyRecords(scope: .private, saving: [share, remindersListRecord]) + + let newShare = try syncEngine.private.database.record(for: share.recordID) + let newRemindersListRecord = try syncEngine.private.database.record( + for: remindersListRecord.recordID + ) + try await syncEngine.modifyRecords(scope: .private, saving: [newShare]).notify() + try await syncEngine.modifyRecords(scope: .private, saving: [newRemindersListRecord]).notify() assertInlineSnapshot(of: syncEngine.container, as: .customDump) { """ MockCloudContainer( privateCloudDatabase: MockCloudDatabase( databaseScope: .private, - storage: [] - ), - sharedCloudDatabase: MockCloudDatabase( - databaseScope: .shared, storage: [ [0]: CKRecord( recordID: CKRecord.ID(Share-1/external.zone/external.owner), @@ -260,6 +272,10 @@ extension BaseCloudKitTests { title: "Personal" ) ] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [] ) ) """ @@ -270,34 +286,7 @@ extension BaseCloudKitTests { } assertInlineSnapshot(of: metadata, as: .customDump) { """ - [ - [0]: SyncMetadata( - recordPrimaryKey: "1", - recordType: "remindersLists", - recordName: "1:remindersLists", - parentRecordPrimaryKey: nil, - parentRecordType: nil, - parentRecordName: nil, - lastKnownServerRecord: CKRecord( - recordID: CKRecord.ID(1:remindersLists/external.zone/external.owner), - recordType: "remindersLists", - parent: nil, - share: CKReference(recordID: CKRecord.ID(Share-1/external.zone/external.owner)) - ), - _lastKnownServerRecordAllFields: CKRecord( - recordID: CKRecord.ID(1:remindersLists/external.zone/external.owner), - recordType: "remindersLists", - parent: nil, - share: CKReference(recordID: CKRecord.ID(Share-1/external.zone/external.owner)), - id: 1, - isCompleted: 0, - title: "Personal" - ), - share: nil, - isShared: false, - userModificationDate: Date(1970-01-01T00:00:00.000Z) - ) - ] + [] """ } } @@ -401,7 +390,10 @@ extension BaseCloudKitTests { reminderRecord.setValue(1, forKey: "remindersListID", at: now) reminderRecord.parent = CKRecord.Reference(record: remindersListRecord, action: .none) - try await syncEngine.modifyRecords(scope: .shared, saving: [remindersListRecord, reminderRecord]).notify() + try await syncEngine.modifyRecords( + scope: .shared, + saving: [remindersListRecord, reminderRecord] + ).notify() try await withDependencies { $0.datetime.now.addTimeInterval(60) @@ -436,6 +428,123 @@ extension BaseCloudKitTests { """ } } + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test(.snapshots(record: .failed)) + func share() 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 sharedRecord = try await syncEngine.share(record: remindersList, configure: { _ in }) + + try await userDatabase.read { db in + let metadata = try #require( + try SyncMetadata + .where { $0.recordPrimaryKey.eq("1") } + .fetchOne(db) + ) + #expect(metadata.share?.recordID == sharedRecord.share.recordID) + } + + 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: [] + ) + ) + """ + } + + return + /* + let remindersListRecord = CKRecord( + recordType: RemindersList.tableName, + recordID: RemindersList.recordID(for: 1, zoneID: externalZone.zoneID) + ) + remindersListRecord.setValue(1, forKey: "id", at: now) + remindersListRecord.setValue(false, forKey: "isCompleted", at: now) + remindersListRecord.setValue("Personal", forKey: "title", at: now) + + let share = CKShare( + rootRecord: remindersListRecord, + shareID: CKRecord.ID( + recordName: "Share-1", + zoneID: externalZone.zoneID + ) + ) + + _ = try syncEngine.modifyRecords(scope: .private, saving: [share, remindersListRecord]) + + let newShare = try syncEngine.private.database.record(for: share.recordID) + let newRemindersListRecord = try syncEngine.private.database.record(for: remindersListRecord.recordID) + try await syncEngine.modifyRecords(scope: .private, saving: [newShare]).notify() + try await syncEngine.modifyRecords(scope: .private, saving: [newRemindersListRecord]).notify() + + assertInlineSnapshot(of: syncEngine.container, as: .customDump) { + """ + MockCloudContainer( + privateCloudDatabase: MockCloudDatabase( + databaseScope: .private, + storage: [ + [0]: CKRecord( + recordID: CKRecord.ID(Share-1/external.zone/external.owner), + recordType: "cloudkit.share", + parent: nil, + share: nil + ), + [1]: CKRecord( + recordID: CKRecord.ID(1:remindersLists/external.zone/external.owner), + recordType: "remindersLists", + parent: nil, + share: CKReference(recordID: CKRecord.ID(Share-1/external.zone/external.owner)), + id: 1, + isCompleted: 0, + title: "Personal" + ) + ] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [] + ) + ) + """ + } + + let metadata = try await userDatabase.read { db in + try SyncMetadata.order(by: \.recordName).fetchAll(db) + } + assertInlineSnapshot(of: metadata, as: .customDump) { + """ + [] + """ + } + */ + } } } From 138368ab7f3580211e2a24ec2c58ece49c87089e Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Fri, 15 Aug 2025 10:40:15 -0500 Subject: [PATCH 2/7] wip --- Tests/SharingGRDBTests/CloudKitTests/SharingTests.swift | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/Tests/SharingGRDBTests/CloudKitTests/SharingTests.swift b/Tests/SharingGRDBTests/CloudKitTests/SharingTests.swift index 46834b97..c54ef01e 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/SharingTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/SharingTests.swift @@ -430,8 +430,7 @@ extension BaseCloudKitTests { } @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - @Test(.snapshots(record: .failed)) - func share() async throws { + @Test func share() async throws { let remindersList = RemindersList(id: 1, title: "Personal") try await userDatabase.userWrite { db in try db.seed { From de79743de07cbbd0ea4b09c11678bc635928b44f Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Fri, 15 Aug 2025 12:27:57 -0500 Subject: [PATCH 3/7] wip --- .../Internal/MockCloudContainer.swift | 46 +++- .../CloudKit/Internal/MockCloudDatabase.swift | 2 - .../CloudKit/Internal/MockSyncEngine.swift | 18 +- .../SharingGRDBCore/CloudKit/SyncEngine.swift | 11 +- .../CloudKitTests/SharingTests.swift | 216 ++++++++++++------ 5 files changed, 214 insertions(+), 79 deletions(-) diff --git a/Sources/SharingGRDBCore/CloudKit/Internal/MockCloudContainer.swift b/Sources/SharingGRDBCore/CloudKit/Internal/MockCloudContainer.swift index d2447223..e2a21e57 100644 --- a/Sources/SharingGRDBCore/CloudKit/Internal/MockCloudContainer.swift +++ b/Sources/SharingGRDBCore/CloudKit/Internal/MockCloudContainer.swift @@ -18,6 +18,12 @@ package final class MockCloudContainer: CloudContainer, CustomDumpReflectable { self.containerIdentifier = containerIdentifier self.privateCloudDatabase = privateCloudDatabase self.sharedCloudDatabase = sharedCloudDatabase + + guard let containerIdentifier else { return } + @Dependency(\.mockCloudContainers) var mockCloudContainers + mockCloudContainers.withValue { storage in + storage[containerIdentifier] = self + } } package func accountStatus() -> CKAccountStatus { @@ -36,11 +42,40 @@ package final class MockCloudContainer: CloudContainer, CustomDumpReflectable { for share: CKShare, shouldFetchRootRecord: Bool ) async throws -> ShareMetadata { - fatalError() + let database = share.recordID.zoneID.ownerName == CKCurrentUserDefaultName + ? privateCloudDatabase + : sharedCloudDatabase + + let rootRecord: CKRecord? = database.storage.withValue { + $0[share.recordID.zoneID]?.values.first { record in + record.share?.recordID == share.recordID + } + } + + return ShareMetadata.init( + containerIdentifier: containerIdentifier!, + hierarchicalRootRecordID: rootRecord?.recordID, + rootRecord: rootRecord + ) } package func accept(_ metadata: ShareMetadata) async throws -> CKShare { - fatalError() + guard let rootRecord = metadata.rootRecord + else { + struct SomeError: Error { let file = #fileID, line = #line } + throw SomeError() + } + + let share = CKShare.init( + rootRecord: rootRecord, + shareID: CKRecord.ID.init( + recordName: "Share-\(rootRecord.recordID.recordName)", + zoneID: rootRecord.recordID.zoneID + ) + ) + let (saveResults, _) = try sharedCloudDatabase.modifyRecords(saving: [share, rootRecord]) + try saveResults.values.forEach { _ = try $0.get() } + return share } package static func createContainer(identifier containerIdentifier: String) -> MockCloudContainer { @@ -48,7 +83,7 @@ package final class MockCloudContainer: CloudContainer, CustomDumpReflectable { return mockCloudContainers.withValue { storage in let container: MockCloudContainer if let existingContainer = storage[containerIdentifier] { - container = existingContainer + return existingContainer } else { container = MockCloudContainer( accountStatus: .available, @@ -85,7 +120,10 @@ package final class MockCloudContainer: CloudContainer, CustomDumpReflectable { } @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) -private enum MockCloudContainersKey: TestDependencyKey { +private enum MockCloudContainersKey: DependencyKey { + static var liveValue: LockIsolated<[String: MockCloudContainer]> { + LockIsolated<[String: MockCloudContainer]>([:]) + } static var testValue: LockIsolated<[String: MockCloudContainer]> { LockIsolated<[String: MockCloudContainer]>([:]) } diff --git a/Sources/SharingGRDBCore/CloudKit/Internal/MockCloudDatabase.swift b/Sources/SharingGRDBCore/CloudKit/Internal/MockCloudDatabase.swift index b73c7f7a..9f30c059 100644 --- a/Sources/SharingGRDBCore/CloudKit/Internal/MockCloudDatabase.swift +++ b/Sources/SharingGRDBCore/CloudKit/Internal/MockCloudDatabase.swift @@ -87,8 +87,6 @@ package final class MockCloudDatabase: CloudDatabase { switch savePolicy { case .ifServerRecordUnchanged: for recordToSave in recordsToSave { - storage[recordToSave.recordID.zoneID] = storage[recordToSave.recordID.zoneID] ?? [:] - if let share = recordToSave as? CKShare { let isSavingRootRecord = recordsToSave.contains(where: { $0.share?.recordID == share.recordID }) let rootRecordWasPreviouslySaved = storage[share.recordID.zoneID]?.contains(where: { diff --git a/Sources/SharingGRDBCore/CloudKit/Internal/MockSyncEngine.swift b/Sources/SharingGRDBCore/CloudKit/Internal/MockSyncEngine.swift index 3bd4507a..503e73a1 100644 --- a/Sources/SharingGRDBCore/CloudKit/Internal/MockSyncEngine.swift +++ b/Sources/SharingGRDBCore/CloudKit/Internal/MockSyncEngine.swift @@ -33,7 +33,23 @@ package final class MockSyncEngine: SyncEngineProtocol { } package func fetchChanges(_ options: CKSyncEngine.FetchChangesOptions) async throws { - // TODO: do something here + let records: [CKRecord] + switch options.scope { + case .all, .allExcluding: + fatalError() + case .zoneIDs(let zoneIDs): + records = zoneIDs.reduce(into: [CKRecord]()) { accum, zoneID in + accum += database.storage.withValue { + ($0[zoneID]?.values).map { Array($0) } ?? [] + } + } + @unknown default: + fatalError() + } + await delegate.handleEvent( + .fetchedRecordZoneChanges(modifications: records, deletions: []), + syncEngine: self + ) } package func recordZoneChangeBatch( diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift index 37da81da..bad180c2 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift @@ -474,18 +474,13 @@ // TODO: Possible to get test coverage on this? package func acceptShare(metadata: ShareMetadata) async throws { - guard let metadata = metadata.rawValue - else { - reportIssue("TODO") - return - } guard let rootRecordID = metadata.hierarchicalRootRecordID else { - reportIssue("TODO") - return + reportIssue("Attempting to share without root record information.") + throw CKError(.invalidArguments) } let container = type(of: container).createContainer(identifier: metadata.containerIdentifier) - _ = try await container.accept(ShareMetadata(rawValue: metadata)) + _ = try await container.accept(metadata) try await syncEngines.shared?.fetchChanges( CKSyncEngine.FetchChangesOptions( scope: .zoneIDs([rootRecordID.zoneID]), diff --git a/Tests/SharingGRDBTests/CloudKitTests/SharingTests.swift b/Tests/SharingGRDBTests/CloudKitTests/SharingTests.swift index c54ef01e..daef65b1 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/SharingTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/SharingTests.swift @@ -215,7 +215,6 @@ extension BaseCloudKitTests { } @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - @Test(.snapshots(record: .failed)) func shareDelieveredBeforeRecord() async throws { let externalZone = CKRecordZone( zoneID: CKRecordZone.ID( @@ -477,72 +476,161 @@ extension BaseCloudKitTests { ) """ } + } + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func acceptShare() async throws { + let externalZone = CKRecordZone( + zoneID: CKRecordZone.ID( + zoneName: "external.zone", + ownerName: "external.owner" + ) + ) + try await syncEngine.modifyRecordZones(scope: .shared, saving: [externalZone]).notify() - return - /* - let remindersListRecord = CKRecord( - recordType: RemindersList.tableName, - recordID: RemindersList.recordID(for: 1, zoneID: externalZone.zoneID) - ) - remindersListRecord.setValue(1, forKey: "id", at: now) - remindersListRecord.setValue(false, forKey: "isCompleted", at: now) - remindersListRecord.setValue("Personal", forKey: "title", at: now) - - let share = CKShare( - rootRecord: remindersListRecord, - shareID: CKRecord.ID( - recordName: "Share-1", - zoneID: externalZone.zoneID + let remindersListRecord = CKRecord( + recordType: RemindersList.tableName, + recordID: RemindersList.recordID(for: 1, zoneID: externalZone.zoneID) + ) + remindersListRecord.setValue(1, forKey: "id", at: now) + remindersListRecord.setValue("Personal", forKey: "title", at: now) + + try await syncEngine + .acceptShare( + metadata: ShareMetadata( + containerIdentifier: container.containerIdentifier!, + hierarchicalRootRecordID: remindersListRecord.recordID, + rootRecord: remindersListRecord + ) + ) + + try await userDatabase.read { db in + let remindersList = try #require(try RemindersList.find(1).fetchOne(db)) + let metadata = try #require( + try SyncMetadata + .where { $0.recordName.eq(remindersListRecord.recordID.recordName) } + .fetchOne(db) + ) + #expect(remindersList.title == "Personal") + #expect( + metadata.share?.recordID.recordName == "Share-\(remindersListRecord.recordID.recordName)" + ) + } + + assertInlineSnapshot(of: container, as: .customDump) { + """ + MockCloudContainer( + privateCloudDatabase: MockCloudDatabase( + databaseScope: .private, + storage: [] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [ + [0]: CKRecord( + recordID: CKRecord.ID(Share-1:remindersLists/external.zone/external.owner), + recordType: "cloudkit.share", + parent: nil, + share: nil + ), + [1]: CKRecord( + recordID: CKRecord.ID(1:remindersLists/external.zone/external.owner), + recordType: "remindersLists", + parent: nil, + share: CKReference(recordID: CKRecord.ID(Share-1:remindersLists/external.zone/external.owner)), + id: 1, + title: "Personal" ) - ) - - _ = try syncEngine.modifyRecords(scope: .private, saving: [share, remindersListRecord]) - - let newShare = try syncEngine.private.database.record(for: share.recordID) - let newRemindersListRecord = try syncEngine.private.database.record(for: remindersListRecord.recordID) - try await syncEngine.modifyRecords(scope: .private, saving: [newShare]).notify() - try await syncEngine.modifyRecords(scope: .private, saving: [newRemindersListRecord]).notify() - - assertInlineSnapshot(of: syncEngine.container, as: .customDump) { - """ - MockCloudContainer( - privateCloudDatabase: MockCloudDatabase( - databaseScope: .private, - storage: [ - [0]: CKRecord( - recordID: CKRecord.ID(Share-1/external.zone/external.owner), - recordType: "cloudkit.share", - parent: nil, - share: nil - ), - [1]: CKRecord( - recordID: CKRecord.ID(1:remindersLists/external.zone/external.owner), - recordType: "remindersLists", - parent: nil, - share: CKReference(recordID: CKRecord.ID(Share-1/external.zone/external.owner)), - id: 1, - isCompleted: 0, - title: "Personal" - ) - ] - ), - sharedCloudDatabase: MockCloudDatabase( - databaseScope: .shared, - storage: [] - ) + ] + ) + ) + """ + } + } + + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func acceptShareCreateReminder() async throws { + let externalZone = CKRecordZone( + zoneID: CKRecordZone.ID( + zoneName: "external.zone", + ownerName: "external.owner" + ) + ) + try await syncEngine.modifyRecordZones(scope: .shared, saving: [externalZone]).notify() + + let remindersListRecord = CKRecord( + recordType: RemindersList.tableName, + recordID: RemindersList.recordID(for: 1, zoneID: externalZone.zoneID) + ) + remindersListRecord.setValue(1, forKey: "id", at: now) + remindersListRecord.setValue("Personal", forKey: "title", at: now) + + try await syncEngine + .acceptShare( + metadata: ShareMetadata( + containerIdentifier: container.containerIdentifier!, + hierarchicalRootRecordID: remindersListRecord.recordID, + rootRecord: remindersListRecord + ) + ) + + try await userDatabase.userWrite { db in + try db.seed { + Reminder(id: 1, title: "Get milk", remindersListID: 1) + } + } + + try await syncEngine.processPendingRecordZoneChanges(scope: .shared) + + try await userDatabase.read { db in + let metadata = try #require( + try SyncMetadata + .where { $0.recordName.eq("1:reminders") } + .fetchOne(db) + ) + #expect(metadata.parentRecordName == "1:remindersLists") + } + + assertInlineSnapshot(of: container, as: .customDump) { + """ + MockCloudContainer( + privateCloudDatabase: MockCloudDatabase( + databaseScope: .private, + storage: [] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [ + [0]: CKRecord( + recordID: CKRecord.ID(Share-1:remindersLists/external.zone/external.owner), + recordType: "cloudkit.share", + parent: nil, + share: nil + ), + [1]: CKRecord( + recordID: CKRecord.ID(1:reminders/external.zone/external.owner), + recordType: "reminders", + parent: CKReference(recordID: CKRecord.ID(1:remindersLists/external.zone/external.owner)), + share: nil, + id: 1, + isCompleted: 0, + remindersListID: 1, + title: "Get milk" + ), + [2]: CKRecord( + recordID: CKRecord.ID(1:remindersLists/external.zone/external.owner), + recordType: "remindersLists", + parent: nil, + share: CKReference(recordID: CKRecord.ID(Share-1:remindersLists/external.zone/external.owner)), + id: 1, + title: "Personal" ) - """ - } - - let metadata = try await userDatabase.read { db in - try SyncMetadata.order(by: \.recordName).fetchAll(db) - } - assertInlineSnapshot(of: metadata, as: .customDump) { - """ - [] - """ - } - */ + ] + ) + ) + """ + } } } } From 746081f82bcbd97a93fbf99c0e8e7f96311fdf16 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Fri, 15 Aug 2025 13:44:08 -0500 Subject: [PATCH 4/7] wip --- .../CloudKit/CloudContainer.swift | 14 +++++---- .../CloudKit/CloudKit+StructuredQueries.swift | 2 +- .../CloudKit/CloudKitSharing.swift | 2 +- .../Internal/MockCloudContainer.swift | 19 +++++------- .../CloudKit/Internal/MockCloudDatabase.swift | 2 ++ .../SharingGRDBCore/CloudKit/SyncEngine.swift | 12 +++----- .../CloudKitTests/SharingTests.swift | 30 ++++++++++++++----- 7 files changed, 47 insertions(+), 34 deletions(-) diff --git a/Sources/SharingGRDBCore/CloudKit/CloudContainer.swift b/Sources/SharingGRDBCore/CloudKit/CloudContainer.swift index 93a09160..5a32837a 100644 --- a/Sources/SharingGRDBCore/CloudKit/CloudContainer.swift +++ b/Sources/SharingGRDBCore/CloudKit/CloudContainer.swift @@ -1,7 +1,7 @@ #if canImport(CloudKit) import CloudKit -@available(macOS 12, *) +@available(iOS 15, macOS 12, *) package protocol CloudContainer: AnyObject, Equatable, Hashable, Sendable { associatedtype Database: CloudDatabase @@ -16,31 +16,35 @@ package protocol CloudContainer: AnyObject, Equatable, Hashable, Senda func shareMetadata(for share: CKShare, shouldFetchRootRecord: Bool) async throws -> ShareMetadata } -@available(macOS 12, *) +@available(iOS 15, macOS 12, *) package struct ShareMetadata: Hashable { package var containerIdentifier: String package var hierarchicalRootRecordID: CKRecord.ID? package var rootRecord: CKRecord? + package var share: CKShare package var rawValue: CKShare.Metadata? package init(rawValue: CKShare.Metadata) { self.containerIdentifier = rawValue.containerIdentifier self.hierarchicalRootRecordID = rawValue.hierarchicalRootRecordID self.rootRecord = rawValue.rootRecord + self.share = rawValue.share self.rawValue = rawValue } package init( containerIdentifier: String, hierarchicalRootRecordID: CKRecord.ID?, - rootRecord: CKRecord? + rootRecord: CKRecord?, + share: CKShare ) { self.containerIdentifier = containerIdentifier self.hierarchicalRootRecordID = hierarchicalRootRecordID self.rootRecord = rootRecord + self.share = share self.rawValue = nil } } -@available(macOS 12, *) +@available(iOS 15, macOS 12, *) extension CloudContainer { package func database(for recordID: CKRecord.ID) -> any CloudDatabase { recordID.zoneID.ownerName == CKCurrentUserDefaultName @@ -49,7 +53,7 @@ extension CloudContainer { } } -@available(macOS 12, *) +@available(iOS 15, macOS 12, *) extension CKContainer: CloudContainer { package func accept(_ metadata: ShareMetadata) async throws -> CKShare { guard let metadata = metadata.rawValue diff --git a/Sources/SharingGRDBCore/CloudKit/CloudKit+StructuredQueries.swift b/Sources/SharingGRDBCore/CloudKit/CloudKit+StructuredQueries.swift index 5e47bcdf..40448072 100644 --- a/Sources/SharingGRDBCore/CloudKit/CloudKit+StructuredQueries.swift +++ b/Sources/SharingGRDBCore/CloudKit/CloudKit+StructuredQueries.swift @@ -243,7 +243,7 @@ extension CKRecord { let column = column as! any WritableTableColumnExpression let didSet: Bool if let value = other[key] as? CKAsset { - didSet = setValue(value, forKey: key, at: other.encryptedValues[at: key]) + didSet = setValue(value, forKey: key, at: other[at: key]) } else if let value = other.encryptedValues[key] as? any EquatableCKRecordValueProtocol { didSet = setValue(value, forKey: key, at: other.encryptedValues[at: key]) } else if other.encryptedValues[key] == nil { diff --git a/Sources/SharingGRDBCore/CloudKit/CloudKitSharing.swift b/Sources/SharingGRDBCore/CloudKit/CloudKitSharing.swift index 97a5ba54..c7b549c9 100644 --- a/Sources/SharingGRDBCore/CloudKit/CloudKitSharing.swift +++ b/Sources/SharingGRDBCore/CloudKit/CloudKitSharing.swift @@ -7,7 +7,7 @@ import SwiftUI import UIKit #endif -@available(macOS 12, *) +@available(iOS 15, macOS 12, *) public struct SharedRecord: Hashable, Identifiable, Sendable { let container: any CloudContainer public let share: CKShare diff --git a/Sources/SharingGRDBCore/CloudKit/Internal/MockCloudContainer.swift b/Sources/SharingGRDBCore/CloudKit/Internal/MockCloudContainer.swift index e2a21e57..39c11fe5 100644 --- a/Sources/SharingGRDBCore/CloudKit/Internal/MockCloudContainer.swift +++ b/Sources/SharingGRDBCore/CloudKit/Internal/MockCloudContainer.swift @@ -52,30 +52,25 @@ package final class MockCloudContainer: CloudContainer, CustomDumpReflectable { } } - return ShareMetadata.init( + return ShareMetadata( containerIdentifier: containerIdentifier!, hierarchicalRootRecordID: rootRecord?.recordID, - rootRecord: rootRecord + rootRecord: shouldFetchRootRecord ? rootRecord : nil, + share: share ) } package func accept(_ metadata: ShareMetadata) async throws -> CKShare { guard let rootRecord = metadata.rootRecord else { - struct SomeError: Error { let file = #fileID, line = #line } - throw SomeError() + fatalError("Must provide root record in mock shares during tests.") } - let share = CKShare.init( - rootRecord: rootRecord, - shareID: CKRecord.ID.init( - recordName: "Share-\(rootRecord.recordID.recordName)", - zoneID: rootRecord.recordID.zoneID - ) + let (saveResults, _) = try sharedCloudDatabase.modifyRecords( + saving: [metadata.share, rootRecord] ) - let (saveResults, _) = try sharedCloudDatabase.modifyRecords(saving: [share, rootRecord]) try saveResults.values.forEach { _ = try $0.get() } - return share + return metadata.share } package static func createContainer(identifier containerIdentifier: String) -> MockCloudContainer { diff --git a/Sources/SharingGRDBCore/CloudKit/Internal/MockCloudDatabase.swift b/Sources/SharingGRDBCore/CloudKit/Internal/MockCloudDatabase.swift index 9f30c059..9d988ca7 100644 --- a/Sources/SharingGRDBCore/CloudKit/Internal/MockCloudDatabase.swift +++ b/Sources/SharingGRDBCore/CloudKit/Internal/MockCloudDatabase.swift @@ -87,6 +87,7 @@ package final class MockCloudDatabase: CloudDatabase { switch savePolicy { case .ifServerRecordUnchanged: for recordToSave in recordsToSave { + // TODO: allow saving share alone if it has been previously saved if let share = recordToSave as? CKShare { let isSavingRootRecord = recordsToSave.contains(where: { $0.share?.recordID == share.recordID }) let rootRecordWasPreviouslySaved = storage[share.recordID.zoneID]?.contains(where: { @@ -138,6 +139,7 @@ package final class MockCloudDatabase: CloudDatabase { .load(assetURL) } } + // TODO: this should merge copy's values into storage storage[recordToSave.recordID.zoneID]?[recordToSave.recordID] = copy saveResults[recordToSave.recordID] = .success(copy) } diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift index bad180c2..28d37752 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift @@ -472,12 +472,11 @@ ) } - // TODO: Possible to get test coverage on this? package func acceptShare(metadata: ShareMetadata) async throws { guard let rootRecordID = metadata.hierarchicalRootRecordID else { reportIssue("Attempting to share without root record information.") - throw CKError(.invalidArguments) + return } let container = type(of: container).createContainer(identifier: metadata.containerIdentifier) _ = try await container.accept(metadata) @@ -1148,20 +1147,17 @@ private func cacheShare(_ share: CKShare) async throws { guard - let metadata = try? await container.shareMetadata( - for: share, - shouldFetchRootRecord: true - ) + let metadata = try? await container.shareMetadata(for: share, shouldFetchRootRecord: false) else { // TODO: should we delete this record if it doesn't exist in the container? return } - guard let rootRecord = metadata.rootRecord + guard let rootRecordID = metadata.hierarchicalRootRecordID else { return } try await userDatabase.write { db in try SyncMetadata - .where { $0.recordName.eq(rootRecord.recordID.recordName) } + .where { $0.recordName.eq(rootRecordID.recordName) } .update { $0.share = share } .execute(db) } diff --git a/Tests/SharingGRDBTests/CloudKitTests/SharingTests.swift b/Tests/SharingGRDBTests/CloudKitTests/SharingTests.swift index daef65b1..77d70069 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/SharingTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/SharingTests.swift @@ -494,13 +494,21 @@ extension BaseCloudKitTests { ) remindersListRecord.setValue(1, forKey: "id", at: now) remindersListRecord.setValue("Personal", forKey: "title", at: now) + let share = CKShare( + rootRecord: remindersListRecord, + shareID: CKRecord.ID( + recordName: "share-\(remindersListRecord.recordID.recordName)", + zoneID: remindersListRecord.recordID.zoneID + ) + ) try await syncEngine .acceptShare( metadata: ShareMetadata( containerIdentifier: container.containerIdentifier!, hierarchicalRootRecordID: remindersListRecord.recordID, - rootRecord: remindersListRecord + rootRecord: remindersListRecord, + share: share ) ) @@ -513,7 +521,7 @@ extension BaseCloudKitTests { ) #expect(remindersList.title == "Personal") #expect( - metadata.share?.recordID.recordName == "Share-\(remindersListRecord.recordID.recordName)" + metadata.share?.recordID.recordName == "share-\(remindersListRecord.recordID.recordName)" ) } @@ -528,7 +536,7 @@ extension BaseCloudKitTests { databaseScope: .shared, storage: [ [0]: CKRecord( - recordID: CKRecord.ID(Share-1:remindersLists/external.zone/external.owner), + recordID: CKRecord.ID(share-1:remindersLists/external.zone/external.owner), recordType: "cloudkit.share", parent: nil, share: nil @@ -537,7 +545,7 @@ extension BaseCloudKitTests { recordID: CKRecord.ID(1:remindersLists/external.zone/external.owner), recordType: "remindersLists", parent: nil, - share: CKReference(recordID: CKRecord.ID(Share-1:remindersLists/external.zone/external.owner)), + share: CKReference(recordID: CKRecord.ID(share-1:remindersLists/external.zone/external.owner)), id: 1, title: "Personal" ) @@ -565,13 +573,21 @@ extension BaseCloudKitTests { ) remindersListRecord.setValue(1, forKey: "id", at: now) remindersListRecord.setValue("Personal", forKey: "title", at: now) + let share = CKShare( + rootRecord: remindersListRecord, + shareID: CKRecord.ID( + recordName: "share-\(remindersListRecord.recordID.recordName)", + zoneID: remindersListRecord.recordID.zoneID + ) + ) try await syncEngine .acceptShare( metadata: ShareMetadata( containerIdentifier: container.containerIdentifier!, hierarchicalRootRecordID: remindersListRecord.recordID, - rootRecord: remindersListRecord + rootRecord: remindersListRecord, + share: share ) ) @@ -603,7 +619,7 @@ extension BaseCloudKitTests { databaseScope: .shared, storage: [ [0]: CKRecord( - recordID: CKRecord.ID(Share-1:remindersLists/external.zone/external.owner), + recordID: CKRecord.ID(share-1:remindersLists/external.zone/external.owner), recordType: "cloudkit.share", parent: nil, share: nil @@ -622,7 +638,7 @@ extension BaseCloudKitTests { recordID: CKRecord.ID(1:remindersLists/external.zone/external.owner), recordType: "remindersLists", parent: nil, - share: CKReference(recordID: CKRecord.ID(Share-1:remindersLists/external.zone/external.owner)), + share: CKReference(recordID: CKRecord.ID(share-1:remindersLists/external.zone/external.owner)), id: 1, title: "Personal" ) From e1ba6e172103fb6d9e86a6764ec88177e46ef566 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Fri, 15 Aug 2025 15:05:56 -0500 Subject: [PATCH 5/7] wip --- .../CloudKit/CloudContainer.swift | 8 +++---- .../CloudKit/CloudKitSharing.swift | 2 +- .../CloudKit/Internal/MockCloudDatabase.swift | 11 +++++----- .../MockCloudDatabaseTests.swift | 22 +++++++++++++++++++ 4 files changed, 32 insertions(+), 11 deletions(-) diff --git a/Sources/SharingGRDBCore/CloudKit/CloudContainer.swift b/Sources/SharingGRDBCore/CloudKit/CloudContainer.swift index 5a32837a..5627daf5 100644 --- a/Sources/SharingGRDBCore/CloudKit/CloudContainer.swift +++ b/Sources/SharingGRDBCore/CloudKit/CloudContainer.swift @@ -1,7 +1,7 @@ #if canImport(CloudKit) import CloudKit -@available(iOS 15, macOS 12, *) +@available(iOS 15, tvOS 15, macOS 12, watchOS 8, *) package protocol CloudContainer: AnyObject, Equatable, Hashable, Sendable { associatedtype Database: CloudDatabase @@ -16,7 +16,7 @@ package protocol CloudContainer: AnyObject, Equatable, Hashable, Senda func shareMetadata(for share: CKShare, shouldFetchRootRecord: Bool) async throws -> ShareMetadata } -@available(iOS 15, macOS 12, *) +@available(iOS 15, tvOS 15, macOS 12, watchOS 8, *) package struct ShareMetadata: Hashable { package var containerIdentifier: String package var hierarchicalRootRecordID: CKRecord.ID? @@ -44,7 +44,7 @@ package struct ShareMetadata: Hashable { } } -@available(iOS 15, macOS 12, *) +@available(iOS 15, tvOS 15, macOS 12, watchOS 8, *) extension CloudContainer { package func database(for recordID: CKRecord.ID) -> any CloudDatabase { recordID.zoneID.ownerName == CKCurrentUserDefaultName @@ -53,7 +53,7 @@ extension CloudContainer { } } -@available(iOS 15, macOS 12, *) +@available(iOS 15, tvOS 15, macOS 12, watchOS 8, *) extension CKContainer: CloudContainer { package func accept(_ metadata: ShareMetadata) async throws -> CKShare { guard let metadata = metadata.rawValue diff --git a/Sources/SharingGRDBCore/CloudKit/CloudKitSharing.swift b/Sources/SharingGRDBCore/CloudKit/CloudKitSharing.swift index c7b549c9..556b9b34 100644 --- a/Sources/SharingGRDBCore/CloudKit/CloudKitSharing.swift +++ b/Sources/SharingGRDBCore/CloudKit/CloudKitSharing.swift @@ -7,7 +7,7 @@ import SwiftUI import UIKit #endif -@available(iOS 15, macOS 12, *) +@available(iOS 15, tvOS 15, macOS 12, watchOS 8, *) public struct SharedRecord: Hashable, Identifiable, Sendable { let container: any CloudContainer public let share: CKShare diff --git a/Sources/SharingGRDBCore/CloudKit/Internal/MockCloudDatabase.swift b/Sources/SharingGRDBCore/CloudKit/Internal/MockCloudDatabase.swift index 9d988ca7..9d33bc7b 100644 --- a/Sources/SharingGRDBCore/CloudKit/Internal/MockCloudDatabase.swift +++ b/Sources/SharingGRDBCore/CloudKit/Internal/MockCloudDatabase.swift @@ -90,11 +90,8 @@ package final class MockCloudDatabase: CloudDatabase { // TODO: allow saving share alone if it has been previously saved if let share = recordToSave as? CKShare { let isSavingRootRecord = recordsToSave.contains(where: { $0.share?.recordID == share.recordID }) - let rootRecordWasPreviouslySaved = storage[share.recordID.zoneID]?.contains(where: { - _, record in record.share?.recordID == share.recordID - }) - ?? false - guard isSavingRootRecord || rootRecordWasPreviouslySaved + let shareWasPreviouslySaved = storage[share.recordID.zoneID]?[share.recordID] != nil + guard shareWasPreviouslySaved || isSavingRootRecord else { reportIssue( """ @@ -131,6 +128,7 @@ package final class MockCloudDatabase: CloudDatabase { guard let copy = recordToSave.copy() as? CKRecord else { fatalError("Could not copy CKRecord.") } copy._recordChangeTag = UUID().uuidString + assets.withValue { assets in for key in copy.allKeys() { guard let assetURL = (copy[key] as? CKAsset)?.fileURL @@ -139,7 +137,8 @@ package final class MockCloudDatabase: CloudDatabase { .load(assetURL) } } - // TODO: this should merge copy's values into storage + + // TODO: this should merge copy's values into storage but not sure how right now. storage[recordToSave.recordID.zoneID]?[recordToSave.recordID] = copy saveResults[recordToSave.recordID] = .success(copy) } diff --git a/Tests/SharingGRDBTests/CloudKitTests/MockCloudDatabaseTests.swift b/Tests/SharingGRDBTests/CloudKitTests/MockCloudDatabaseTests.swift index 31822f41..5b94aa00 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/MockCloudDatabaseTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/MockCloudDatabaseTests.swift @@ -388,5 +388,27 @@ extension BaseCloudKitTests { """ } } + + @Test func saveShareWithoutRootRecord() async throws { + let record = CKRecord(recordType: "A", recordID: CKRecord.ID(recordName: "1")) + let share = CKShare(rootRecord: record, shareID: CKRecord.ID(recordName: "share")) + try withKnownIssue { + _ = try syncEngine.modifyRecords(scope: .private, saving: [share]) + } matching: { issue in + issue.description == """ + Issue recorded: An added share is being saved without its rootRecord being saved in the \ + same operation. + """ + } + } + + @Test func saveShareAndRootThenSaveShareAlone() async throws { + let record = CKRecord(recordType: "A", recordID: CKRecord.ID(recordName: "1")) + let share = CKShare(rootRecord: record, shareID: CKRecord.ID(recordName: "share")) + _ = try syncEngine.modifyRecords(scope: .private, saving: [share, record]) + + let newShare = try syncEngine.private.database.record(for: CKRecord.ID(recordName: "share")) + _ = try syncEngine.modifyRecords(scope: .private, saving: [newShare]) + } } } From a98767861893644ae632211b4c3310e683fb5f64 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Fri, 15 Aug 2025 15:16:00 -0500 Subject: [PATCH 6/7] fixes --- .../CloudKit/Internal/MockCloudDatabase.swift | 1 - .../CloudKitTests/SharingTests.swift | 60 ++++++++++++++----- 2 files changed, 46 insertions(+), 15 deletions(-) diff --git a/Sources/SharingGRDBCore/CloudKit/Internal/MockCloudDatabase.swift b/Sources/SharingGRDBCore/CloudKit/Internal/MockCloudDatabase.swift index 9d33bc7b..7bf55819 100644 --- a/Sources/SharingGRDBCore/CloudKit/Internal/MockCloudDatabase.swift +++ b/Sources/SharingGRDBCore/CloudKit/Internal/MockCloudDatabase.swift @@ -87,7 +87,6 @@ package final class MockCloudDatabase: CloudDatabase { switch savePolicy { case .ifServerRecordUnchanged: for recordToSave in recordsToSave { - // TODO: allow saving share alone if it has been previously saved if let share = recordToSave as? CKShare { let isSavingRootRecord = recordsToSave.contains(where: { $0.share?.recordID == share.recordID }) let shareWasPreviouslySaved = storage[share.recordID.zoneID]?[share.recordID] != nil diff --git a/Tests/SharingGRDBTests/CloudKitTests/SharingTests.swift b/Tests/SharingGRDBTests/CloudKitTests/SharingTests.swift index 77d70069..89965b70 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/SharingTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/SharingTests.swift @@ -215,7 +215,7 @@ extension BaseCloudKitTests { } @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - func shareDelieveredBeforeRecord() async throws { + @Test func shareDelieveredBeforeRecord() async throws { let externalZone = CKRecordZone( zoneID: CKRecordZone.ID( zoneName: "external.zone", @@ -235,28 +235,32 @@ extension BaseCloudKitTests { let share = CKShare( rootRecord: remindersListRecord, shareID: CKRecord.ID( - recordName: "Share-1", + recordName: "share-\(remindersListRecord.recordID.recordName)", zoneID: externalZone.zoneID ) ) - _ = try syncEngine.modifyRecords(scope: .private, saving: [share, remindersListRecord]) + _ = try syncEngine.modifyRecords(scope: .shared, saving: [share, remindersListRecord]) - let newShare = try syncEngine.private.database.record(for: share.recordID) - let newRemindersListRecord = try syncEngine.private.database.record( + let newShare = try syncEngine.shared.database.record(for: share.recordID) + let newRemindersListRecord = try syncEngine.shared.database.record( for: remindersListRecord.recordID ) - try await syncEngine.modifyRecords(scope: .private, saving: [newShare]).notify() - try await syncEngine.modifyRecords(scope: .private, saving: [newRemindersListRecord]).notify() + try await syncEngine.modifyRecords(scope: .shared, saving: [newShare]).notify() + try await syncEngine.modifyRecords(scope: .shared, saving: [newRemindersListRecord]).notify() assertInlineSnapshot(of: syncEngine.container, as: .customDump) { """ MockCloudContainer( privateCloudDatabase: MockCloudDatabase( databaseScope: .private, + storage: [] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, storage: [ [0]: CKRecord( - recordID: CKRecord.ID(Share-1/external.zone/external.owner), + recordID: CKRecord.ID(share-1:remindersLists/external.zone/external.owner), recordType: "cloudkit.share", parent: nil, share: nil @@ -265,16 +269,12 @@ extension BaseCloudKitTests { recordID: CKRecord.ID(1:remindersLists/external.zone/external.owner), recordType: "remindersLists", parent: nil, - share: CKReference(recordID: CKRecord.ID(Share-1/external.zone/external.owner)), + share: CKReference(recordID: CKRecord.ID(share-1:remindersLists/external.zone/external.owner)), id: 1, isCompleted: 0, title: "Personal" ) ] - ), - sharedCloudDatabase: MockCloudDatabase( - databaseScope: .shared, - storage: [] ) ) """ @@ -285,7 +285,39 @@ extension BaseCloudKitTests { } assertInlineSnapshot(of: metadata, as: .customDump) { """ - [] + [ + [0]: SyncMetadata( + recordPrimaryKey: "1", + recordType: "remindersLists", + recordName: "1:remindersLists", + parentRecordPrimaryKey: nil, + parentRecordType: nil, + parentRecordName: nil, + lastKnownServerRecord: CKRecord( + recordID: CKRecord.ID(1:remindersLists/external.zone/external.owner), + recordType: "remindersLists", + parent: nil, + share: CKReference(recordID: CKRecord.ID(share-1:remindersLists/external.zone/external.owner)) + ), + _lastKnownServerRecordAllFields: CKRecord( + recordID: CKRecord.ID(1:remindersLists/external.zone/external.owner), + recordType: "remindersLists", + parent: nil, + share: CKReference(recordID: CKRecord.ID(share-1:remindersLists/external.zone/external.owner)), + id: 1, + isCompleted: 0, + title: "Personal" + ), + share: CKRecord( + recordID: CKRecord.ID(share-1:remindersLists/external.zone/external.owner), + recordType: "cloudkit.share", + parent: nil, + share: nil + ), + isShared: true, + userModificationDate: Date(1970-01-01T00:00:00.000Z) + ) + ] """ } } From 43787236a484c89614515ad31d09fd6e04ff9e57 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Fri, 15 Aug 2025 15:26:18 -0500 Subject: [PATCH 7/7] wip --- .../CloudKit/Internal/MockSyncEngine.swift | 20 +++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/Sources/SharingGRDBCore/CloudKit/Internal/MockSyncEngine.swift b/Sources/SharingGRDBCore/CloudKit/Internal/MockSyncEngine.swift index 503e73a1..07bee97f 100644 --- a/Sources/SharingGRDBCore/CloudKit/Internal/MockSyncEngine.swift +++ b/Sources/SharingGRDBCore/CloudKit/Internal/MockSyncEngine.swift @@ -34,18 +34,22 @@ package final class MockSyncEngine: SyncEngineProtocol { package func fetchChanges(_ options: CKSyncEngine.FetchChangesOptions) async throws { let records: [CKRecord] + let zoneIDs: [CKRecordZone.ID] switch options.scope { - case .all, .allExcluding: - fatalError() - case .zoneIDs(let zoneIDs): - records = zoneIDs.reduce(into: [CKRecord]()) { accum, zoneID in - accum += database.storage.withValue { - ($0[zoneID]?.values).map { Array($0) } ?? [] - } - } + case .all: + zoneIDs = Array(database.storage.keys) + case .allExcluding(let excludedZoneIDs): + zoneIDs = Array(Set(database.storage.keys).subtracting(excludedZoneIDs)) + case .zoneIDs(let includedZoneIDs): + zoneIDs = includedZoneIDs @unknown default: fatalError() } + records = zoneIDs.reduce(into: [CKRecord]()) { accum, zoneID in + accum += database.storage.withValue { + ($0[zoneID]?.values).map { Array($0) } ?? [] + } + } await delegate.handleEvent( .fetchedRecordZoneChanges(modifications: records, deletions: []), syncEngine: self