From d6bd53bac3f0c3651f0c6a20553fd7ff02ddf0fe Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Wed, 13 Aug 2025 12:54:54 -0500 Subject: [PATCH 1/6] wip --- Examples/Examples.xcodeproj/project.pbxproj | 56 +- .../xcshareddata/swiftpm/Package.resolved | 4 +- Examples/Reminders/RemindersApp.swift | 12 +- Examples/Reminders/Schema.swift | 14 + Examples/Reminders/TagsForm.swift | 1 + Examples/RemindersTests/Internal.swift | 43 +- .../RemindersDetailsTests.swift | 1 + .../RemindersTests/RemindersListsTests.swift | 1 + .../RemindersTests/SearchRemindersTests.swift | 1 + .../CloudKit}/Internal/IsolatedWeakVar.swift | 0 .../Internal/MockCloudContainer.swift | 101 +++ .../CloudKit/Internal/MockCloudDatabase.swift | 283 ++++++++ .../CloudKit/Internal/MockSyncEngine.swift | 268 ++++++++ .../SharingGRDBCore/CloudKit/SyncEngine.swift | 63 +- .../CloudKitTests/CloudKitTests.swift | 44 +- .../CloudKitTests/MetadataTests.swift | 9 +- .../CloudKitTests/RecordTypeTests.swift | 14 +- .../CloudKitTests/TriggerTests.swift | 6 +- .../Internal/BaseCloudKitTests.swift | 2 - .../Internal/CloudKitTestHelpers.swift | 630 ------------------ Tests/SharingGRDBTests/Internal/Schema.swift | 10 +- 21 files changed, 808 insertions(+), 755 deletions(-) rename {Tests/SharingGRDBTests => Sources/SharingGRDBCore/CloudKit}/Internal/IsolatedWeakVar.swift (100%) create mode 100644 Sources/SharingGRDBCore/CloudKit/Internal/MockCloudContainer.swift create mode 100644 Sources/SharingGRDBCore/CloudKit/Internal/MockCloudDatabase.swift create mode 100644 Sources/SharingGRDBCore/CloudKit/Internal/MockSyncEngine.swift diff --git a/Examples/Examples.xcodeproj/project.pbxproj b/Examples/Examples.xcodeproj/project.pbxproj index b6566760..1de241e6 100644 --- a/Examples/Examples.xcodeproj/project.pbxproj +++ b/Examples/Examples.xcodeproj/project.pbxproj @@ -7,14 +7,13 @@ objects = { /* Begin PBXBuildFile section */ + CA0F43C32E4D06740086070B /* SharingGRDB in Frameworks */ = {isa = PBXBuildFile; productRef = CA0F43C22E4D06740086070B /* SharingGRDB */; }; + CA0F43C62E4D06C60086070B /* GRDB in Frameworks */ = {isa = PBXBuildFile; productRef = CA0F43C52E4D06C60086070B /* GRDB */; }; CA1146CA2DF38D1D0054BA77 /* SharingGRDB in Frameworks */ = {isa = PBXBuildFile; productRef = CA1146C92DF38D1D0054BA77 /* SharingGRDB */; }; CA14DBC92DA884C400E36852 /* CasePaths in Frameworks */ = {isa = PBXBuildFile; productRef = CA14DBC82DA884C400E36852 /* CasePaths */; }; CA2908C92D4AF70E003F165F /* UIKitNavigation in Frameworks */ = {isa = PBXBuildFile; productRef = CA2908C82D4AF70E003F165F /* UIKitNavigation */; }; CA42392E2DF7219E000AF560 /* SwiftUINavigation in Frameworks */ = {isa = PBXBuildFile; productRef = CA42392D2DF7219E000AF560 /* SwiftUINavigation */; }; CA9102EB2E1F299900F85DD0 /* SharingGRDB in Frameworks */ = {isa = PBXBuildFile; productRef = CA9102EA2E1F299900F85DD0 /* SharingGRDB */; }; - CA9102ED2E1F29A400F85DD0 /* SharingGRDB in Frameworks */ = {isa = PBXBuildFile; productRef = CA9102EC2E1F29A400F85DD0 /* SharingGRDB */; }; - CA9102EF2E1F29AA00F85DD0 /* SharingGRDB in Frameworks */ = {isa = PBXBuildFile; productRef = CA9102EE2E1F29AA00F85DD0 /* SharingGRDB */; }; - CA9102F12E1F29E300F85DD0 /* SharingGRDB in Frameworks */ = {isa = PBXBuildFile; productRef = CA9102F02E1F29E300F85DD0 /* SharingGRDB */; }; CA9F99D82DF915D300934431 /* DependenciesTestSupport in Frameworks */ = {isa = PBXBuildFile; productRef = CA9F99D72DF915D300934431 /* DependenciesTestSupport */; }; CA9F99DD2DF9185A00934431 /* SnapshotTestingCustomDump in Frameworks */ = {isa = PBXBuildFile; productRef = CA9F99DC2DF9185A00934431 /* SnapshotTestingCustomDump */; }; CA9F99DF2DF9190C00934431 /* InlineSnapshotTesting in Frameworks */ = {isa = PBXBuildFile; productRef = CA9F99DE2DF9190C00934431 /* InlineSnapshotTesting */; }; @@ -195,7 +194,6 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - CA9102F12E1F29E300F85DD0 /* SharingGRDB in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -206,6 +204,7 @@ CA9F99DD2DF9185A00934431 /* SnapshotTestingCustomDump in Frameworks */, CA9F99D82DF915D300934431 /* DependenciesTestSupport in Frameworks */, CA9F99DF2DF9190C00934431 /* InlineSnapshotTesting in Frameworks */, + CA0F43C62E4D06C60086070B /* GRDB in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -239,7 +238,7 @@ buildActionMask = 2147483647; files = ( CA14DBC92DA884C400E36852 /* CasePaths in Frameworks */, - CA9102ED2E1F29A400F85DD0 /* SharingGRDB in Frameworks */, + CA0F43C32E4D06740086070B /* SharingGRDB in Frameworks */, DC7082542E035FC500A66B7D /* SwiftUINavigation in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -250,7 +249,6 @@ files = ( DCF267392D48437300B680BE /* SwiftUINavigation in Frameworks */, DC5FA7482D4C63D60082743E /* DependenciesMacros in Frameworks */, - CA9102EF2E1F29AA00F85DD0 /* SharingGRDB in Frameworks */, DCBE8A142D4842BF0071F499 /* CasePaths in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -367,7 +365,6 @@ ); name = CloudKitPlayground; packageProductDependencies = ( - CA9102F02E1F29E300F85DD0 /* SharingGRDB */, ); productName = CloudKitPlayground; productReference = CA9101C82E1F270100F85DD0 /* CloudKitPlayground.app */; @@ -394,6 +391,7 @@ CA9F99D72DF915D300934431 /* DependenciesTestSupport */, CA9F99DC2DF9185A00934431 /* SnapshotTestingCustomDump */, CA9F99DE2DF9190C00934431 /* InlineSnapshotTesting */, + CA0F43C52E4D06C60086070B /* GRDB */, ); productName = RemindersTests; productReference = CA9F99482DF9134D00934431 /* RemindersTests.xctest */; @@ -490,7 +488,7 @@ packageProductDependencies = ( CA14DBC82DA884C400E36852 /* CasePaths */, DC7082532E035FC500A66B7D /* SwiftUINavigation */, - CA9102EC2E1F29A400F85DD0 /* SharingGRDB */, + CA0F43C22E4D06740086070B /* SharingGRDB */, ); productName = Reminders; productReference = CAF836D82D4735AB0047AEB5 /* Reminders.app */; @@ -516,7 +514,6 @@ DCBE8A132D4842BF0071F499 /* CasePaths */, DCF267382D48437300B680BE /* SwiftUINavigation */, DC5FA7472D4C63D60082743E /* DependenciesMacros */, - CA9102EE2E1F29AA00F85DD0 /* SharingGRDB */, ); productName = SyncUps; productReference = DCBE89CC2D483FB90071F499 /* SyncUps.app */; @@ -578,7 +575,8 @@ DCBE8A122D4842BF0071F499 /* XCRemoteSwiftPackageReference "swift-case-paths" */, DCF267372D48437300B680BE /* XCRemoteSwiftPackageReference "swift-navigation" */, DC5FA7462D4C63D60082743E /* XCRemoteSwiftPackageReference "swift-dependencies" */, - CA9102E92E1F299900F85DD0 /* XCLocalSwiftPackageReference ".." */, + CA0F43C12E4D06740086070B /* XCLocalSwiftPackageReference "../../sharing-grdb" */, + CA0F43C42E4D06C60086070B /* XCRemoteSwiftPackageReference "GRDB" */, ); preferredProjectObjectVersion = 77; productRefGroup = CAF836992D4735620047AEB5 /* Products */; @@ -1072,6 +1070,7 @@ SDKROOT = iphoneos; SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; SWIFT_VERSION = 6.0; }; name = Debug; @@ -1128,6 +1127,7 @@ MTL_FAST_MATH = YES; SDKROOT = iphoneos; SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; SWIFT_VERSION = 6.0; VALIDATE_PRODUCT = YES; }; @@ -1433,13 +1433,21 @@ /* End XCConfigurationList section */ /* Begin XCLocalSwiftPackageReference section */ - CA9102E92E1F299900F85DD0 /* XCLocalSwiftPackageReference ".." */ = { + CA0F43C12E4D06740086070B /* XCLocalSwiftPackageReference "../../sharing-grdb" */ = { isa = XCLocalSwiftPackageReference; - relativePath = ".."; + relativePath = "../../sharing-grdb"; }; /* End XCLocalSwiftPackageReference section */ /* Begin XCRemoteSwiftPackageReference section */ + CA0F43C42E4D06C60086070B /* XCRemoteSwiftPackageReference "GRDB" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "http://github.com/groue/GRDB.swift"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 7.0.0; + }; + }; CA9F99D92DF9185A00934431 /* XCRemoteSwiftPackageReference "swift-snapshot-testing" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/pointfreeco/swift-snapshot-testing.git"; @@ -1475,6 +1483,15 @@ /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ + CA0F43C22E4D06740086070B /* SharingGRDB */ = { + isa = XCSwiftPackageProductDependency; + productName = SharingGRDB; + }; + CA0F43C52E4D06C60086070B /* GRDB */ = { + isa = XCSwiftPackageProductDependency; + package = CA0F43C42E4D06C60086070B /* XCRemoteSwiftPackageReference "GRDB" */; + productName = GRDB; + }; CA1146C92DF38D1D0054BA77 /* SharingGRDB */ = { isa = XCSwiftPackageProductDependency; productName = SharingGRDB; @@ -1498,21 +1515,6 @@ isa = XCSwiftPackageProductDependency; productName = SharingGRDB; }; - CA9102EC2E1F29A400F85DD0 /* SharingGRDB */ = { - isa = XCSwiftPackageProductDependency; - package = CA9102E92E1F299900F85DD0 /* XCLocalSwiftPackageReference ".." */; - productName = SharingGRDB; - }; - CA9102EE2E1F29AA00F85DD0 /* SharingGRDB */ = { - isa = XCSwiftPackageProductDependency; - package = CA9102E92E1F299900F85DD0 /* XCLocalSwiftPackageReference ".." */; - productName = SharingGRDB; - }; - CA9102F02E1F29E300F85DD0 /* SharingGRDB */ = { - isa = XCSwiftPackageProductDependency; - package = CA9102E92E1F299900F85DD0 /* XCLocalSwiftPackageReference ".." */; - productName = SharingGRDB; - }; CA9F99D72DF915D300934431 /* DependenciesTestSupport */ = { isa = XCSwiftPackageProductDependency; package = DC5FA7462D4C63D60082743E /* XCRemoteSwiftPackageReference "swift-dependencies" */; diff --git a/Examples/Examples.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Examples/Examples.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 5c8d479d..75543a5a 100644 --- a/Examples/Examples.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Examples/Examples.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "80ce8831f89d2da19d6c4e6f30a71328a79a080602e0d57e255665812e2823d7", + "originHash" : "36222981020fdf2c13a4a981b023e3be675cc4aebd4b842b561cd1f9bff923d0", "pins" : [ { "identity" : "combine-schedulers", @@ -13,7 +13,7 @@ { "identity" : "grdb.swift", "kind" : "remoteSourceControl", - "location" : "https://github.com/groue/GRDB.swift", + "location" : "http://github.com/groue/GRDB.swift", "state" : { "revision" : "a5a1be26b4513dc7ec360eb56bc08a345bac6649", "version" : "7.5.0" diff --git a/Examples/Reminders/RemindersApp.swift b/Examples/Reminders/RemindersApp.swift index 115e51f3..b1f3690f 100644 --- a/Examples/Reminders/RemindersApp.swift +++ b/Examples/Reminders/RemindersApp.swift @@ -1,4 +1,6 @@ import CloudKit +import Combine +import Dependencies import SharingGRDB import SwiftUI import UIKit @@ -12,15 +14,7 @@ struct RemindersApp: App { init() { if context == .live { try! prepareDependencies { - $0.defaultDatabase = try Reminders.appDatabase() - $0.defaultSyncEngine = try SyncEngine( - for: $0.defaultDatabase, - tables: RemindersList.self, - RemindersListAsset.self, - Reminder.self, - Tag.self, - ReminderTag.self - ) + try $0.bootstrapDatabase() } } } diff --git a/Examples/Reminders/Schema.swift b/Examples/Reminders/Schema.swift index c5b89c88..973b3295 100644 --- a/Examples/Reminders/Schema.swift +++ b/Examples/Reminders/Schema.swift @@ -104,6 +104,20 @@ struct ReminderTag: Hashable, Identifiable { var tagID: Tag.ID } +extension DependencyValues { + mutating func bootstrapDatabase() throws { + defaultDatabase = try Reminders.appDatabase() + defaultSyncEngine = try SyncEngine( + for: defaultDatabase, + tables: RemindersList.self, + RemindersListAsset.self, + Reminder.self, + Tag.self, + ReminderTag.self + ) + } +} + func appDatabase() throws -> any DatabaseWriter { @Dependency(\.context) var context let database: any DatabaseWriter diff --git a/Examples/Reminders/TagsForm.swift b/Examples/Reminders/TagsForm.swift index 3545a977..5bfb7c29 100644 --- a/Examples/Reminders/TagsForm.swift +++ b/Examples/Reminders/TagsForm.swift @@ -1,5 +1,6 @@ import SharingGRDB import SwiftUI +import SwiftUINavigation struct TagsView: View { @Fetch(Tags()) var tags = Tags.Value() diff --git a/Examples/RemindersTests/Internal.swift b/Examples/RemindersTests/Internal.swift index 82a02504..944177d3 100644 --- a/Examples/RemindersTests/Internal.swift +++ b/Examples/RemindersTests/Internal.swift @@ -1,5 +1,8 @@ +import Dependencies +import DependenciesTestSupport import Foundation import SharingGRDB +import SnapshotTesting import SwiftUI import Testing @@ -8,7 +11,7 @@ import Testing @Suite( .dependencies { $0.date.now = baseDate - $0.defaultDatabase = try Reminders.appDatabase() + try $0.bootstrapDatabase() try $0.defaultDatabase.write { try $0.seedTestData() } }, .snapshots(record: .failed) @@ -132,25 +135,25 @@ extension Database { remindersListID: UUID(2), title: "Prepare for WWDC" ) - Tag(id: UUID(0), title: "car") - Tag(id: UUID(1), title: "kids") - Tag(id: UUID(2), title: "someday") - Tag(id: UUID(3), title: "optional") - Tag(id: UUID(4), title: "social") - Tag(id: UUID(5), title: "night") - Tag(id: UUID(6), title: "adulting") - ReminderTag.Draft(reminderID: UUID(0), tagID: UUID(2)) - ReminderTag.Draft(reminderID: UUID(0), tagID: UUID(3)) - ReminderTag.Draft(reminderID: UUID(0), tagID: UUID(6)) - ReminderTag.Draft(reminderID: UUID(1), tagID: UUID(2)) - ReminderTag.Draft(reminderID: UUID(1), tagID: UUID(3)) - ReminderTag.Draft(reminderID: UUID(2), tagID: UUID(6)) - ReminderTag.Draft(reminderID: UUID(3), tagID: UUID(0)) - ReminderTag.Draft(reminderID: UUID(3), tagID: UUID(1)) - ReminderTag.Draft(reminderID: UUID(4), tagID: UUID(4)) - ReminderTag.Draft(reminderID: UUID(3), tagID: UUID(4)) - ReminderTag.Draft(reminderID: UUID(10), tagID: UUID(4)) - ReminderTag.Draft(reminderID: UUID(4), tagID: UUID(5)) + Tag(title: "car") + Tag(title: "kids") + Tag(title: "someday") + Tag(title: "optional") + Tag(title: "social") + Tag(title: "night") + Tag(title: "adulting") + ReminderTag.Draft(reminderID: UUID(0), tagID: "someday") + ReminderTag.Draft(reminderID: UUID(0), tagID: "optional") + ReminderTag.Draft(reminderID: UUID(0), tagID: "adulting") + ReminderTag.Draft(reminderID: UUID(1), tagID: "someday") + ReminderTag.Draft(reminderID: UUID(1), tagID: "optional") + ReminderTag.Draft(reminderID: UUID(2), tagID: "adulting") + ReminderTag.Draft(reminderID: UUID(3), tagID: "car") + ReminderTag.Draft(reminderID: UUID(3), tagID: "kids") + ReminderTag.Draft(reminderID: UUID(4), tagID: "social") + ReminderTag.Draft(reminderID: UUID(3), tagID: "social") + ReminderTag.Draft(reminderID: UUID(10), tagID: "social") + ReminderTag.Draft(reminderID: UUID(4), tagID: "night") } } } diff --git a/Examples/RemindersTests/RemindersDetailsTests.swift b/Examples/RemindersTests/RemindersDetailsTests.swift index 89529d88..65cc5475 100644 --- a/Examples/RemindersTests/RemindersDetailsTests.swift +++ b/Examples/RemindersTests/RemindersDetailsTests.swift @@ -1,6 +1,7 @@ import Dependencies import DependenciesTestSupport import InlineSnapshotTesting +import SharingGRDB import SnapshotTestingCustomDump import Testing diff --git a/Examples/RemindersTests/RemindersListsTests.swift b/Examples/RemindersTests/RemindersListsTests.swift index 28eef9b3..2e4becfb 100644 --- a/Examples/RemindersTests/RemindersListsTests.swift +++ b/Examples/RemindersTests/RemindersListsTests.swift @@ -1,4 +1,5 @@ import InlineSnapshotTesting +import SharingGRDB import SnapshotTestingCustomDump import Testing diff --git a/Examples/RemindersTests/SearchRemindersTests.swift b/Examples/RemindersTests/SearchRemindersTests.swift index de0c53c5..4defe8b7 100644 --- a/Examples/RemindersTests/SearchRemindersTests.swift +++ b/Examples/RemindersTests/SearchRemindersTests.swift @@ -1,6 +1,7 @@ import Dependencies import DependenciesTestSupport import InlineSnapshotTesting +import SharingGRDB import SnapshotTestingCustomDump import Testing diff --git a/Tests/SharingGRDBTests/Internal/IsolatedWeakVar.swift b/Sources/SharingGRDBCore/CloudKit/Internal/IsolatedWeakVar.swift similarity index 100% rename from Tests/SharingGRDBTests/Internal/IsolatedWeakVar.swift rename to Sources/SharingGRDBCore/CloudKit/Internal/IsolatedWeakVar.swift diff --git a/Sources/SharingGRDBCore/CloudKit/Internal/MockCloudContainer.swift b/Sources/SharingGRDBCore/CloudKit/Internal/MockCloudContainer.swift new file mode 100644 index 00000000..e45f977f --- /dev/null +++ b/Sources/SharingGRDBCore/CloudKit/Internal/MockCloudContainer.swift @@ -0,0 +1,101 @@ +import CustomDump +import CloudKit + +@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) +package final class MockCloudContainer: CloudContainer, CustomDumpReflectable { + package let _accountStatus: LockIsolated + package let containerIdentifier: String? + package let privateCloudDatabase: MockCloudDatabase + package let sharedCloudDatabase: MockCloudDatabase + + package init( + accountStatus: CKAccountStatus = .available, + containerIdentifier: String?, + privateCloudDatabase: MockCloudDatabase, + sharedCloudDatabase: MockCloudDatabase + ) { + self._accountStatus = LockIsolated(accountStatus) + self.containerIdentifier = containerIdentifier + self.privateCloudDatabase = privateCloudDatabase + self.sharedCloudDatabase = sharedCloudDatabase + } + + package func accountStatus() -> CKAccountStatus { + _accountStatus.withValue(\.self) + } + + package var rawValue: CKContainer { + fatalError("This should never be called in tests.") + } + + package func accountStatus() async throws -> CKAccountStatus { + _accountStatus.withValue { $0 } + } + + package func shareMetadata(for url: URL, shouldFetchRootRecord: Bool) async throws -> CKShare.Metadata { + fatalError() + } + + package func accept(_ metadata: CKShare.Metadata) async throws -> CKShare { + fatalError() + } + + package static func createContainer(identifier containerIdentifier: String) -> MockCloudContainer { + @Dependency(\.mockCloudContainers) var mockCloudContainers + return mockCloudContainers.withValue { storage in + let container: MockCloudContainer + if let existingContainer = storage[containerIdentifier] { + container = existingContainer + } else { + container = MockCloudContainer( + accountStatus: .available, + containerIdentifier: containerIdentifier, + privateCloudDatabase: MockCloudDatabase(databaseScope: .private), + sharedCloudDatabase: MockCloudDatabase(databaseScope: .shared) + ) + container.privateCloudDatabase.set(container: container) + container.sharedCloudDatabase.set(container: container) + } + storage[containerIdentifier] = container + return container + } + } + + package static func == (lhs: MockCloudContainer, rhs: MockCloudContainer) -> Bool { + lhs === rhs + } + + package func hash(into hasher: inout Hasher) { + hasher.combine(ObjectIdentifier(self)) + } + + package var customDumpMirror: Mirror { + Mirror.init( + self, + children: [ + ("privateCloudDatabase", privateCloudDatabase), + ("sharedCloudDatabase", sharedCloudDatabase), + ], + displayStyle: .struct + ) + } +} + +@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) +private enum MockCloudContainersKey: TestDependencyKey { + static var testValue: LockIsolated<[String: MockCloudContainer]> { + LockIsolated<[String: MockCloudContainer]>([:]) + } +} + +extension DependencyValues { + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + package var mockCloudContainers: LockIsolated<[String: MockCloudContainer]> { + get { + self[MockCloudContainersKey.self] + } + set { + self[MockCloudContainersKey.self] = newValue + } + } +} diff --git a/Sources/SharingGRDBCore/CloudKit/Internal/MockCloudDatabase.swift b/Sources/SharingGRDBCore/CloudKit/Internal/MockCloudDatabase.swift new file mode 100644 index 00000000..1fb82e7b --- /dev/null +++ b/Sources/SharingGRDBCore/CloudKit/Internal/MockCloudDatabase.swift @@ -0,0 +1,283 @@ +import CloudKit +import CustomDump +import IssueReporting + +@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) +package final class MockCloudDatabase: CloudDatabase { + package let storage = LockIsolated<[CKRecordZone.ID: [CKRecord.ID: CKRecord]]>([:]) + let assets = LockIsolated<[AssetID: Data]>([:]) + package let databaseScope: CKDatabase.Scope + let _container = IsolatedWeakVar() + + let dataManager = Dependency(\.dataManager) + + struct AssetID: Hashable { + let recordID: CKRecord.ID + let key: String + } + + package init(databaseScope: CKDatabase.Scope) { + self.databaseScope = databaseScope + } + + package func set(container: MockCloudContainer) { + _container.set(container) + } + + package var container: MockCloudContainer { + _container.value! + } + + package func record(for recordID: CKRecord.ID) throws -> CKRecord { + let accountStatus = container.accountStatus() + guard accountStatus == .available + else { throw ckError(forAccountStatus: accountStatus) } + guard let zone = storage[recordID.zoneID] + else { throw CKError(.zoneNotFound) } + guard let record = zone[recordID] + else { throw CKError(.unknownItem) } + guard let record = record.copy() as? CKRecord + else { fatalError("Could not copy CKRecord.") } + + try assets.withValue { assets in + for key in record.allKeys() { + guard let assetData = assets[AssetID(recordID: record.recordID, key: key)] + else { continue } + let url = URL(filePath: UUID().uuidString.lowercased()) + try dataManager.wrappedValue.save(assetData, to: url) + record[key] = CKAsset(fileURL: url) + } + } + + return record + } + + package func records( + for ids: [CKRecord.ID], + desiredKeys: [CKRecord.FieldKey]? + ) throws -> [CKRecord.ID: Result] { + let accountStatus = container.accountStatus() + guard accountStatus == .available + else { throw ckError(forAccountStatus: accountStatus) } + + var results: [CKRecord.ID: Result] = [:] + for id in ids { + results[id] = Result { try record(for: id) } + } + return results + } + + package func modifyRecords( + saving recordsToSave: [CKRecord] = [], + deleting recordIDsToDelete: [CKRecord.ID] = [], + savePolicy: CKModifyRecordsOperation.RecordSavePolicy = .ifServerRecordUnchanged, + atomically: Bool = true + ) throws -> ( + saveResults: [CKRecord.ID: Result], + deleteResults: [CKRecord.ID: Result] + ) { + let accountStatus = container.accountStatus() + guard accountStatus == .available + else { throw ckError(forAccountStatus: accountStatus) } + + return storage.withValue { storage in + var saveResults: [CKRecord.ID: Result] = [:] + var deleteResults: [CKRecord.ID: Result] = [:] + + switch savePolicy { + case .ifServerRecordUnchanged: + for recordToSave in recordsToSave { + guard storage[recordToSave.recordID.zoneID] != nil + else { + saveResults[recordToSave.recordID] = .failure(CKError(.zoneNotFound)) + continue + } + + let existingRecord = storage[recordToSave.recordID.zoneID]?[recordToSave.recordID] + + func saveRecordToDatabase() { + let hasReferenceViolation = + recordToSave.parent.map { parent in + storage[parent.recordID.zoneID]?[parent.recordID] == nil + && !recordsToSave.contains { $0.recordID == parent.recordID } + } + ?? false + guard !hasReferenceViolation + else { + saveResults[recordToSave.recordID] = .failure(CKError(.referenceViolation)) + return + } + + 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 + else { continue } + assets[AssetID(recordID: copy.recordID, key: key)] = try? dataManager.wrappedValue + .load(assetURL) + } + } + storage[recordToSave.recordID.zoneID]?[recordToSave.recordID] = copy + saveResults[recordToSave.recordID] = .success(copy) + } + + switch (existingRecord, recordToSave._recordChangeTag) { + case (.some(let existingRecord), .some(let recordToSaveChangeTag)): + // We are trying to save a record with a change tag that also already exists in the + // DB. If the tags match, we can save the record. Otherwise, we notify the sync engine + // that the server record has changed since it was last synced. + if existingRecord._recordChangeTag == recordToSaveChangeTag { + precondition(existingRecord._recordChangeTag != nil) + saveRecordToDatabase() + } else { + saveResults[recordToSave.recordID] = .failure( + CKError( + .serverRecordChanged, + userInfo: [ + CKRecordChangedErrorServerRecordKey: existingRecord.copy() as Any, + CKRecordChangedErrorClientRecordKey: recordToSave.copy(), + ] + ) + ) + } + break + case (.some(let existingRecord), .none): + // We are trying to save a record that does not have a change tag yet also already + // exists in the DB. This means the user has created a new CKRecord from scratch, + // giving it a new identity, rather than leveraging an existing CKRecord. + reportIssue( + """ + A new identity was created for an existing 'CKRecord' \ + ('\(existingRecord.recordID.recordName)'). Rather than creating \ + 'CKRecord' from scratch for an existing record, use the database to fetch the \ + current record. + """ + ) + saveResults[recordToSave.recordID] = .failure( + CKError( + .serverRejectedRequest, + userInfo: [ + CKRecordChangedErrorServerRecordKey: existingRecord.copy() as Any, + CKRecordChangedErrorClientRecordKey: recordToSave.copy(), + ] + ) + ) + case (.none, .some): + // We are trying to save a record with a change tag but it does not exist in the DB. + // This means the record was deleted by another device. + saveResults[recordToSave.recordID] = .failure(CKError(.unknownItem)) + case (.none, .none): + // We are trying to save a record with no change tag and no existing record in the DB. + // This means it's a brand new record. + saveRecordToDatabase() + } + } + case .allKeys, .changedKeys: + fatalError() + @unknown default: + fatalError() + } + for recordIDToDelete in recordIDsToDelete { + guard storage[recordIDToDelete.zoneID] != nil + else { + deleteResults[recordIDToDelete] = .failure(CKError(.zoneNotFound)) + continue + } + let hasReferenceViolation = !Set( + storage[recordIDToDelete.zoneID]?.values + .compactMap { $0.parent?.recordID == recordIDToDelete ? $0.recordID : nil } + ?? [] + ) + .subtracting(recordIDsToDelete) + .isEmpty + + guard !hasReferenceViolation + else { + deleteResults[recordIDToDelete] = .failure(CKError(.referenceViolation)) + continue + } + storage[recordIDToDelete.zoneID]?[recordIDToDelete] = nil + deleteResults[recordIDToDelete] = .success(()) + } + + return (saveResults: saveResults, deleteResults: deleteResults) + } + } + + package func modifyRecordZones( + saving recordZonesToSave: [CKRecordZone] = [], + deleting recordZoneIDsToDelete: [CKRecordZone.ID] = [] + ) throws -> ( + saveResults: [CKRecordZone.ID: Result], + deleteResults: [CKRecordZone.ID: Result] + ) { + let accountStatus = container.accountStatus() + guard accountStatus == .available + else { throw ckError(forAccountStatus: accountStatus) } + + return storage.withValue { storage in + var saveResults: [CKRecordZone.ID: Result] = [:] + var deleteResults: [CKRecordZone.ID: Result] = [:] + + for recordZoneToSave in recordZonesToSave { + storage[recordZoneToSave.zoneID] = storage[recordZoneToSave.zoneID] ?? [:] + saveResults[recordZoneToSave.zoneID] = .success(recordZoneToSave) + } + + for recordZoneIDsToDelete in recordZoneIDsToDelete { + guard storage[recordZoneIDsToDelete] != nil + else { + deleteResults[recordZoneIDsToDelete] = .failure(CKError(.zoneNotFound)) + continue + } + storage[recordZoneIDsToDelete] = nil + deleteResults[recordZoneIDsToDelete] = .success(()) + } + + return (saveResults: saveResults, deleteResults: deleteResults) + } + } + + package nonisolated static func == (lhs: MockCloudDatabase, rhs: MockCloudDatabase) -> Bool { + lhs === rhs + } + + package nonisolated func hash(into hasher: inout Hasher) { + hasher.combine(ObjectIdentifier(self)) + } +} + +@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) +extension MockCloudDatabase: CustomDumpReflectable { + package var customDumpMirror: Mirror { + Mirror( + self, + children: [ + "databaseScope": databaseScope, + "storage": storage + .value + .flatMap { _, value in value.values } + .sorted { + ($0.recordType, $0.recordID.recordName) < ($1.recordType, $1.recordID.recordName) + }, + ], + displayStyle: .struct + ) + } +} + +@available(macOS 13.0, iOS 16.0, tvOS 16.0, watchOS 9.0, *) +private func ckError(forAccountStatus accountStatus: CKAccountStatus) -> CKError { + switch accountStatus { + case .couldNotDetermine, .restricted, .noAccount: + return CKError(.notAuthenticated) + case .temporarilyUnavailable: + return CKError(.accountTemporarilyUnavailable) + case .available: + fatalError() + @unknown default: + fatalError() + } +} diff --git a/Sources/SharingGRDBCore/CloudKit/Internal/MockSyncEngine.swift b/Sources/SharingGRDBCore/CloudKit/Internal/MockSyncEngine.swift new file mode 100644 index 00000000..3bd4507a --- /dev/null +++ b/Sources/SharingGRDBCore/CloudKit/Internal/MockSyncEngine.swift @@ -0,0 +1,268 @@ +import CloudKit +import CustomDump +import OrderedCollections + +@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) +package final class MockSyncEngine: SyncEngineProtocol { + package let database: MockCloudDatabase + package let delegate: any SyncEngineDelegate + private let _state: LockIsolated + private let _fetchChangesScopes = LockIsolated<[CKSyncEngine.FetchChangesOptions.Scope]>([]) + private let _acceptedShareMetadata = LockIsolated>([]) + + package init( + database: MockCloudDatabase, + delegate: any SyncEngineDelegate, + state: MockSyncEngineState + ) { + self.database = database + self.delegate = delegate + self._state = LockIsolated(state) + } + + package var scope: CKDatabase.Scope { + database.databaseScope + } + + package var state: MockSyncEngineState { + _state.withValue(\.self) + } + + package func acceptShare(metadata: ShareMetadata) { + _ = _acceptedShareMetadata.withValue { $0.insert(metadata) } + } + + package func fetchChanges(_ options: CKSyncEngine.FetchChangesOptions) async throws { + // TODO: do something here + } + + package func recordZoneChangeBatch( + pendingChanges: [CKSyncEngine.PendingRecordZoneChange], + recordProvider: @Sendable (CKRecord.ID) async -> CKRecord? + ) async -> CKSyncEngine.RecordZoneChangeBatch? { + var recordsToSave: [CKRecord] = [] + var recordIDsSkipped: [CKRecord.ID] = [] + var recordIDsToDelete: [CKRecord.ID] = [] + for pendingChange in pendingChanges { + switch pendingChange { + case .saveRecord(let recordID): + guard let record = await recordProvider(recordID) + else { + recordIDsSkipped.append(recordID) + continue + } + recordsToSave.append(record) + case .deleteRecord(let recordID): + recordIDsToDelete.append(recordID) + @unknown default: + fatalError() + } + } + + state.remove(pendingRecordZoneChanges: recordIDsSkipped.map { .saveRecord($0) }) + + return CKSyncEngine.RecordZoneChangeBatch( + recordsToSave: recordsToSave, + recordIDsToDelete: recordIDsToDelete + ) + } + + package func assertFetchChangesScopes( + _ scopes: [CKSyncEngine.FetchChangesOptions.Scope], + fileID: StaticString = #fileID, + filePath: StaticString = #filePath, + line: UInt = #line, + column: UInt = #column + ) { + _fetchChangesScopes.withValue { + expectNoDifference( + scopes, + $0, + fileID: fileID, + filePath: filePath, + line: line, + column: column + ) + $0.removeAll() + } + } + + package func assertAcceptedShareMetadata( + _ sharedMetadata: Set, + fileID: StaticString = #fileID, + filePath: StaticString = #filePath, + line: UInt = #line, + column: UInt = #column + ) { + _acceptedShareMetadata.withValue { + expectNoDifference( + sharedMetadata, + $0, + fileID: fileID, + filePath: filePath, + line: line, + column: column + ) + $0.removeAll() + } + } + + package func cancelOperations() async { + } +} + +@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) +package final class MockSyncEngineState: CKSyncEngineStateProtocol, CustomDumpReflectable { + private let _pendingRecordZoneChanges = LockIsolated< + OrderedSet + >([] + ) + private let _pendingDatabaseChanges = LockIsolated< + OrderedSet + >([]) + private let fileID: StaticString + private let filePath: StaticString + private let line: UInt + private let column: UInt + + package init( + fileID: StaticString = #fileID, + filePath: StaticString = #filePath, + line: UInt = #line, + column: UInt = #column + ) { + self.fileID = fileID + self.filePath = filePath + self.line = line + self.column = column + } + + package func assertPendingRecordZoneChanges( + _ changes: OrderedSet, + fileID: StaticString = #fileID, + filePath: StaticString = #filePath, + line: UInt = #line, + column: UInt = #column + ) { + _pendingRecordZoneChanges.withValue { + expectNoDifference( + Set(changes), + Set($0), + fileID: fileID, + filePath: filePath, + line: line, + column: column + ) + $0.removeAll() + } + } + + package func assertPendingDatabaseChanges( + _ changes: OrderedSet, + fileID: StaticString = #fileID, + filePath: StaticString = #filePath, + line: UInt = #line, + column: UInt = #column + ) { + _pendingDatabaseChanges.withValue { + expectNoDifference( + Set(changes), + Set($0), + fileID: fileID, + filePath: filePath, + line: line, + column: column + ) + $0.removeAll() + } + } + + package var pendingRecordZoneChanges: [CKSyncEngine.PendingRecordZoneChange] { + _pendingRecordZoneChanges.withValue { Array($0) } + } + + package var pendingDatabaseChanges: [CKSyncEngine.PendingDatabaseChange] { + _pendingDatabaseChanges.withValue { Array($0) } + } + + package func add(pendingRecordZoneChanges: [CKSyncEngine.PendingRecordZoneChange]) { + self._pendingRecordZoneChanges.withValue { + $0.append(contentsOf: pendingRecordZoneChanges) + } + } + + package func remove(pendingRecordZoneChanges: [CKSyncEngine.PendingRecordZoneChange]) { + self._pendingRecordZoneChanges.withValue { + $0.subtract(pendingRecordZoneChanges) + } + } + + package func add(pendingDatabaseChanges: [CKSyncEngine.PendingDatabaseChange]) { + self._pendingDatabaseChanges.withValue { + $0.append(contentsOf: pendingDatabaseChanges) + } + } + + package func remove(pendingDatabaseChanges: [CKSyncEngine.PendingDatabaseChange]) { + self._pendingDatabaseChanges.withValue { + $0.subtract(pendingDatabaseChanges) + } + } + + package var customDumpMirror: Mirror { + return Mirror( + self, + children: [ + ( + "pendingRecordZoneChanges", + _pendingRecordZoneChanges.withValue(\.self) + .sorted(by: comparePendingRecordZoneChange) + as Any + ), + ( + "pendingDatabaseChanges", + _pendingDatabaseChanges.withValue(\.self) + .sorted(by: comparePendingDatabaseChange) as Any + ), + ], + displayStyle: .struct + ) + } +} + +@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) +private func comparePendingRecordZoneChange( + _ lhs: CKSyncEngine.PendingRecordZoneChange, + _ rhs: CKSyncEngine.PendingRecordZoneChange +) -> Bool { + switch (lhs, rhs) { + case (.saveRecord(let lhs), .saveRecord(let rhs)), + (.deleteRecord(let lhs), .deleteRecord(let rhs)): + lhs.recordName < rhs.recordName + case (.deleteRecord, .saveRecord): + true + case (.saveRecord, .deleteRecord): + false + default: + false + } +} + +@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) +private func comparePendingDatabaseChange( + _ lhs: CKSyncEngine.PendingDatabaseChange, + _ rhs: CKSyncEngine.PendingDatabaseChange +) -> Bool { + switch (lhs, rhs) { + case (.saveZone(let lhs), .saveZone(let rhs)): + lhs.zoneID.zoneName < rhs.zoneID.zoneName + case (.deleteZone(let lhs), .deleteZone(let rhs)): + lhs.zoneName < rhs.zoneName + case (.deleteZone, .saveZone): + true + case (.saveZone, .deleteZone): + false + default: + false + } +} diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift index aa6f9d9e..b92045d8 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift @@ -32,7 +32,7 @@ privateTables: repeat (each T2).Type, containerIdentifier: String? = nil, defaultZone: CKRecordZone = CKRecordZone(zoneName: "co.pointfree.SQLiteData.defaultZone"), - logger: Logger = Logger(subsystem: "SQLiteData", category: "CloudKit") + logger: Logger = isTesting ? Logger(.disabled) : Logger(subsystem: "SQLiteData", category: "CloudKit") ) throws where repeat (each T1).PrimaryKey.QueryOutput: IdentifierStringConvertible, @@ -41,17 +41,6 @@ let containerIdentifier = containerIdentifier ?? ModelConfiguration(groupContainer: .automatic).cloudKitContainerIdentifier - guard let containerIdentifier else { - throw SchemaError( - reason: .noCloudKitContainer, - debugDescription: """ - No default CloudKit container found. Please add a container identifier to your app's \ - entitlements. - """ - ) - } - - let container = CKContainer(identifier: containerIdentifier) var allTables: [any PrimaryKeyedTable.Type] = [] var allPrivateTables: [any PrimaryKeyedTable.Type] = [] for table in repeat each tables { @@ -60,8 +49,55 @@ for privateTable in repeat each privateTables { allPrivateTables.append(privateTable) } - let userDatabase = UserDatabase(database: database) + + guard let containerIdentifier else { + guard isTesting + else { + throw SchemaError( + reason: .noCloudKitContainer, + debugDescription: """ + No default CloudKit container found. Please add a container identifier to your app's \ + entitlements. + """ + ) + } + let privateDatabase = MockCloudDatabase(databaseScope: .private) + let sharedDatabase = MockCloudDatabase(databaseScope: .shared) + try self.init( + container: MockCloudContainer( + containerIdentifier: "co.pointfree.sqlitedata-icloud.testing", + privateCloudDatabase: privateDatabase, + sharedCloudDatabase: sharedDatabase + ), + defaultZone: defaultZone, + defaultSyncEngines: { _, syncEngine in + ( + private: MockSyncEngine( + database: privateDatabase, + delegate: syncEngine, + state: MockSyncEngineState() + ), + shared: MockSyncEngine( + database: sharedDatabase, + delegate: syncEngine, + state: MockSyncEngineState() + ) + ) + }, + userDatabase: userDatabase, + logger: logger, + tables: allTables, + privateTables: allPrivateTables + ) + _ = try setUpSyncEngine( + userDatabase: userDatabase, + metadatabase: metadatabase + ) + return + } + + let container = CKContainer(identifier: containerIdentifier) try self.init( container: container, defaultZone: defaultZone, @@ -1144,7 +1180,6 @@ .where(\.isShared) .select { ($0.share, $0.recordName) } .fetchAll(db) - // TODO: Write test that we never accidentally delete a new share from a delete event of an old share .first(where: { share, _ in share?.recordID == recordID }) ?? nil guard let (_, recordName) = shareAndRecordName else { return } diff --git a/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift b/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift index 4fec6e12..6a053e7e 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift @@ -192,21 +192,13 @@ extension BaseCloudKitTests { tableName: "tags", schema: """ CREATE TABLE "tags" ( - "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, - "title" TEXT NOT NULL ON CONFLICT REPLACE DEFAULT '' + "title" TEXT PRIMARY KEY NOT NULL COLLATE NOCASE ) STRICT """, tableInfo: [ [0]: TableInfo( defaultValue: nil, isPrimaryKey: true, - name: "id", - notNull: true, - type: "INTEGER" - ), - [1]: TableInfo( - defaultValue: "\'\'", - isPrimaryKey: false, name: "title", notNull: true, type: "TEXT" @@ -219,7 +211,7 @@ extension BaseCloudKitTests { CREATE TABLE "reminderTags" ( "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "reminderID" INTEGER NOT NULL REFERENCES "reminders"("id") ON DELETE CASCADE, - "tagID" INTEGER NOT NULL REFERENCES "tags"("id") ON DELETE CASCADE + "tagID" TEXT NOT NULL REFERENCES "tags"("title") ON DELETE CASCADE ON UPDATE CASCADE ) STRICT """, tableInfo: [ @@ -242,7 +234,7 @@ extension BaseCloudKitTests { isPrimaryKey: false, name: "tagID", notNull: true, - type: "INTEGER" + type: "TEXT" ) ] ), @@ -952,8 +944,8 @@ extension BaseCloudKitTests { @Test func cascadingDeletionOrder() async throws { try await userDatabase.userWrite { db in try db.seed { - Tag(id: 1, title: "") - Tag(id: 2, title: "") + Tag(title: "fun") + Tag(title: "weekend") } } for _ in 1...100 { @@ -965,14 +957,14 @@ extension BaseCloudKitTests { Reminder(id: 2, title: "", remindersListID: 1) Reminder(id: 3, title: "", remindersListID: 1) Reminder(id: 4, title: "", remindersListID: 1) - ReminderTag(id: 1, reminderID: 1, tagID: 1) - ReminderTag(id: 2, reminderID: 2, tagID: 1) - ReminderTag(id: 3, reminderID: 3, tagID: 1) - ReminderTag(id: 4, reminderID: 4, tagID: 1) - ReminderTag(id: 5, reminderID: 1, tagID: 2) - ReminderTag(id: 6, reminderID: 2, tagID: 2) - ReminderTag(id: 7, reminderID: 3, tagID: 2) - ReminderTag(id: 8, reminderID: 4, tagID: 2) + ReminderTag(id: 1, reminderID: 1, tagID: "fun") + ReminderTag(id: 2, reminderID: 2, tagID: "fun") + ReminderTag(id: 3, reminderID: 3, tagID: "fun") + ReminderTag(id: 4, reminderID: 4, tagID: "fun") + ReminderTag(id: 5, reminderID: 1, tagID: "weekend") + ReminderTag(id: 6, reminderID: 2, tagID: "weekend") + ReminderTag(id: 7, reminderID: 3, tagID: "weekend") + ReminderTag(id: 8, reminderID: 4, tagID: "weekend") } } @@ -990,20 +982,18 @@ extension BaseCloudKitTests { databaseScope: .private, storage: [ [0]: CKRecord( - recordID: CKRecord.ID(1:tags/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), + recordID: CKRecord.ID(fun:tags/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), recordType: "tags", parent: nil, share: nil, - id: 1, - title: "" + title: "fun" ), [1]: CKRecord( - recordID: CKRecord.ID(2:tags/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), + recordID: CKRecord.ID(weekend:tags/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), recordType: "tags", parent: nil, share: nil, - id: 2, - title: "" + title: "weekend" ) ] ), diff --git a/Tests/SharingGRDBTests/CloudKitTests/MetadataTests.swift b/Tests/SharingGRDBTests/CloudKitTests/MetadataTests.swift index 906a7479..998534bf 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/MetadataTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/MetadataTests.swift @@ -139,8 +139,8 @@ extension BaseCloudKitTests { try db.seed { RemindersList(id: 1, title: "Personal") Reminder(id: 1, title: "Groceries", remindersListID: 1) - Tag(id: 1, title: "weekend") - ReminderTag(id: 1, reminderID: 1, tagID: 1) + Tag(title: "weekend") + ReminderTag(id: 1, reminderID: 1, tagID: "weekend") } } @@ -158,7 +158,7 @@ extension BaseCloudKitTests { share: nil, id: 1, reminderID: 1, - tagID: 1 + tagID: "weekend" ), [1]: CKRecord( recordID: CKRecord.ID(1:reminders/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), @@ -179,11 +179,10 @@ extension BaseCloudKitTests { title: "Personal" ), [3]: CKRecord( - recordID: CKRecord.ID(1:tags/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), + recordID: CKRecord.ID(weekend:tags/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), recordType: "tags", parent: nil, share: nil, - id: 1, title: "weekend" ) ] diff --git a/Tests/SharingGRDBTests/CloudKitTests/RecordTypeTests.swift b/Tests/SharingGRDBTests/CloudKitTests/RecordTypeTests.swift index 2f112a16..dabfa903 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/RecordTypeTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/RecordTypeTests.swift @@ -191,21 +191,13 @@ extension BaseCloudKitTests { tableName: "tags", schema: """ CREATE TABLE "tags" ( - "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, - "title" TEXT NOT NULL ON CONFLICT REPLACE DEFAULT '' + "title" TEXT PRIMARY KEY NOT NULL COLLATE NOCASE ) STRICT """, tableInfo: [ [0]: TableInfo( defaultValue: nil, isPrimaryKey: true, - name: "id", - notNull: true, - type: "INTEGER" - ), - [1]: TableInfo( - defaultValue: "\'\'", - isPrimaryKey: false, name: "title", notNull: true, type: "TEXT" @@ -218,7 +210,7 @@ extension BaseCloudKitTests { CREATE TABLE "reminderTags" ( "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "reminderID" INTEGER NOT NULL REFERENCES "reminders"("id") ON DELETE CASCADE, - "tagID" INTEGER NOT NULL REFERENCES "tags"("id") ON DELETE CASCADE + "tagID" TEXT NOT NULL REFERENCES "tags"("title") ON DELETE CASCADE ON UPDATE CASCADE ) STRICT """, tableInfo: [ @@ -241,7 +233,7 @@ extension BaseCloudKitTests { isPrimaryKey: false, name: "tagID", notNull: true, - type: "INTEGER" + type: "TEXT" ) ] ), diff --git a/Tests/SharingGRDBTests/CloudKitTests/TriggerTests.swift b/Tests/SharingGRDBTests/CloudKitTests/TriggerTests.swift index 0494b499..08aafd75 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/TriggerTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/TriggerTests.swift @@ -170,7 +170,7 @@ extension BaseCloudKitTests { AFTER DELETE ON "tags" FOR EACH ROW BEGIN DELETE FROM "sqlitedata_icloud_metadata" - WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "old"."id") AND ("sqlitedata_icloud_metadata"."recordType" = 'tags')); + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "old"."title") AND ("sqlitedata_icloud_metadata"."recordType" = 'tags')); END """, [15]: """ @@ -300,7 +300,7 @@ extension BaseCloudKitTests { FOR EACH ROW BEGIN INSERT INTO "sqlitedata_icloud_metadata" ("recordPrimaryKey", "recordType", "parentRecordPrimaryKey", "parentRecordType") - SELECT "new"."id", 'tags', NULL, NULL + SELECT "new"."title", 'tags', NULL, NULL ON CONFLICT ("recordPrimaryKey", "recordType") DO UPDATE SET "parentRecordPrimaryKey" = "excluded"."parentRecordPrimaryKey", "parentRecordType" = "excluded"."parentRecordType", "userModificationDate" = "excluded"."userModificationDate"; END @@ -432,7 +432,7 @@ extension BaseCloudKitTests { FOR EACH ROW BEGIN INSERT INTO "sqlitedata_icloud_metadata" ("recordPrimaryKey", "recordType", "parentRecordPrimaryKey", "parentRecordType") - SELECT "new"."id", 'tags', NULL, NULL + SELECT "new"."title", 'tags', NULL, NULL ON CONFLICT ("recordPrimaryKey", "recordType") DO UPDATE SET "parentRecordPrimaryKey" = "excluded"."parentRecordPrimaryKey", "parentRecordType" = "excluded"."parentRecordType", "userModificationDate" = "excluded"."userModificationDate"; END diff --git a/Tests/SharingGRDBTests/Internal/BaseCloudKitTests.swift b/Tests/SharingGRDBTests/Internal/BaseCloudKitTests.swift index 842da86e..819652eb 100644 --- a/Tests/SharingGRDBTests/Internal/BaseCloudKitTests.swift +++ b/Tests/SharingGRDBTests/Internal/BaseCloudKitTests.swift @@ -148,13 +148,11 @@ extension SyncEngine { MockSyncEngine( database: container.privateCloudDatabase as! MockCloudDatabase, delegate: syncEngine, - scope: .private, state: MockSyncEngineState() ), MockSyncEngine( database: container.sharedCloudDatabase as! MockCloudDatabase, delegate: syncEngine, - scope: .shared, state: MockSyncEngineState() ) ) diff --git a/Tests/SharingGRDBTests/Internal/CloudKitTestHelpers.swift b/Tests/SharingGRDBTests/Internal/CloudKitTestHelpers.swift index 58eb5c3b..bb90c86f 100644 --- a/Tests/SharingGRDBTests/Internal/CloudKitTestHelpers.swift +++ b/Tests/SharingGRDBTests/Internal/CloudKitTestHelpers.swift @@ -17,624 +17,7 @@ extension PrimaryKeyedTable where PrimaryKey.QueryOutput: IdentifierStringConver } } -@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) -final class MockSyncEngine: SyncEngineProtocol { - let database: MockCloudDatabase - let delegate: any SyncEngineDelegate - private let _state: LockIsolated - private let _fetchChangesScopes = LockIsolated<[CKSyncEngine.FetchChangesOptions.Scope]>([]) - private let _acceptedShareMetadata = LockIsolated>([]) - let scope: CKDatabase.Scope - init( - database: MockCloudDatabase, - delegate: any SyncEngineDelegate, - scope: CKDatabase.Scope, - state: MockSyncEngineState - ) { - self.database = database - self.delegate = delegate - self.scope = scope - self._state = LockIsolated(state) - } - - var state: MockSyncEngineState { - _state.withValue(\.self) - } - - func acceptShare(metadata: ShareMetadata) { - _ = _acceptedShareMetadata.withValue { $0.insert(metadata) } - } - - func fetchChanges(_ options: CKSyncEngine.FetchChangesOptions) async throws { - // TODO: do something here - } - - func recordZoneChangeBatch( - pendingChanges: [CKSyncEngine.PendingRecordZoneChange], - recordProvider: @Sendable (CKRecord.ID) async -> CKRecord? - ) async -> CKSyncEngine.RecordZoneChangeBatch? { - var recordsToSave: [CKRecord] = [] - var recordIDsSkipped: [CKRecord.ID] = [] - var recordIDsToDelete: [CKRecord.ID] = [] - for pendingChange in pendingChanges { - switch pendingChange { - case .saveRecord(let recordID): - guard let record = await recordProvider(recordID) - else { - recordIDsSkipped.append(recordID) - continue - } - recordsToSave.append(record) - case .deleteRecord(let recordID): - recordIDsToDelete.append(recordID) - @unknown default: - fatalError() - } - } - - state.remove(pendingRecordZoneChanges: recordIDsSkipped.map { .saveRecord($0) }) - - return CKSyncEngine.RecordZoneChangeBatch( - recordsToSave: recordsToSave, - recordIDsToDelete: recordIDsToDelete - ) - } - - func assertFetchChangesScopes( - _ scopes: [CKSyncEngine.FetchChangesOptions.Scope], - fileID: StaticString = #fileID, - filePath: StaticString = #filePath, - line: UInt = #line, - column: UInt = #column - ) { - _fetchChangesScopes.withValue { - expectNoDifference( - scopes, - $0, - fileID: fileID, - filePath: filePath, - line: line, - column: column - ) - $0.removeAll() - } - } - - func assertAcceptedShareMetadata( - _ sharedMetadata: Set, - fileID: StaticString = #fileID, - filePath: StaticString = #filePath, - line: UInt = #line, - column: UInt = #column - ) { - _acceptedShareMetadata.withValue { - expectNoDifference( - sharedMetadata, - $0, - fileID: fileID, - filePath: filePath, - line: line, - column: column - ) - $0.removeAll() - } - } - - func cancelOperations() async { - } -} - -@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) -final class MockSyncEngineState: CKSyncEngineStateProtocol, CustomDumpReflectable { - private let _pendingRecordZoneChanges = LockIsolated< - OrderedSet - >([] - ) - private let _pendingDatabaseChanges = LockIsolated< - OrderedSet - >([]) - private let fileID: StaticString - private let filePath: StaticString - private let line: UInt - private let column: UInt - - init( - fileID: StaticString = #fileID, - filePath: StaticString = #filePath, - line: UInt = #line, - column: UInt = #column - ) { - self.fileID = fileID - self.filePath = filePath - self.line = line - self.column = column - } - - func assertPendingRecordZoneChanges( - _ changes: OrderedSet, - fileID: StaticString = #fileID, - filePath: StaticString = #filePath, - line: UInt = #line, - column: UInt = #column - ) { - _pendingRecordZoneChanges.withValue { - expectNoDifference( - Set(changes), - Set($0), - fileID: fileID, - filePath: filePath, - line: line, - column: column - ) - $0.removeAll() - } - } - - func assertPendingDatabaseChanges( - _ changes: OrderedSet, - fileID: StaticString = #fileID, - filePath: StaticString = #filePath, - line: UInt = #line, - column: UInt = #column - ) { - _pendingDatabaseChanges.withValue { - expectNoDifference( - Set(changes), - Set($0), - fileID: fileID, - filePath: filePath, - line: line, - column: column - ) - $0.removeAll() - } - } - - var pendingRecordZoneChanges: [CKSyncEngine.PendingRecordZoneChange] { - _pendingRecordZoneChanges.withValue { Array($0) } - } - - var pendingDatabaseChanges: [CKSyncEngine.PendingDatabaseChange] { - _pendingDatabaseChanges.withValue { Array($0) } - } - - func add(pendingRecordZoneChanges: [CKSyncEngine.PendingRecordZoneChange]) { - self._pendingRecordZoneChanges.withValue { - $0.append(contentsOf: pendingRecordZoneChanges) - } - } - func remove(pendingRecordZoneChanges: [CKSyncEngine.PendingRecordZoneChange]) { - self._pendingRecordZoneChanges.withValue { - $0.subtract(pendingRecordZoneChanges) - } - } - func add(pendingDatabaseChanges: [CKSyncEngine.PendingDatabaseChange]) { - self._pendingDatabaseChanges.withValue { - $0.append(contentsOf: pendingDatabaseChanges) - } - } - func remove(pendingDatabaseChanges: [CKSyncEngine.PendingDatabaseChange]) { - self._pendingDatabaseChanges.withValue { - $0.subtract(pendingDatabaseChanges) - } - } - - var customDumpMirror: Mirror { - return Mirror( - self, - children: [ - ( - "pendingRecordZoneChanges", - _pendingRecordZoneChanges.withValue(\.self) - .sorted(by: comparePendingRecordZoneChange) - as Any - ), - ( - "pendingDatabaseChanges", - _pendingDatabaseChanges.withValue(\.self) - .sorted(by: comparePendingDatabaseChange) as Any - ), - ], - displayStyle: .struct - ) - } -} - -final class MockCloudDatabase: CloudDatabase { - let storage = LockIsolated<[CKRecordZone.ID: [CKRecord.ID: CKRecord]]>([:]) - let assets = LockIsolated<[AssetID: Data]>([:]) - let databaseScope: CKDatabase.Scope - let _container = IsolatedWeakVar() - - let dataManager = Dependency(\.dataManager) - - struct AssetID: Hashable { - let recordID: CKRecord.ID - let key: String - } - - init(databaseScope: CKDatabase.Scope) { - self.databaseScope = databaseScope - } - - func set(container: MockCloudContainer) { - _container.set(container) - } - - var container: MockCloudContainer { - _container.value! - } - - func record(for recordID: CKRecord.ID) throws -> CKRecord { - let accountStatus = container.accountStatus() - guard accountStatus == .available - else { throw ckError(forAccountStatus: accountStatus) } - guard let zone = storage[recordID.zoneID] - else { throw CKError(.zoneNotFound) } - guard let record = zone[recordID] - else { throw CKError(.unknownItem) } - guard let record = record.copy() as? CKRecord - else { fatalError("Could not copy CKRecord.") } - - try assets.withValue { assets in - for key in record.allKeys() { - guard let assetData = assets[AssetID(recordID: record.recordID, key: key)] - else { continue } - let url = URL(filePath: UUID().uuidString.lowercased()) - try dataManager.wrappedValue.save(assetData, to: url) - record[key] = CKAsset(fileURL: url) - } - } - - return record - } - - func records( - for ids: [CKRecord.ID], - desiredKeys: [CKRecord.FieldKey]? - ) throws -> [CKRecord.ID: Result] { - let accountStatus = container.accountStatus() - guard accountStatus == .available - else { throw ckError(forAccountStatus: accountStatus) } - - var results: [CKRecord.ID: Result] = [:] - for id in ids { - results[id] = Result { try record(for: id) } - } - return results - } - - func modifyRecords( - saving recordsToSave: [CKRecord] = [], - deleting recordIDsToDelete: [CKRecord.ID] = [], - savePolicy: CKModifyRecordsOperation.RecordSavePolicy = .ifServerRecordUnchanged, - atomically: Bool = true - ) throws -> ( - saveResults: [CKRecord.ID: Result], - deleteResults: [CKRecord.ID: Result] - ) { - let accountStatus = container.accountStatus() - guard accountStatus == .available - else { throw ckError(forAccountStatus: accountStatus) } - - return storage.withValue { storage in - var saveResults: [CKRecord.ID: Result] = [:] - var deleteResults: [CKRecord.ID: Result] = [:] - - switch savePolicy { - case .ifServerRecordUnchanged: - for recordToSave in recordsToSave { - guard storage[recordToSave.recordID.zoneID] != nil - else { - saveResults[recordToSave.recordID] = .failure(CKError(.zoneNotFound)) - continue - } - - let existingRecord = storage[recordToSave.recordID.zoneID]?[recordToSave.recordID] - - func saveRecordToDatabase() { - let hasReferenceViolation = - recordToSave.parent.map { parent in - storage[parent.recordID.zoneID]?[parent.recordID] == nil - && !recordsToSave.contains { $0.recordID == parent.recordID } - } - ?? false - guard !hasReferenceViolation - else { - saveResults[recordToSave.recordID] = .failure(CKError(.referenceViolation)) - return - } - - 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 - else { continue } - assets[AssetID(recordID: copy.recordID, key: key)] = try? dataManager.wrappedValue - .load(assetURL) - } - } - storage[recordToSave.recordID.zoneID]?[recordToSave.recordID] = copy - saveResults[recordToSave.recordID] = .success(copy) - } - - switch (existingRecord, recordToSave._recordChangeTag) { - case (.some(let existingRecord), .some(let recordToSaveChangeTag)): - // We are trying to save a record with a change tag that also already exists in the - // DB. If the tags match, we can save the record. Otherwise, we notify the sync engine - // that the server record has changed since it was last synced. - if existingRecord._recordChangeTag == recordToSaveChangeTag { - precondition(existingRecord._recordChangeTag != nil) - saveRecordToDatabase() - } else { - saveResults[recordToSave.recordID] = .failure( - CKError( - .serverRecordChanged, - userInfo: [ - CKRecordChangedErrorServerRecordKey: existingRecord.copy() as Any, - CKRecordChangedErrorClientRecordKey: recordToSave.copy(), - ] - ) - ) - } - break - case (.some(let existingRecord), .none): - // We are trying to save a record that does not have a change tag yet also already - // exists in the DB. This means the user has created a new CKRecord from scratch, - // giving it a new identity, rather than leveraging an existing CKRecord. - Issue.record( - """ - A new identity was created for an existing 'CKRecord' \ - ('\(existingRecord.recordID.recordName)'). Rather than creating \ - 'CKRecord' from scratch for an existing record, use the database to fetch the \ - current record. - """ - ) - saveResults[recordToSave.recordID] = .failure( - CKError( - .serverRejectedRequest, - userInfo: [ - CKRecordChangedErrorServerRecordKey: existingRecord.copy() as Any, - CKRecordChangedErrorClientRecordKey: recordToSave.copy(), - ] - ) - ) - case (.none, .some): - // We are trying to save a record with a change tag but it does not exist in the DB. - // This means the record was deleted by another device. - saveResults[recordToSave.recordID] = .failure(CKError(.unknownItem)) - case (.none, .none): - // We are trying to save a record with no change tag and no existing record in the DB. - // This means it's a brand new record. - saveRecordToDatabase() - } - } - case .allKeys, .changedKeys: - fatalError() - @unknown default: - fatalError() - } - for recordIDToDelete in recordIDsToDelete { - guard storage[recordIDToDelete.zoneID] != nil - else { - deleteResults[recordIDToDelete] = .failure(CKError(.zoneNotFound)) - continue - } - let hasReferenceViolation = !Set( - storage[recordIDToDelete.zoneID]?.values - .compactMap { $0.parent?.recordID == recordIDToDelete ? $0.recordID : nil } - ?? [] - ) - .subtracting(recordIDsToDelete) - .isEmpty - - guard !hasReferenceViolation - else { - deleteResults[recordIDToDelete] = .failure(CKError(.referenceViolation)) - continue - } - storage[recordIDToDelete.zoneID]?[recordIDToDelete] = nil - deleteResults[recordIDToDelete] = .success(()) - } - - return (saveResults: saveResults, deleteResults: deleteResults) - } - } - - func modifyRecordZones( - saving recordZonesToSave: [CKRecordZone] = [], - deleting recordZoneIDsToDelete: [CKRecordZone.ID] = [] - ) throws -> ( - saveResults: [CKRecordZone.ID: Result], - deleteResults: [CKRecordZone.ID: Result] - ) { - let accountStatus = container.accountStatus() - guard accountStatus == .available - else { throw ckError(forAccountStatus: accountStatus) } - - return storage.withValue { storage in - var saveResults: [CKRecordZone.ID: Result] = [:] - var deleteResults: [CKRecordZone.ID: Result] = [:] - - for recordZoneToSave in recordZonesToSave { - storage[recordZoneToSave.zoneID] = storage[recordZoneToSave.zoneID] ?? [:] - saveResults[recordZoneToSave.zoneID] = .success(recordZoneToSave) - } - - for recordZoneIDsToDelete in recordZoneIDsToDelete { - guard storage[recordZoneIDsToDelete] != nil - else { - deleteResults[recordZoneIDsToDelete] = .failure(CKError(.zoneNotFound)) - continue - } - storage[recordZoneIDsToDelete] = nil - deleteResults[recordZoneIDsToDelete] = .success(()) - } - - return (saveResults: saveResults, deleteResults: deleteResults) - } - } - - nonisolated static func == (lhs: MockCloudDatabase, rhs: MockCloudDatabase) -> Bool { - lhs === rhs - } - - nonisolated func hash(into hasher: inout Hasher) { - hasher.combine(ObjectIdentifier(self)) - } -} - -extension MockCloudDatabase: CustomDumpReflectable { - var customDumpMirror: Mirror { - Mirror( - self, - children: [ - "databaseScope": databaseScope, - "storage": storage - .value - .flatMap { _, value in value.values } - .sorted { - ($0.recordType, $0.recordID.recordName) < ($1.recordType, $1.recordID.recordName) - }, - ], - displayStyle: .struct - ) - } -} - -final class MockCloudContainer: CloudContainer, CustomDumpReflectable { - let _accountStatus: LockIsolated - let containerIdentifier: String? - let privateCloudDatabase: MockCloudDatabase - let sharedCloudDatabase: MockCloudDatabase - - init( - accountStatus: CKAccountStatus = .available, - containerIdentifier: String?, - privateCloudDatabase: MockCloudDatabase, - sharedCloudDatabase: MockCloudDatabase - ) { - self._accountStatus = LockIsolated(accountStatus) - self.containerIdentifier = containerIdentifier - self.privateCloudDatabase = privateCloudDatabase - self.sharedCloudDatabase = sharedCloudDatabase - } - - func accountStatus() -> CKAccountStatus { - _accountStatus.withValue(\.self) - } - - var rawValue: CKContainer { - fatalError("This should never be called in tests.") - } - - func accountStatus() async throws -> CKAccountStatus { - _accountStatus.withValue { $0 } - } - - func shareMetadata(for url: URL, shouldFetchRootRecord: Bool) async throws -> CKShare.Metadata { - fatalError() - } - - func accept(_ metadata: CKShare.Metadata) async throws -> CKShare { - fatalError() - } - - static func createContainer(identifier containerIdentifier: String) -> MockCloudContainer { - @Dependency(\.mockCloudContainers) var mockCloudContainers - return mockCloudContainers.withValue { storage in - let container: MockCloudContainer - if let existingContainer = storage[containerIdentifier] { - container = existingContainer - } else { - container = MockCloudContainer( - accountStatus: .available, - containerIdentifier: containerIdentifier, - privateCloudDatabase: MockCloudDatabase(databaseScope: .private), - sharedCloudDatabase: MockCloudDatabase(databaseScope: .shared) - ) - container.privateCloudDatabase.set(container: container) - container.sharedCloudDatabase.set(container: container) - } - storage[containerIdentifier] = container - return container - } - } - - static func == (lhs: MockCloudContainer, rhs: MockCloudContainer) -> Bool { - lhs === rhs - } - - func hash(into hasher: inout Hasher) { - hasher.combine(ObjectIdentifier(self)) - } - var customDumpMirror: Mirror { - Mirror.init( - self, - children: [ - ("privateCloudDatabase", privateCloudDatabase), - ("sharedCloudDatabase", sharedCloudDatabase), - ], - displayStyle: .struct - ) - } -} - -private enum MockCloudContainersKey: TestDependencyKey { - static var testValue: LockIsolated<[String: MockCloudContainer]> { - LockIsolated<[String: MockCloudContainer]>([:]) - } -} -extension DependencyValues { - var mockCloudContainers: LockIsolated<[String: MockCloudContainer]> { - get { - self[MockCloudContainersKey.self] - } - set { - self[MockCloudContainersKey.self] = newValue - } - } -} - -@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) -private func comparePendingRecordZoneChange( - _ lhs: CKSyncEngine.PendingRecordZoneChange, - _ rhs: CKSyncEngine.PendingRecordZoneChange -) -> Bool { - switch (lhs, rhs) { - case (.saveRecord(let lhs), .saveRecord(let rhs)), - (.deleteRecord(let lhs), .deleteRecord(let rhs)): - lhs.recordName < rhs.recordName - case (.deleteRecord, .saveRecord): - true - case (.saveRecord, .deleteRecord): - false - default: - false - } -} - -@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) -private func comparePendingDatabaseChange( - _ lhs: CKSyncEngine.PendingDatabaseChange, - _ rhs: CKSyncEngine.PendingDatabaseChange -) -> Bool { - switch (lhs, rhs) { - case (.saveZone(let lhs), .saveZone(let rhs)): - lhs.zoneID.zoneName < rhs.zoneID.zoneName - case (.deleteZone(let lhs), .deleteZone(let rhs)): - lhs.zoneName < rhs.zoneName - case (.deleteZone, .saveZone): - true - case (.saveZone, .deleteZone): - false - default: - false - } -} extension SyncEngine { struct ModifyRecordsCallback { @@ -929,16 +312,3 @@ extension SyncEngine { } } } - -private func ckError(forAccountStatus accountStatus: CKAccountStatus) -> CKError { - switch accountStatus { - case .couldNotDetermine, .restricted, .noAccount: - return CKError(.notAuthenticated) - case .temporarilyUnavailable: - return CKError(.accountTemporarilyUnavailable) - case .available: - fatalError() - @unknown default: - fatalError() - } -} diff --git a/Tests/SharingGRDBTests/Internal/Schema.swift b/Tests/SharingGRDBTests/Internal/Schema.swift index db60e072..540e0489 100644 --- a/Tests/SharingGRDBTests/Internal/Schema.swift +++ b/Tests/SharingGRDBTests/Internal/Schema.swift @@ -27,8 +27,9 @@ import SharingGRDB var remindersListID: RemindersList.ID } @Table struct Tag: Equatable, Identifiable { - let id: Int - var title = "" + @Column(primaryKey: true) + let title: String + var id: String { title } } @Table struct ReminderTag: Equatable, Identifiable { let id: Int @@ -128,8 +129,7 @@ func database(containerIdentifier: String) throws -> DatabasePool { try #sql( """ CREATE TABLE "tags" ( - "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, - "title" TEXT NOT NULL ON CONFLICT REPLACE DEFAULT '' + "title" TEXT PRIMARY KEY NOT NULL COLLATE NOCASE ) STRICT """ ) @@ -139,7 +139,7 @@ func database(containerIdentifier: String) throws -> DatabasePool { CREATE TABLE "reminderTags" ( "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "reminderID" INTEGER NOT NULL REFERENCES "reminders"("id") ON DELETE CASCADE, - "tagID" INTEGER NOT NULL REFERENCES "tags"("id") ON DELETE CASCADE + "tagID" TEXT NOT NULL REFERENCES "tags"("title") ON DELETE CASCADE ON UPDATE CASCADE ) STRICT """ ) From e246aadc0161f365ce7daf440da494d4ace78867 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Wed, 13 Aug 2025 14:31:40 -0500 Subject: [PATCH 2/6] control dates internally --- .../RemindersDetailsTests.swift | 1 + .../RemindersTests/RemindersListsTests.swift | 1 + .../RemindersTests/SearchRemindersTests.swift | 1 + .../CloudKit/Internal/DatetimeGenerator.swift | 26 ++++++++++++++++ .../SharingGRDBCore/CloudKit/SyncEngine.swift | 30 +++++++++--------- .../CloudKitTests/AssetsTests.swift | 2 +- .../CloudKitTests/CloudKitTests.swift | 4 +-- .../FetchRecordZoneChangesTests.swift | 8 ++--- .../ForeignKeyConstraintTests.swift | 14 ++++----- .../CloudKitTests/MergeConflictTests.swift | 14 ++++----- .../CloudKitTests/MetadataTests.swift | 2 +- .../ReferenceViolationTests.swift | 18 +++++------ .../CloudKitTests/SchemaChangeTests.swift | 8 ++--- .../CloudKitTests/SharingTests.swift | 6 ++-- .../CloudKitTests/UserlandTests.swift | 31 +++++++++++++++++++ .../Internal/BaseCloudKitTests.swift | 4 +-- Tests/SharingGRDBTests/Internal/Schema.swift | 6 ++-- 17 files changed, 119 insertions(+), 57 deletions(-) create mode 100644 Sources/SharingGRDBCore/CloudKit/Internal/DatetimeGenerator.swift create mode 100644 Tests/SharingGRDBTests/CloudKitTests/UserlandTests.swift diff --git a/Examples/RemindersTests/RemindersDetailsTests.swift b/Examples/RemindersTests/RemindersDetailsTests.swift index 65cc5475..96db1f7a 100644 --- a/Examples/RemindersTests/RemindersDetailsTests.swift +++ b/Examples/RemindersTests/RemindersDetailsTests.swift @@ -1,5 +1,6 @@ import Dependencies import DependenciesTestSupport +import GRDB import InlineSnapshotTesting import SharingGRDB import SnapshotTestingCustomDump diff --git a/Examples/RemindersTests/RemindersListsTests.swift b/Examples/RemindersTests/RemindersListsTests.swift index 2e4becfb..4cf16a2f 100644 --- a/Examples/RemindersTests/RemindersListsTests.swift +++ b/Examples/RemindersTests/RemindersListsTests.swift @@ -1,3 +1,4 @@ +import GRDB import InlineSnapshotTesting import SharingGRDB import SnapshotTestingCustomDump diff --git a/Examples/RemindersTests/SearchRemindersTests.swift b/Examples/RemindersTests/SearchRemindersTests.swift index 4defe8b7..d996868f 100644 --- a/Examples/RemindersTests/SearchRemindersTests.swift +++ b/Examples/RemindersTests/SearchRemindersTests.swift @@ -1,6 +1,7 @@ import Dependencies import DependenciesTestSupport import InlineSnapshotTesting +import GRDB import SharingGRDB import SnapshotTestingCustomDump import Testing diff --git a/Sources/SharingGRDBCore/CloudKit/Internal/DatetimeGenerator.swift b/Sources/SharingGRDBCore/CloudKit/Internal/DatetimeGenerator.swift new file mode 100644 index 00000000..94c36a11 --- /dev/null +++ b/Sources/SharingGRDBCore/CloudKit/Internal/DatetimeGenerator.swift @@ -0,0 +1,26 @@ +import Dependencies +import Foundation + +package struct DatetimeGenerator: DependencyKey, Sendable { + private var generate: @Sendable () -> Date + package var now: Date { + get { self.generate() } + set { self.generate = { newValue } } + } + package func callAsFunction() -> Date { + self.generate() + } + package static var liveValue: DatetimeGenerator { + Self { Date() } + } + package static var testValue: DatetimeGenerator { + Self { Date() } + } +} + +extension DependencyValues { + package var datetime: DatetimeGenerator { + get { self[DatetimeGenerator.self] } + set { self[DatetimeGenerator.self] = newValue } + } +} diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift index b92045d8..4ae48eab 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift @@ -2,6 +2,7 @@ import CloudKit import ConcurrencyExtras import CustomDump + import Dependencies import OrderedCollections import OSLog import StructuredQueriesCore @@ -51,22 +52,13 @@ } let userDatabase = UserDatabase(database: database) - guard let containerIdentifier else { - guard isTesting - else { - throw SchemaError( - reason: .noCloudKitContainer, - debugDescription: """ - No default CloudKit container found. Please add a container identifier to your app's \ - entitlements. - """ - ) - } + guard !isTesting + else { let privateDatabase = MockCloudDatabase(databaseScope: .private) let sharedDatabase = MockCloudDatabase(databaseScope: .shared) try self.init( container: MockCloudContainer( - containerIdentifier: "co.pointfree.sqlitedata-icloud.testing", + containerIdentifier: containerIdentifier ?? "co.pointfree.sqlitedata-icloud.testing", privateCloudDatabase: privateDatabase, sharedCloudDatabase: sharedDatabase ), @@ -94,7 +86,17 @@ userDatabase: userDatabase, metadatabase: metadatabase ) - return + return + } + + guard let containerIdentifier else { + throw SchemaError( + reason: .noCloudKitContainer, + debugDescription: """ + No default CloudKit container found. Please add a container identifier to your app's \ + entitlements. + """ + ) } let container = CKContainer(identifier: containerIdentifier) @@ -1430,7 +1432,7 @@ fileprivate static var datetime: Self { Self(.sqliteDataCloudKitSchemaName + "_datetime", argumentCount: 0) { _ in - @Dependency(\.date.now) var now + @Dependency(\.datetime.now) var now return now.formatted( .iso8601 .year().month().day() diff --git a/Tests/SharingGRDBTests/CloudKitTests/AssetsTests.swift b/Tests/SharingGRDBTests/CloudKitTests/AssetsTests.swift index 2a6b3d2d..0fd6da2c 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/AssetsTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/AssetsTests.swift @@ -70,7 +70,7 @@ extension BaseCloudKitTests { } try await withDependencies { - $0.date.now.addTimeInterval(1) + $0.datetime.now.addTimeInterval(1) } operation: { try await userDatabase.userWrite { db in try RemindersListAsset diff --git a/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift b/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift index 6a053e7e..84099044 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift @@ -611,7 +611,7 @@ extension BaseCloudKitTests { } try await withDependencies { - $0.date.now.addTimeInterval(60) + $0.datetime.now.addTimeInterval(60) } operation: { try await userDatabase.userWrite { db in try RemindersList @@ -762,7 +762,7 @@ extension BaseCloudKitTests { try await syncEngine.processPendingRecordZoneChanges(scope: .private) try await withDependencies { - $0.date.now.addTimeInterval(1) + $0.datetime.now.addTimeInterval(1) } operation: { try await userDatabase.userWrite { db in try RemindersList.find(1).update { $0.title = "My stuff" }.execute(db) diff --git a/Tests/SharingGRDBTests/CloudKitTests/FetchRecordZoneChangesTests.swift b/Tests/SharingGRDBTests/CloudKitTests/FetchRecordZoneChangesTests.swift index f3483154..c3051a28 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/FetchRecordZoneChangesTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/FetchRecordZoneChangesTests.swift @@ -61,7 +61,7 @@ extension BaseCloudKitTests { } try await withDependencies { - $0.date.now.addTimeInterval(1) + $0.datetime.now.addTimeInterval(1) } operation: { try await userDatabase.userWrite { db in try Reminder.find(1).update { $0.isCompleted.toggle() }.execute(db) @@ -116,7 +116,7 @@ extension BaseCloudKitTests { try await syncEngine.processPendingRecordZoneChanges(scope: .private) try await withDependencies { - $0.date.now.addTimeInterval(1) + $0.datetime.now.addTimeInterval(1) } operation: { let reminderRecord = try syncEngine.private.database .record(for: Reminder.recordID(for: 1)) @@ -245,7 +245,7 @@ extension BaseCloudKitTests { } try await withDependencies { - $0.date.now.addTimeInterval(1) + $0.datetime.now.addTimeInterval(1) } operation: { try await userDatabase.userWrite { db in try RemindersList.find(1).update { $0.title = "My stuff" }.execute(db) @@ -366,7 +366,7 @@ extension BaseCloudKitTests { } try await withDependencies { - $0.date.now.addTimeInterval(1) + $0.datetime.now.addTimeInterval(1) } operation: { try await userDatabase.userWrite { db in try Reminder.find(1).update { $0.title = "Buy milk" }.execute(db) diff --git a/Tests/SharingGRDBTests/CloudKitTests/ForeignKeyConstraintTests.swift b/Tests/SharingGRDBTests/CloudKitTests/ForeignKeyConstraintTests.swift index f3d79db6..692c1d7b 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/ForeignKeyConstraintTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/ForeignKeyConstraintTests.swift @@ -98,7 +98,7 @@ extension BaseCloudKitTests { } try await withDependencies { - $0.date.now.addTimeInterval(1) + $0.datetime.now.addTimeInterval(1) } operation: { try await userDatabase.userWrite { db in try Reminder.find(1).update { $0.title = "Buy milk" }.execute(db) @@ -388,7 +388,7 @@ extension BaseCloudKitTests { } try await withDependencies { - $0.date.now.addTimeInterval(1) + $0.datetime.now.addTimeInterval(1) } operation: { try await userDatabase.userWrite { db in try Reminder.find(1).update { $0.title = "Buy milk" }.execute(db) @@ -508,7 +508,7 @@ extension BaseCloudKitTests { } let modifications = try await withDependencies { - $0.date.now.addTimeInterval(1) + $0.datetime.now.addTimeInterval(1) } operation: { let reminderRecord = try syncEngine.private.database.record( for: Reminder.recordID(for: 1) @@ -591,7 +591,7 @@ extension BaseCloudKitTests { try await syncEngine.processPendingRecordZoneChanges(scope: .private) let modifications = try withDependencies { - $0.date.now.addTimeInterval(1) + $0.datetime.now.addTimeInterval(1) } operation: { let reminderRecord = try syncEngine.private.database .record(for: Reminder.recordID(for: 1)) @@ -604,7 +604,7 @@ extension BaseCloudKitTests { } try await withDependencies { - $0.date.now.addTimeInterval(2) + $0.datetime.now.addTimeInterval(2) } operation: { try await userDatabase.userWrite { db in try Reminder.find(1) @@ -674,7 +674,7 @@ extension BaseCloudKitTests { try await syncEngine.processPendingRecordZoneChanges(scope: .private) let modifications = try withDependencies { - $0.date.now.addTimeInterval(1) + $0.datetime.now.addTimeInterval(1) } operation: { let reminderRecord = try syncEngine.private.database .record(for: Reminder.recordID(for: 1)) @@ -687,7 +687,7 @@ extension BaseCloudKitTests { } try await withDependencies { - $0.date.now.addTimeInterval(2) + $0.datetime.now.addTimeInterval(2) } operation: { try await userDatabase.userWrite { db in try Reminder.find(1).update { $0.remindersListID = 3 }.execute(db) diff --git a/Tests/SharingGRDBTests/CloudKitTests/MergeConflictTests.swift b/Tests/SharingGRDBTests/CloudKitTests/MergeConflictTests.swift index 4e426e37..3416b803 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/MergeConflictTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/MergeConflictTests.swift @@ -68,7 +68,7 @@ extension BaseCloudKitTests { }() try await withDependencies { - $0.date.now = now.addingTimeInterval(30) + $0.datetime.now = now.addingTimeInterval(30) } operation: { try await userDatabase.userWrite { db in try Reminder.find(1).update { $0.isCompleted = true }.execute(db) @@ -222,7 +222,7 @@ extension BaseCloudKitTests { }() try await withDependencies { - $0.date.now = now.addingTimeInterval(60) + $0.datetime.now = now.addingTimeInterval(60) } operation: { try await userDatabase.userWrite { db in try Reminder.find(1).update { $0.isCompleted = true }.execute(db) @@ -335,7 +335,7 @@ extension BaseCloudKitTests { }() try await withDependencies { - $0.date.now = now.addingTimeInterval(60) + $0.datetime.now = now.addingTimeInterval(60) } operation: { try await userDatabase.userWrite { db in try Reminder.find(1).update { $0.isCompleted = true }.execute(db) @@ -404,7 +404,7 @@ extension BaseCloudKitTests { }() try await withDependencies { - $0.date.now = now.addingTimeInterval(30) + $0.datetime.now = now.addingTimeInterval(30) } operation: { try await userDatabase.userWrite { db in try Reminder.find(1).update { $0.title = "Get milk" }.execute(db) @@ -480,7 +480,7 @@ extension BaseCloudKitTests { }() try await withDependencies { - $0.date.now = now.addingTimeInterval(60) + $0.datetime.now = now.addingTimeInterval(60) } operation: { try await userDatabase.userWrite { db in try Reminder.find(1).update { $0.title = "Get milk" }.execute(db) @@ -549,7 +549,7 @@ extension BaseCloudKitTests { }() try await withDependencies { - $0.date.now = now.addingTimeInterval(60) + $0.datetime.now = now.addingTimeInterval(60) } operation: { try await userDatabase.userWrite { db in try Reminder.find(1).update { $0.title = "Get milk" }.execute(db) @@ -626,7 +626,7 @@ extension BaseCloudKitTests { ) try withDependencies { - $0.date.now.addTimeInterval(2) + $0.datetime.now.addTimeInterval(2) } operation: { try userDatabase.userWrite { db in try Reminder.find(1).update { $0.priority = 3 }.execute(db) diff --git a/Tests/SharingGRDBTests/CloudKitTests/MetadataTests.swift b/Tests/SharingGRDBTests/CloudKitTests/MetadataTests.swift index 998534bf..9470bf6f 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/MetadataTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/MetadataTests.swift @@ -73,7 +73,7 @@ extension BaseCloudKitTests { } try withDependencies { - $0.date.now.addTimeInterval(60) + $0.datetime.now.addTimeInterval(60) } operation: { _ = try { try userDatabase.userWrite { db in diff --git a/Tests/SharingGRDBTests/CloudKitTests/ReferenceViolationTests.swift b/Tests/SharingGRDBTests/CloudKitTests/ReferenceViolationTests.swift index ed31188e..47aeff38 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/ReferenceViolationTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/ReferenceViolationTests.swift @@ -30,7 +30,7 @@ extension BaseCloudKitTests { deleting: [RemindersList.recordID(for: 2)] ) try withDependencies { - $0.date.now.addTimeInterval(1) + $0.datetime.now.addTimeInterval(1) } operation: { try userDatabase.userWrite { db in try Reminder.find(1).update { $0.remindersListID = 2 }.execute(db) @@ -90,14 +90,14 @@ extension BaseCloudKitTests { try await syncEngine.processPendingRecordZoneChanges(scope: .private) try await withDependencies { - $0.date.now.addTimeInterval(1) + $0.datetime.now.addTimeInterval(1) } operation: { try await userDatabase.userWrite { db in try RemindersList.find(1).delete().execute(db) } } let modifications = try withDependencies { - $0.date.now.addTimeInterval(2) + $0.datetime.now.addTimeInterval(2) } operation: { let reminderRecord = CKRecord( recordType: Reminder.tableName, @@ -174,14 +174,14 @@ extension BaseCloudKitTests { try await syncEngine.processPendingRecordZoneChanges(scope: .private) try await withDependencies { - $0.date.now.addTimeInterval(1) + $0.datetime.now.addTimeInterval(1) } operation: { try await userDatabase.userWrite { db in try RemindersList.find(1).delete().execute(db) } } let modifications = try withDependencies { - $0.date.now.addTimeInterval(2) + $0.datetime.now.addTimeInterval(2) } operation: { let reminderRecord = CKRecord( recordType: Reminder.tableName, @@ -264,14 +264,14 @@ extension BaseCloudKitTests { deleting: [Parent.recordID(for: 2)] ) try await withDependencies { - $0.date.now.addTimeInterval(1) + $0.datetime.now.addTimeInterval(1) } operation: { try await userDatabase.userWrite { db in try ChildWithOnDeleteSetNull.find(1).update { $0.parentID = 2 }.execute(db) } } try await withDependencies { - $0.date.now.addTimeInterval(2) + $0.datetime.now.addTimeInterval(2) } operation: { try await syncEngine.processPendingRecordZoneChanges(scope: .private) await modifications.notify() @@ -344,14 +344,14 @@ extension BaseCloudKitTests { deleting: [Parent.recordID(for: 2)] ) try await withDependencies { - $0.date.now.addTimeInterval(1) + $0.datetime.now.addTimeInterval(1) } operation: { try await userDatabase.userWrite { db in try ChildWithOnDeleteSetDefault.find(1).update { $0.parentID = 2 }.execute(db) } } try await withDependencies { - $0.date.now.addTimeInterval(2) + $0.datetime.now.addTimeInterval(2) } operation: { try await syncEngine.processPendingRecordZoneChanges(scope: .private) await modifications.notify() diff --git a/Tests/SharingGRDBTests/CloudKitTests/SchemaChangeTests.swift b/Tests/SharingGRDBTests/CloudKitTests/SchemaChangeTests.swift index 352edb6e..c0be6c99 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/SchemaChangeTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/SchemaChangeTests.swift @@ -30,7 +30,7 @@ extension BaseCloudKitTests { try await syncEngine.processPendingRecordZoneChanges(scope: .private) try await withDependencies { - $0.date.now.addTimeInterval(60) + $0.datetime.now.addTimeInterval(60) } operation: { let personalListRecord = try syncEngine.private.database.record( for: RemindersList.recordID(for: 1) @@ -120,7 +120,7 @@ extension BaseCloudKitTests { try await syncEngine.processPendingRecordZoneChanges(scope: .private) try await withDependencies { - $0.date.now.addTimeInterval(60) + $0.datetime.now.addTimeInterval(60) } operation: { let personalListRecord = try syncEngine.private.database.record( for: RemindersList.recordID(for: 1) @@ -179,7 +179,7 @@ extension BaseCloudKitTests { try await syncEngine.processPendingRecordZoneChanges(scope: .private) try await withDependencies { - $0.date.now.addTimeInterval(60) + $0.datetime.now.addTimeInterval(60) } operation: { let personalListRecord = try syncEngine.private.database.record( for: RemindersList.recordID(for: 1) @@ -240,7 +240,7 @@ extension BaseCloudKitTests { @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) @Test func newTable() async throws { try await withDependencies { - $0.date.now.addTimeInterval(60) + $0.datetime.now.addTimeInterval(60) } operation: { let imageRecord = CKRecord( recordType: "images", diff --git a/Tests/SharingGRDBTests/CloudKitTests/SharingTests.swift b/Tests/SharingGRDBTests/CloudKitTests/SharingTests.swift index 6a0010d3..aa6d0880 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/SharingTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/SharingTests.swift @@ -159,7 +159,7 @@ extension BaseCloudKitTests { try await syncEngine.modifyRecords(scope: .shared, saving: [remindersListRecord]).notify() try await withDependencies { - $0.date.now.addTimeInterval(60) + $0.datetime.now.addTimeInterval(60) } operation: { try await userDatabase.userWrite { db in try db.seed { @@ -321,7 +321,7 @@ extension BaseCloudKitTests { try await syncEngine.modifyRecords(scope: .shared, saving: [modelARecord]).notify() try await withDependencies { - $0.date.now.addTimeInterval(60) + $0.datetime.now.addTimeInterval(60) } operation: { try await userDatabase.userWrite { db in try db.seed { @@ -404,7 +404,7 @@ extension BaseCloudKitTests { try await syncEngine.modifyRecords(scope: .shared, saving: [remindersListRecord, reminderRecord]).notify() try await withDependencies { - $0.date.now.addTimeInterval(60) + $0.datetime.now.addTimeInterval(60) } operation: { try await userDatabase.userWrite { db in try Reminder.find(1).delete().execute(db) diff --git a/Tests/SharingGRDBTests/CloudKitTests/UserlandTests.swift b/Tests/SharingGRDBTests/CloudKitTests/UserlandTests.swift new file mode 100644 index 00000000..988cb476 --- /dev/null +++ b/Tests/SharingGRDBTests/CloudKitTests/UserlandTests.swift @@ -0,0 +1,31 @@ +import Foundation +import Testing +import SharingGRDB + +@Suite struct UserlandTests { + @Test func basics() async throws { + let database = try SharingGRDBTests.database(containerIdentifier: "tests") + let syncEngine = try SyncEngine( + for: database, + tables: ModelA.self, + ModelB.self, + ModelC.self, + containerIdentifier: "tests" + ) + + try await withDependencies { + $0.defaultDatabase = database + $0.defaultSyncEngine = syncEngine + $0.datetime.now = Date.init(timeIntervalSince1970: 1) + } operation: { + @FetchAll var modelAs: [ModelA] = [] + try await database.write { db in + try db.seed { + ModelA.Draft() + } + } + try await $modelAs.load() + #expect(modelAs == [ModelA(id: 1)]) + } + } +} diff --git a/Tests/SharingGRDBTests/Internal/BaseCloudKitTests.swift b/Tests/SharingGRDBTests/Internal/BaseCloudKitTests.swift index 819652eb..76c74479 100644 --- a/Tests/SharingGRDBTests/Internal/BaseCloudKitTests.swift +++ b/Tests/SharingGRDBTests/Internal/BaseCloudKitTests.swift @@ -9,7 +9,7 @@ import os @Suite( .snapshots(record: .missing), .dependencies { - $0.date.now = Date(timeIntervalSince1970: 0) + $0.datetime.now = Date(timeIntervalSince1970: 0) $0.dataManager = InMemoryDataManager() } ) @@ -18,7 +18,7 @@ class BaseCloudKitTests: @unchecked Sendable { let userDatabase: UserDatabase private let _syncEngine: any Sendable - @Dependency(\.date.now) var now + @Dependency(\.datetime.now) var now @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) var syncEngine: SyncEngine { diff --git a/Tests/SharingGRDBTests/Internal/Schema.swift b/Tests/SharingGRDBTests/Internal/Schema.swift index 540e0489..b499eced 100644 --- a/Tests/SharingGRDBTests/Internal/Schema.swift +++ b/Tests/SharingGRDBTests/Internal/Schema.swift @@ -52,16 +52,16 @@ import SharingGRDB var name = "" var parentID: LocalUser.ID? } -@Table struct ModelA: Identifiable { +@Table struct ModelA: Equatable, Identifiable { let id: Int var count = 0 } -@Table struct ModelB: Identifiable { +@Table struct ModelB: Equatable, Identifiable { let id: Int var isOn = false var modelAID: ModelA.ID } -@Table struct ModelC: Identifiable { +@Table struct ModelC: Equatable, Identifiable { let id: Int var title = "" var modelBID: ModelB.ID From 28afbac63cea4549e768d5db886b6980ca04c02b Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Wed, 13 Aug 2025 14:36:45 -0500 Subject: [PATCH 3/6] wip --- .../SharingGRDBCore/CloudKit/Internal/MockCloudContainer.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/SharingGRDBCore/CloudKit/Internal/MockCloudContainer.swift b/Sources/SharingGRDBCore/CloudKit/Internal/MockCloudContainer.swift index e45f977f..56fe5bf9 100644 --- a/Sources/SharingGRDBCore/CloudKit/Internal/MockCloudContainer.swift +++ b/Sources/SharingGRDBCore/CloudKit/Internal/MockCloudContainer.swift @@ -90,7 +90,7 @@ private enum MockCloudContainersKey: TestDependencyKey { extension DependencyValues { @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - package var mockCloudContainers: LockIsolated<[String: MockCloudContainer]> { + fileprivate var mockCloudContainers: LockIsolated<[String: MockCloudContainer]> { get { self[MockCloudContainersKey.self] } From a16e972e6f5dcfc3d1bafe9f7a9efc917195cf68 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Wed, 13 Aug 2025 14:44:01 -0500 Subject: [PATCH 4/6] wip --- Examples/Examples.xcodeproj/project.pbxproj | 56 ++++++++++--------- .../xcshareddata/swiftpm/Package.resolved | 4 +- 2 files changed, 31 insertions(+), 29 deletions(-) diff --git a/Examples/Examples.xcodeproj/project.pbxproj b/Examples/Examples.xcodeproj/project.pbxproj index 1de241e6..bc3e7f5b 100644 --- a/Examples/Examples.xcodeproj/project.pbxproj +++ b/Examples/Examples.xcodeproj/project.pbxproj @@ -7,13 +7,14 @@ objects = { /* Begin PBXBuildFile section */ - CA0F43C32E4D06740086070B /* SharingGRDB in Frameworks */ = {isa = PBXBuildFile; productRef = CA0F43C22E4D06740086070B /* SharingGRDB */; }; - CA0F43C62E4D06C60086070B /* GRDB in Frameworks */ = {isa = PBXBuildFile; productRef = CA0F43C52E4D06C60086070B /* GRDB */; }; CA1146CA2DF38D1D0054BA77 /* SharingGRDB in Frameworks */ = {isa = PBXBuildFile; productRef = CA1146C92DF38D1D0054BA77 /* SharingGRDB */; }; CA14DBC92DA884C400E36852 /* CasePaths in Frameworks */ = {isa = PBXBuildFile; productRef = CA14DBC82DA884C400E36852 /* CasePaths */; }; CA2908C92D4AF70E003F165F /* UIKitNavigation in Frameworks */ = {isa = PBXBuildFile; productRef = CA2908C82D4AF70E003F165F /* UIKitNavigation */; }; CA42392E2DF7219E000AF560 /* SwiftUINavigation in Frameworks */ = {isa = PBXBuildFile; productRef = CA42392D2DF7219E000AF560 /* SwiftUINavigation */; }; CA9102EB2E1F299900F85DD0 /* SharingGRDB in Frameworks */ = {isa = PBXBuildFile; productRef = CA9102EA2E1F299900F85DD0 /* SharingGRDB */; }; + CA9102ED2E1F29A400F85DD0 /* SharingGRDB in Frameworks */ = {isa = PBXBuildFile; productRef = CA9102EC2E1F29A400F85DD0 /* SharingGRDB */; }; + CA9102EF2E1F29AA00F85DD0 /* SharingGRDB in Frameworks */ = {isa = PBXBuildFile; productRef = CA9102EE2E1F29AA00F85DD0 /* SharingGRDB */; }; + CA9102F12E1F29E300F85DD0 /* SharingGRDB in Frameworks */ = {isa = PBXBuildFile; productRef = CA9102F02E1F29E300F85DD0 /* SharingGRDB */; }; CA9F99D82DF915D300934431 /* DependenciesTestSupport in Frameworks */ = {isa = PBXBuildFile; productRef = CA9F99D72DF915D300934431 /* DependenciesTestSupport */; }; CA9F99DD2DF9185A00934431 /* SnapshotTestingCustomDump in Frameworks */ = {isa = PBXBuildFile; productRef = CA9F99DC2DF9185A00934431 /* SnapshotTestingCustomDump */; }; CA9F99DF2DF9190C00934431 /* InlineSnapshotTesting in Frameworks */ = {isa = PBXBuildFile; productRef = CA9F99DE2DF9190C00934431 /* InlineSnapshotTesting */; }; @@ -194,6 +195,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + CA9102F12E1F29E300F85DD0 /* SharingGRDB in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -204,7 +206,6 @@ CA9F99DD2DF9185A00934431 /* SnapshotTestingCustomDump in Frameworks */, CA9F99D82DF915D300934431 /* DependenciesTestSupport in Frameworks */, CA9F99DF2DF9190C00934431 /* InlineSnapshotTesting in Frameworks */, - CA0F43C62E4D06C60086070B /* GRDB in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -238,7 +239,7 @@ buildActionMask = 2147483647; files = ( CA14DBC92DA884C400E36852 /* CasePaths in Frameworks */, - CA0F43C32E4D06740086070B /* SharingGRDB in Frameworks */, + CA9102ED2E1F29A400F85DD0 /* SharingGRDB in Frameworks */, DC7082542E035FC500A66B7D /* SwiftUINavigation in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -249,6 +250,7 @@ files = ( DCF267392D48437300B680BE /* SwiftUINavigation in Frameworks */, DC5FA7482D4C63D60082743E /* DependenciesMacros in Frameworks */, + CA9102EF2E1F29AA00F85DD0 /* SharingGRDB in Frameworks */, DCBE8A142D4842BF0071F499 /* CasePaths in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -365,6 +367,7 @@ ); name = CloudKitPlayground; packageProductDependencies = ( + CA9102F02E1F29E300F85DD0 /* SharingGRDB */, ); productName = CloudKitPlayground; productReference = CA9101C82E1F270100F85DD0 /* CloudKitPlayground.app */; @@ -391,7 +394,6 @@ CA9F99D72DF915D300934431 /* DependenciesTestSupport */, CA9F99DC2DF9185A00934431 /* SnapshotTestingCustomDump */, CA9F99DE2DF9190C00934431 /* InlineSnapshotTesting */, - CA0F43C52E4D06C60086070B /* GRDB */, ); productName = RemindersTests; productReference = CA9F99482DF9134D00934431 /* RemindersTests.xctest */; @@ -488,7 +490,7 @@ packageProductDependencies = ( CA14DBC82DA884C400E36852 /* CasePaths */, DC7082532E035FC500A66B7D /* SwiftUINavigation */, - CA0F43C22E4D06740086070B /* SharingGRDB */, + CA9102EC2E1F29A400F85DD0 /* SharingGRDB */, ); productName = Reminders; productReference = CAF836D82D4735AB0047AEB5 /* Reminders.app */; @@ -514,6 +516,7 @@ DCBE8A132D4842BF0071F499 /* CasePaths */, DCF267382D48437300B680BE /* SwiftUINavigation */, DC5FA7472D4C63D60082743E /* DependenciesMacros */, + CA9102EE2E1F29AA00F85DD0 /* SharingGRDB */, ); productName = SyncUps; productReference = DCBE89CC2D483FB90071F499 /* SyncUps.app */; @@ -575,8 +578,7 @@ DCBE8A122D4842BF0071F499 /* XCRemoteSwiftPackageReference "swift-case-paths" */, DCF267372D48437300B680BE /* XCRemoteSwiftPackageReference "swift-navigation" */, DC5FA7462D4C63D60082743E /* XCRemoteSwiftPackageReference "swift-dependencies" */, - CA0F43C12E4D06740086070B /* XCLocalSwiftPackageReference "../../sharing-grdb" */, - CA0F43C42E4D06C60086070B /* XCRemoteSwiftPackageReference "GRDB" */, + CA9102E92E1F299900F85DD0 /* XCLocalSwiftPackageReference ".." */, ); preferredProjectObjectVersion = 77; productRefGroup = CAF836992D4735620047AEB5 /* Products */; @@ -1070,6 +1072,7 @@ SDKROOT = iphoneos; SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_UPCOMING_FEATURE_EXISTENTIAL_ANY = YES; SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; SWIFT_VERSION = 6.0; }; @@ -1127,6 +1130,7 @@ MTL_FAST_MATH = YES; SDKROOT = iphoneos; SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_UPCOMING_FEATURE_EXISTENTIAL_ANY = YES; SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; SWIFT_VERSION = 6.0; VALIDATE_PRODUCT = YES; @@ -1433,21 +1437,13 @@ /* End XCConfigurationList section */ /* Begin XCLocalSwiftPackageReference section */ - CA0F43C12E4D06740086070B /* XCLocalSwiftPackageReference "../../sharing-grdb" */ = { + CA9102E92E1F299900F85DD0 /* XCLocalSwiftPackageReference ".." */ = { isa = XCLocalSwiftPackageReference; - relativePath = "../../sharing-grdb"; + relativePath = ..; }; /* End XCLocalSwiftPackageReference section */ /* Begin XCRemoteSwiftPackageReference section */ - CA0F43C42E4D06C60086070B /* XCRemoteSwiftPackageReference "GRDB" */ = { - isa = XCRemoteSwiftPackageReference; - repositoryURL = "http://github.com/groue/GRDB.swift"; - requirement = { - kind = upToNextMajorVersion; - minimumVersion = 7.0.0; - }; - }; CA9F99D92DF9185A00934431 /* XCRemoteSwiftPackageReference "swift-snapshot-testing" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/pointfreeco/swift-snapshot-testing.git"; @@ -1483,15 +1479,6 @@ /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ - CA0F43C22E4D06740086070B /* SharingGRDB */ = { - isa = XCSwiftPackageProductDependency; - productName = SharingGRDB; - }; - CA0F43C52E4D06C60086070B /* GRDB */ = { - isa = XCSwiftPackageProductDependency; - package = CA0F43C42E4D06C60086070B /* XCRemoteSwiftPackageReference "GRDB" */; - productName = GRDB; - }; CA1146C92DF38D1D0054BA77 /* SharingGRDB */ = { isa = XCSwiftPackageProductDependency; productName = SharingGRDB; @@ -1515,6 +1502,21 @@ isa = XCSwiftPackageProductDependency; productName = SharingGRDB; }; + CA9102EC2E1F29A400F85DD0 /* SharingGRDB */ = { + isa = XCSwiftPackageProductDependency; + package = CA9102E92E1F299900F85DD0 /* XCLocalSwiftPackageReference ".." */; + productName = SharingGRDB; + }; + CA9102EE2E1F29AA00F85DD0 /* SharingGRDB */ = { + isa = XCSwiftPackageProductDependency; + package = CA9102E92E1F299900F85DD0 /* XCLocalSwiftPackageReference ".." */; + productName = SharingGRDB; + }; + CA9102F02E1F29E300F85DD0 /* SharingGRDB */ = { + isa = XCSwiftPackageProductDependency; + package = CA9102E92E1F299900F85DD0 /* XCLocalSwiftPackageReference ".." */; + productName = SharingGRDB; + }; CA9F99D72DF915D300934431 /* DependenciesTestSupport */ = { isa = XCSwiftPackageProductDependency; package = DC5FA7462D4C63D60082743E /* XCRemoteSwiftPackageReference "swift-dependencies" */; diff --git a/Examples/Examples.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Examples/Examples.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 75543a5a..bcf55766 100644 --- a/Examples/Examples.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Examples/Examples.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "36222981020fdf2c13a4a981b023e3be675cc4aebd4b842b561cd1f9bff923d0", + "originHash" : "b79958a17ad17f026cb1bfeb29707111c20562741e2ca36a9cc6564f6dcec338", "pins" : [ { "identity" : "combine-schedulers", @@ -13,7 +13,7 @@ { "identity" : "grdb.swift", "kind" : "remoteSourceControl", - "location" : "http://github.com/groue/GRDB.swift", + "location" : "https://github.com/groue/GRDB.swift", "state" : { "revision" : "a5a1be26b4513dc7ec360eb56bc08a345bac6649", "version" : "7.5.0" From 926810dc1a28743f261f2169b24a26ab3221f0fb Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Wed, 13 Aug 2025 15:14:08 -0500 Subject: [PATCH 5/6] add a test --- .../CloudKitTests/MockCloudDatabaseTests.swift | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/Tests/SharingGRDBTests/CloudKitTests/MockCloudDatabaseTests.swift b/Tests/SharingGRDBTests/CloudKitTests/MockCloudDatabaseTests.swift index e38571f1..e052fe28 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/MockCloudDatabaseTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/MockCloudDatabaseTests.swift @@ -373,5 +373,20 @@ extension BaseCloudKitTests { } #expect(error == CKError(.notAuthenticated)) } + + @Test func incorrectlyCreatingNewRecordIdentity() async throws { + let record1 = CKRecord(recordType: "A", recordID: CKRecord.ID.init(recordName: "1")) + try syncEngine.modifyRecords(scope: .private, saving: [record1]) + let record2 = CKRecord(recordType: "A", recordID: CKRecord.ID.init(recordName: "1")) + try withKnownIssue { + try syncEngine.modifyRecords(scope: .private, saving: [record2]) + } matching: { issue in + issue.description == """ + Issue recorded: A new identity was created for an existing 'CKRecord' ('1'). Rather than \ + creating 'CKRecord' from scratch for an existing record, use the database to fetch the \ + current record. + """ + } + } } } From c6548f87d9e826a6f7681c27863aa6a1c38e9b6b Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Wed, 13 Aug 2025 15:15:33 -0500 Subject: [PATCH 6/6] wip --- .../CloudKitTests/MockCloudDatabaseTests.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Tests/SharingGRDBTests/CloudKitTests/MockCloudDatabaseTests.swift b/Tests/SharingGRDBTests/CloudKitTests/MockCloudDatabaseTests.swift index e052fe28..31822f41 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/MockCloudDatabaseTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/MockCloudDatabaseTests.swift @@ -376,10 +376,10 @@ extension BaseCloudKitTests { @Test func incorrectlyCreatingNewRecordIdentity() async throws { let record1 = CKRecord(recordType: "A", recordID: CKRecord.ID.init(recordName: "1")) - try syncEngine.modifyRecords(scope: .private, saving: [record1]) + _ = try syncEngine.modifyRecords(scope: .private, saving: [record1]) let record2 = CKRecord(recordType: "A", recordID: CKRecord.ID.init(recordName: "1")) try withKnownIssue { - try syncEngine.modifyRecords(scope: .private, saving: [record2]) + _ = try syncEngine.modifyRecords(scope: .private, saving: [record2]) } matching: { issue in issue.description == """ Issue recorded: A new identity was created for an existing 'CKRecord' ('1'). Rather than \