diff --git a/Package.resolved b/Package.resolved index d73ca91b..19053c54 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "32f70dab99ea92a018a46453e9ed1e8bbf1e5d17b2993ba8abc63cae49896bbc", + "originHash" : "32a0b9db128d9e5f29bd4495238ec304eafe64f2e75d45e792189c983fbdc49c", "pins" : [ { "identity" : "combine-schedulers", @@ -136,6 +136,15 @@ "version" : "601.0.1" } }, + { + "identity" : "swift-tagged", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-tagged", + "state" : { + "revision" : "3907a9438f5b57d317001dc99f3f11b46882272b", + "version" : "0.10.0" + } + }, { "identity" : "xctest-dynamic-overlay", "kind" : "remoteSourceControl", diff --git a/Sources/SQLiteData/CloudKit/Internal/Metadatabase.swift b/Sources/SQLiteData/CloudKit/Internal/Metadatabase.swift index d7198cb2..cba0d65d 100644 --- a/Sources/SQLiteData/CloudKit/Internal/Metadatabase.swift +++ b/Sources/SQLiteData/CloudKit/Internal/Metadatabase.swift @@ -75,6 +75,12 @@ """ ) .execute(db) + try #sql( + """ + CREATE INDEX "\(raw: .sqliteDataCloudKitSchemaName)_metadata_zoneID" + ON "\(raw: .sqliteDataCloudKitSchemaName)_metadata"("ownerName", "zoneName") + """ + ) try #sql( """ CREATE INDEX "\(raw: .sqliteDataCloudKitSchemaName)_metadata_parentRecordName" diff --git a/Sources/SQLiteData/CloudKit/SyncEngine.swift b/Sources/SQLiteData/CloudKit/SyncEngine.swift index cc101c2a..4f9a5d29 100644 --- a/Sources/SQLiteData/CloudKit/SyncEngine.swift +++ b/Sources/SQLiteData/CloudKit/SyncEngine.swift @@ -1138,12 +1138,12 @@ try await userDatabase.write { db in var defaultZoneDeleted = false for (zoneID, reason) in deletions { - guard zoneID == self.defaultZone.zoneID - else { continue } switch reason { case .deleted, .purged: try deleteRecords(in: zoneID, db: db) - defaultZoneDeleted = true + if zoneID == self.defaultZone.zoneID { + defaultZoneDeleted = true + } case .encryptedDataReset: try uploadRecords(in: zoneID, db: db) @unknown default: @@ -1159,19 +1159,23 @@ } @Sendable func deleteRecords(in zoneID: CKRecordZone.ID, db: Database) throws { - let recordTypes = Set( - try SyncMetadata - .where(\.hasLastKnownServerRecord) - .select(\.lastKnownServerRecord) - .fetchAll(db) - .compactMap { $0?.recordID.zoneID == zoneID ? $0?.recordType : nil } + let recordTypes = Dictionary( + grouping: + try SyncMetadata + .where { $0.zoneName.eq(zoneID.zoneName) && $0.ownerName.eq(zoneID.ownerName) } + .select { ($0.recordType, $0.recordPrimaryKey) } + .fetchAll(db), + by: \.0 ) - for recordType in recordTypes { + .mapValues { + $0.map(\.1) + } + for (recordType, primaryKeys) in recordTypes { guard let table = tablesByName[recordType] else { continue } func open(_: T.Type) { withErrorReporting(.sqliteDataCloudKitFailure) { - try T.delete().execute(db) + try T.where { #sql("\($0.primaryKey)").in(primaryKeys) }.delete().execute(db) } } open(table) diff --git a/Tests/SQLiteDataTests/CloudKitTests/SharingTests.swift b/Tests/SQLiteDataTests/CloudKitTests/SharingTests.swift index 9b4c2f54..7c9646e9 100644 --- a/Tests/SQLiteDataTests/CloudKitTests/SharingTests.swift +++ b/Tests/SQLiteDataTests/CloudKitTests/SharingTests.swift @@ -1085,6 +1085,74 @@ } } + /// Syncing deletion of a root shared record that is not owned by current user should delete + /// entire zone. + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func syncDeletedRootSharedRecord_CurrentUserNotOwner() async throws { + let externalZone = CKRecordZone( + zoneID: CKRecordZone.ID( + zoneName: "external.zone", + ownerName: "external.owner" + ) + ) + try await syncEngine.modifyRecordZones(scope: .shared, saving: [externalZone]).notify() + + let remindersListRecord = CKRecord( + recordType: RemindersList.tableName, + recordID: RemindersList.recordID(for: 1, zoneID: externalZone.zoneID) + ) + remindersListRecord.setValue(1, forKey: "id", at: now) + remindersListRecord.setValue("Personal", forKey: "title", at: now) + let share = CKShare( + rootRecord: remindersListRecord, + shareID: CKRecord.ID( + recordName: "share-\(remindersListRecord.recordID.recordName)", + zoneID: remindersListRecord.recordID.zoneID + ) + ) + + try await syncEngine + .acceptShare( + metadata: ShareMetadata( + containerIdentifier: container.containerIdentifier!, + hierarchicalRootRecordID: remindersListRecord.recordID, + rootRecord: remindersListRecord, + share: share + ) + ) + + try await userDatabase.userWrite { db in + try db.seed { + Reminder(id: 1, title: "Get milk", remindersListID: 1) + Reminder(id: 2, title: "Take a walk", remindersListID: 1) + } + } + + try await syncEngine.processPendingRecordZoneChanges(scope: .shared) + + try await syncEngine.modifyRecordZones(scope: .shared, deleting: [externalZone.zoneID]) + .notify() + + assertQuery(Reminder.all, database: userDatabase.database) + assertQuery(RemindersList.all, database: userDatabase.database) + assertQuery(SyncMetadata.all, database: syncEngine.metadatabase) + + assertInlineSnapshot(of: container, as: .customDump) { + """ + MockCloudContainer( + privateCloudDatabase: MockCloudDatabase( + databaseScope: .private, + storage: [] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [] + ) + ) + """ + } + } + // NB: Come back to this when we have time to investigate. // /// Deleting a root shared record that is not owned by current user should only delete // /// the CKShare, not delete the actual CloudKit records, but delete all the local records.